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)
Changes
@@@ -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 isthe 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@@@ -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);@@@ -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 };@@@ -58,6 +58,26 @@ const b64url = (buf) => buf.toString('base64').replace(/\+/g, '-').replace(/\//gassert.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]);
@@ -72,6 +72,9 @@ async function handle(request, ov) {
@@ -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
- **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
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
`OV.fn('hello')`, and a link or form may point at `/api/hello` directly. In the editor, the preview runs the copy in
@@ -24,6 +24,9 @@ const __call = async (op, args) => {
@@ -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; }
if (r.error) { const e = new Error(r.error.message); e.code = r.error.code; throw e; }
return r.result;
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);
const __ctx = JSON.parse(__context);
globalThis.ov = Object.freeze({
globalThis.ov = Object.freeze({
user: __ctx.user ? Object.freeze(__ctx.user) : null,
user: __ctx.user ? Object.freeze(__ctx.user) : null,
@@ -93,7 +96,7 @@ async function run({ id, name, code, request, context, limits }) {
@@ -93,7 +96,7 @@ async function run({ id, name, code, request, context, limits }) {
let loaded;
let loaded;
try {
try {
vm.unwrapResult(vm.evalCode(PRELUDE, 'prelude.js')).dispose();
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(); }
} finally { leave(); }
if (loaded.error) {
if (loaded.error) {
const e = vm.dump(loaded.error); loaded.error.dispose();
const e = vm.dump(loaded.error); loaded.error.dispose();
@@ -104,10 +107,15 @@ async function run({ id, name, code, request, context, limits }) {
@@ -104,10 +107,15 @@ async function run({ id, name, code, request, context, limits }) {
let started;
let started;
try {
try {
started = vm.evalCode(`(async () => {
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 });
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(); }
} finally { leave(); }
const promiseHandle = vm.unwrapResult(started);
const promiseHandle = vm.unwrapResult(started);
const settled = vm.resolvePromise(promiseHandle);
const settled = vm.resolvePromise(promiseHandle);
@@ -143,7 +143,7 @@ function createSpaceFunctions({ data, workers = 2, queueMax = 32, log = console,
@@ -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,
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 },
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 });
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.busy) return fail(503, 'Space functions are busy right now; try again in a moment.');
if (r.error) {
if (r.error) {
@@ -165,6 +165,21 @@ function createSpaceFunctions({ data, workers = 2, queueMax = 32, log = console,
@@ -165,6 +165,21 @@ function createSpaceFunctions({ data, workers = 2, queueMax = 32, log = console,
return { call, close, NAME_RE };
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) }. */
/** What handle returned → { status, headers, body (string) }. */
function toResponse(out) {
function toResponse(out) {
const kind = (s) => (/^\s*</.test(s) ? 'text/html; charset=utf-8' : 'text/plain; charset=utf-8');
const kind = (s) => (/^\s*</.test(s) ? 'text/html; charset=utf-8' : 'text/plain; charset=utf-8');
@@ -184,4 +199,4 @@ function toResponse(out) {
@@ -184,4 +199,4 @@ function toResponse(out) {
return { status: 200, headers: { 'content-type': 'application/json; charset=utf-8' }, body: JSON.stringify(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 };
@@ -58,6 +58,26 @@ const b64url = (buf) => buf.toString('base64').replace(/\+/g, '-').replace(/\//g
@@ -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');
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 () => {
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"; }');
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]);
assert.deepStrictEqual([many.status, /at most 100 ov calls/.test(many.body)], [500, true]);