openvibers/openvibe.space Commits

Space functions: export/export default/module.exports handlers work (the AI builder wrote export async function handle), always run as a script, and an error names its line (#64)

OP OpenVibers committed 28 days ago · 4ae17af52c · 4 files, +52 −6

Changes

ddiff --git a/docs/spaces.md b/docs/spaces.md
iindex 9311e15..50c4012 100644
---- a/docs/spaces.md
++++ b/docs/spaces.md
@@@ -72,6 +72,9 @@ async function handle(request, ov) {
}
```
++- **How to write it:** a plain script. `export function handle`, `export default function (…)` and
++ `module.exports = { handle }` work too; there is no `import` or `require`. An error answers 500 with its message and
++ line number.
- **Where it runs:** on a published space at `https://<slug>.openvibe.website/api/<name>` (GET, HEAD and POST; it is
the published copy that runs, so a saved change waits for the next publish). The page calls it with
`OV.fn('hello')`, and a link or form may point at `/api/hello` directly. In the editor, the preview runs the copy in
ddiff --git a/server/spaces/functions-worker.js b/server/spaces/functions-worker.js
iindex a2bf924..244d46a 100644
---- a/server/spaces/functions-worker.js
++++ b/server/spaces/functions-worker.js
@@@ -24,6 +24,9 @@ const __call = async (op, args) => {
if (r.error) { const e = new Error(r.error.message); e.code = r.error.code; throw e; }
return r.result;
};
++// CommonJS spellings work too: module.exports = { handle } or module.exports = async (request, ov) => …
++globalThis.module = { exports: {} };
++globalThis.exports = globalThis.module.exports;
const __ctx = JSON.parse(__context);
globalThis.ov = Object.freeze({
user: __ctx.user ? Object.freeze(__ctx.user) : null,
@@@ -93,7 +96,7 @@ async function run({ id, name, code, request, context, limits }) {
let loaded;
try {
vm.unwrapResult(vm.evalCode(PRELUDE, 'prelude.js')).dispose();
-- loaded = vm.evalCode(code, `functions/${name}.js`);
++ loaded = vm.evalCode(code, `functions/${name}.js`, { type: 'global' });
} finally { leave(); }
if (loaded.error) {
const e = vm.dump(loaded.error); loaded.error.dispose();
@@@ -104,10 +107,15 @@ async function run({ id, name, code, request, context, limits }) {
let started;
try {
started = vm.evalCode(`(async () => {
-- if (typeof handle !== 'function') throw new Error('it must define handle(request, ov)');
-- const out = await handle(JSON.parse(__request), ov);
++ const m = globalThis.module.exports;
++ const fn = typeof handle === 'function' ? handle : typeof m === 'function' ? m : m && (typeof m.handle === 'function' ? m.handle : typeof m.default === 'function' ? m.default : null);
++ if (!fn) throw new Error('it must define handle(request, ov)');
++ const out = await fn(JSON.parse(__request), ov);
return JSON.stringify({ out: out === undefined ? null : out, logs: __logs });
--})().catch((e) => JSON.stringify({ failed: String((e && e.message) || e), logs: __logs }))`, 'run.js');
++})().catch((e) => {
++ const at = new RegExp('functions/[^:)]+:([0-9]+)').exec(String((e && e.stack) || ''));
++ return JSON.stringify({ failed: String((e && e.message) || e) + (at ? ' (line ' + at[1] + ')' : ''), logs: __logs });
++})`, 'run.js');
} finally { leave(); }
const promiseHandle = vm.unwrapResult(started);
const settled = vm.resolvePromise(promiseHandle);
ddiff --git a/server/spaces/functions.js b/server/spaces/functions.js
iindex ce88db0..3af0456 100644
---- a/server/spaces/functions.js
++++ b/server/spaces/functions.js
@@@ -143,7 +143,7 @@ function createSpaceFunctions({ data, workers = 2, queueMax = 32, log = console,
user: visitor ? { username: visitor.person.username, display_name: visitor.person.name || visitor.person.username } : null,
space: { id: space.id, slug: space.slug, name: space.name },
};
-- const r = await submit({ name, code, request, context, ops_: opsFor(space, visitor) });
++ const r = await submit({ name, code: asScript(code), request, context, ops_: opsFor(space, visitor) });
const fail = (status, text, logs = []) => ({ status, headers: { 'content-type': 'text/plain; charset=utf-8' }, body: text, logs });
if (r.busy) return fail(503, 'Space functions are busy right now; try again in a moment.');
if (r.error) {
@@@ -165,6 +165,21 @@ function createSpaceFunctions({ data, workers = 2, queueMax = 32, log = console,
return { call, close, NAME_RE };
}
++/**
++ * The function as a plain script: the module spellings people (and models) write still work. `export` goes from
++ * `export [async] function|const|let|var|class`, `export default [async] function handle`, `export { … }` and
++ * `export default handle;`; an anonymous `export default [async] function (…)` becomes the handler. Lines keep their
++ * numbers, so errors still point at the right one.
++ */
++function asScript(code) {
++ return code
++ .replace(/^([ \t]*)export[ \t]+default[ \t]+(async[ \t]+)?function[ \t]*\(/m, (m, i, a) => `${i}globalThis.handle = ${a || ''}function (`)
++ .replace(/^([ \t]*)export[ \t]+default[ \t]+(async[ \t]+)?function[ \t]+handle\b/m, (m, i, a) => `${i}${a || ''}function handle`)
++ .replace(/^([ \t]*)export[ \t]+(?=(?:async[ \t]+)?function\b|const\b|let\b|var\b|class\b)/gm, '$1')
++ .replace(/^([ \t]*)export[ \t]*\{[^}\n]*\}[ \t]*;?/gm, '$1')
++ .replace(/^([ \t]*)export[ \t]+default[ \t]+handle[ \t]*;?/gm, '$1');
++}
++
/** What handle returned → { status, headers, body (string) }. */
function toResponse(out) {
const kind = (s) => (/^\s*</.test(s) ? 'text/html; charset=utf-8' : 'text/plain; charset=utf-8');
@@@ -184,4 +199,4 @@ function toResponse(out) {
return { status: 200, headers: { 'content-type': 'application/json; charset=utf-8' }, body: JSON.stringify(out) };
}
--module.exports = { createSpaceFunctions, toResponse, NAME_RE, FN_DEADLINE_MS, FN_CPU_MS, MAX_SOURCE };
++module.exports = { createSpaceFunctions, toResponse, asScript, NAME_RE, FN_DEADLINE_MS, FN_CPU_MS, MAX_SOURCE };
ddiff --git a/test/space-functions.test.js b/test/space-functions.test.js
iindex 87f533e..4cab7db 100644
---- a/test/space-functions.test.js
++++ b/test/space-functions.test.js
@@@ -58,6 +58,26 @@ const b64url = (buf) => buf.toString('base64').replace(/\+/g, '-').replace(/\//g
assert.strictEqual((await run('function handle() { return "still fine"; }')).body, 'still fine', 'the worker survives all of it');
});
++ await check('the module and CommonJS spellings of a handler work (models write them)', async () => {
++ const spellings = {
++ 'export async function handle(r, ov) {\n return "a";\n}': 'a',
++ 'export default async function handle() { return "b"; }': 'b',
++ 'export default function (r) { return "c" + r.method; }': 'cGET',
++ 'const handle = async () => "d";\nexport { handle };': 'd',
++ 'async function handle() { return "e"; }\nexport default handle;': 'e',
++ 'module.exports = { handle: async () => "f" };': 'f',
++ 'module.exports = async () => "g";': 'g',
++ 'exports.handle = () => "h";': 'h',
++ 'export const handle = () => "i";': 'i',
++ };
++ for (const [code, want] of Object.entries(spellings)) assert.strictEqual((await run(code)).body, want, code);
++ const err = await run('// one\n// two\nexport function handle() { return nope; }');
++ assert.match(err.body, /'?nope'? is not defined \(line 3\)/, 'the error and its line');
++ const { asScript } = require('../server/spaces/functions');
++ assert.strictEqual(asScript('a\nexport function handle() {}\nb').split('\n').length, 3, 'lines keep their numbers');
++ assert.strictEqual(asScript('const s = "export function x";'), 'const s = "export function x";', 'only at the start of a line');
++ });
++
await check('one run makes at most 100 ov calls; a caller makes a limited number of calls a minute', async () => {
const many = await run('async function handle(r, ov) { for (let i = 0; i < 150; i++) await ov.store.get("k"); return "done"; }');
assert.deepStrictEqual([many.status, /at most 100 ov calls/.test(many.body)], [500, true]);