Refactor extensions load function into isolated helpers

Extracted QuickJS evaluation, API definition, and guest calling logic from the monolithic load function into explicit, standalone helper functions. Introduced a runWithTimeout closure to handle QuickJS interruption securely.

Co-authored-by: Horizontal42 <75383519+Horizontal42@users.noreply.github.com>
This commit is contained in:
google-labs-jules[bot] 2026-08-09 10:22:34 +00:00
parent c958f30662
commit 36dde29fda

View file

@ -1,13 +1,20 @@
// JS extensions runtime: numi.setVariable, numi.addUnit, numi.addFunction.
// Scripts run inside a QuickJS WASM sandbox — no DOM, no Tauri IPC, no host globals.
import { newQuickJSWASMModuleFromVariant, QuickJSContext, QuickJSHandle } from "quickjs-emscripten-core";
import {
newQuickJSWASMModuleFromVariant,
QuickJSContext,
QuickJSHandle,
} from "quickjs-emscripten-core";
import releaseSyncVariant from "@jitl/quickjs-singlefile-browser-release-sync";
import { SumEngine, ExtensionUnitSpec, ExtensionValue } from "./engine";
interface ExtensionApi {
setVariable(name: string, value: number | ExtensionValue): void;
addUnit(spec: ExtensionUnitSpec): void;
addFunction(spec: { id: string; phrases: string }, fn: (values: ExtensionValue[]) => ExtensionValue | number): void;
addFunction(
spec: { id: string; phrases: string },
fn: (values: ExtensionValue[]) => ExtensionValue | number,
): void;
}
export function makeApi(engine: SumEngine): ExtensionApi {
@ -29,14 +36,17 @@ const LOAD_TIMEOUT_MS = 1000;
// every keystroke for every line that uses them — this budget must stay small.
const CALL_TIMEOUT_MS = 50;
const now = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
const now = () =>
typeof performance !== "undefined" ? performance.now() : Date.now();
function toExtValue(v: unknown, what: string): ExtensionValue | number {
if (typeof v === "number" && Number.isFinite(v)) return v;
if (v && typeof v === "object") {
const o = v as { double?: unknown; unitId?: unknown };
if (typeof o.double === "number" && Number.isFinite(o.double)) {
return typeof o.unitId === "string" ? { double: o.double, unitId: o.unitId } : { double: o.double };
return typeof o.unitId === "string"
? { double: o.double, unitId: o.unitId }
: { double: o.double };
}
}
throw new Error(`${what}: expected a number or { double, unitId? }`);
@ -44,7 +54,11 @@ function toExtValue(v: unknown, what: string): ExtensionValue | number {
function message(e: unknown): string {
if (e instanceof Error) return e.message;
if (e && typeof e === "object" && typeof (e as { message?: unknown }).message === "string") {
if (
e &&
typeof e === "object" &&
typeof (e as { message?: unknown }).message === "string"
) {
return (e as { message: string }).message;
}
return String(e);
@ -53,7 +67,10 @@ function message(e: unknown): string {
// The sandbox has no console/setTimeout/fetch: an extension touching those gets a
// ReferenceError inside QuickJS, which surfaces as a normal per-script failure.
// Never rejects — boot() runs unawaited, so a broken sandbox must not stop the app.
export async function runExtensions(engine: SumEngine, scripts: { name: string; code: string }[]): Promise<void> {
export async function runExtensions(
engine: SumEngine,
scripts: { name: string; code: string }[],
): Promise<void> {
if (scripts.length === 0) return;
try {
await load(engine, scripts);
@ -62,56 +79,52 @@ export async function runExtensions(engine: SumEngine, scripts: { name: string;
}
}
async function load(engine: SumEngine, scripts: { name: string; code: string }[]): Promise<void> {
const mod = await newQuickJSWASMModuleFromVariant(releaseSyncVariant);
const vm: QuickJSContext = mod.newContext();
vm.runtime.setMemoryLimit(MEMORY_LIMIT);
vm.runtime.setMaxStackSize(STACK_SIZE);
type RunWithTimeout = <T>(timeoutMs: number, fn: () => T) => T;
// Armed before every entry into the sandbox and disarmed after, so a hung script
// at load time and a hung function call on keystroke N are both bounded.
let deadline = Infinity;
vm.runtime.setInterruptHandler(() => now() > deadline);
const api = makeApi(engine);
const callGuest = (id: string, fn: QuickJSHandle, values: ExtensionValue[]): ExtensionValue | number => {
// extension-sample.js convention: the callback takes one array of values.
const arr = vm.newArray();
try {
values.forEach((v, i) => {
const o = vm.newObject();
const d = vm.newNumber(v.double);
vm.setProp(o, "double", d);
d.dispose();
if (v.unitId) {
const u = vm.newString(v.unitId);
vm.setProp(o, "unitId", u);
u.dispose();
}
vm.setProp(arr, i, o);
o.dispose();
});
let res;
deadline = now() + CALL_TIMEOUT_MS;
try {
res = vm.callFunction(fn, vm.undefined, [arr]);
} finally {
deadline = Infinity;
function callGuest(
vm: QuickJSContext,
id: string,
fn: QuickJSHandle,
values: ExtensionValue[],
runWithTimeout: RunWithTimeout,
): ExtensionValue | number {
// extension-sample.js convention: the callback takes one array of values.
const arr = vm.newArray();
try {
values.forEach((v, i) => {
const o = vm.newObject();
const d = vm.newNumber(v.double);
vm.setProp(o, "double", d);
d.dispose();
if (v.unitId) {
const u = vm.newString(v.unitId);
vm.setProp(o, "unitId", u);
u.dispose();
}
if (res.error) {
const err = vm.dump(res.error);
res.error.dispose();
throw new Error(`${id}: ${message(err)}`);
}
const out = vm.dump(res.value);
res.value.dispose();
return toExtValue(out, id);
} finally {
arr.dispose();
vm.setProp(arr, i, o);
o.dispose();
});
const res = runWithTimeout(CALL_TIMEOUT_MS, () =>
vm.callFunction(fn, vm.undefined, [arr]),
);
if (res.error) {
const err = vm.dump(res.error);
res.error.dispose();
throw new Error(`${id}: ${message(err)}`);
}
};
const out = vm.dump(res.value);
res.value.dispose();
return toExtValue(out, id);
} finally {
arr.dispose();
}
}
function exposeApi(
vm: QuickJSContext,
api: ExtensionApi,
runWithTimeout: RunWithTimeout,
) {
const numi = vm.newObject();
const define = (name: string, impl: (...args: QuickJSHandle[]) => void) => {
const fn = vm.newFunction(name, (...args) => {
@ -127,13 +140,19 @@ async function load(engine: SumEngine, scripts: { name: string; code: string }[]
define("setVariable", (nameHandle, valueHandle) => {
const name = vm.dump(nameHandle);
if (typeof name !== "string" || !name) throw new Error("expects a variable name");
if (typeof name !== "string" || !name)
throw new Error("expects a variable name");
api.setVariable(name, toExtValue(vm.dump(valueHandle), "value"));
});
define("addUnit", (specHandle) => {
const spec = vm.dump(specHandle);
if (!spec || typeof spec.id !== "string" || typeof spec.baseUnitId !== "string" || typeof spec.ratio !== "number") {
if (
!spec ||
typeof spec.id !== "string" ||
typeof spec.baseUnitId !== "string" ||
typeof spec.ratio !== "number"
) {
throw new Error("expects { id, phrases, baseUnitId, ratio }");
}
api.addUnit({
@ -147,28 +166,33 @@ async function load(engine: SumEngine, scripts: { name: string; code: string }[]
define("addFunction", (specHandle, fnHandle) => {
const spec = vm.dump(specHandle);
if (!spec || typeof spec.id !== "string") throw new Error("expects { id, phrases }");
if (!fnHandle || vm.typeof(fnHandle) !== "function") throw new Error("expects a function");
if (!spec || typeof spec.id !== "string")
throw new Error("expects { id, phrases }");
if (!fnHandle || vm.typeof(fnHandle) !== "function")
throw new Error("expects a function");
// outlives this callback, so it needs its own reference; the context is never disposed
const guestFn = fnHandle.dup();
const id = spec.id;
api.addFunction({ id, phrases: typeof spec.phrases === "string" ? spec.phrases : id }, (values) =>
callGuest(id, guestFn, values),
api.addFunction(
{ id, phrases: typeof spec.phrases === "string" ? spec.phrases : id },
(values) => callGuest(vm, id, guestFn, values, runWithTimeout),
);
});
vm.setProp(vm.global, "numi", numi);
numi.dispose();
}
function evaluateScripts(
vm: QuickJSContext,
scripts: { name: string; code: string }[],
runWithTimeout: RunWithTimeout,
) {
for (const s of scripts) {
try {
let res;
deadline = now() + LOAD_TIMEOUT_MS;
try {
res = vm.evalCode(s.code, s.name);
} finally {
deadline = Infinity;
}
const res = runWithTimeout(LOAD_TIMEOUT_MS, () =>
vm.evalCode(s.code, s.name),
);
if (res.error) {
const err = vm.dump(res.error);
res.error.dispose();
@ -180,3 +204,30 @@ async function load(engine: SumEngine, scripts: { name: string; code: string }[]
}
}
}
async function load(
engine: SumEngine,
scripts: { name: string; code: string }[],
): Promise<void> {
const mod = await newQuickJSWASMModuleFromVariant(releaseSyncVariant);
const vm: QuickJSContext = mod.newContext();
vm.runtime.setMemoryLimit(MEMORY_LIMIT);
vm.runtime.setMaxStackSize(STACK_SIZE);
// Armed before every entry into the sandbox and disarmed after, so a hung script
// at load time and a hung function call on keystroke N are both bounded.
let deadline = Infinity;
vm.runtime.setInterruptHandler(() => now() > deadline);
const runWithTimeout: RunWithTimeout = (timeoutMs, fn) => {
deadline = now() + timeoutMs;
try {
return fn();
} finally {
deadline = Infinity;
}
};
exposeApi(vm, makeApi(engine), runWithTimeout);
evaluateScripts(vm, scripts, runWithTimeout);
}