mirror of
https://github.com/Horizontal42/summarum.git
synced 2026-11-10 23:51:59 +03:00
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:
parent
c958f30662
commit
36dde29fda
1 changed files with 116 additions and 65 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue