mirror of
https://github.com/Horizontal42/summarum.git
synced 2026-11-11 02:12:00 +03:00
split extensions load into isolated helpers
This commit is contained in:
parent
3a657a2d5d
commit
2762e4461c
1 changed files with 116 additions and 65 deletions
|
|
@ -1,13 +1,20 @@
|
||||||
// JS extensions runtime: numi.setVariable, numi.addUnit, numi.addFunction.
|
// JS extensions runtime: numi.setVariable, numi.addUnit, numi.addFunction.
|
||||||
// Scripts run inside a QuickJS WASM sandbox — no DOM, no Tauri IPC, no host globals.
|
// 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 releaseSyncVariant from "@jitl/quickjs-singlefile-browser-release-sync";
|
||||||
import { SumEngine, ExtensionUnitSpec, ExtensionValue } from "./engine";
|
import { SumEngine, ExtensionUnitSpec, ExtensionValue } from "./engine";
|
||||||
|
|
||||||
interface ExtensionApi {
|
interface ExtensionApi {
|
||||||
setVariable(name: string, value: number | ExtensionValue): void;
|
setVariable(name: string, value: number | ExtensionValue): void;
|
||||||
addUnit(spec: ExtensionUnitSpec): 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 {
|
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.
|
// every keystroke for every line that uses them — this budget must stay small.
|
||||||
const CALL_TIMEOUT_MS = 50;
|
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 {
|
function toExtValue(v: unknown, what: string): ExtensionValue | number {
|
||||||
if (typeof v === "number" && Number.isFinite(v)) return v;
|
if (typeof v === "number" && Number.isFinite(v)) return v;
|
||||||
if (v && typeof v === "object") {
|
if (v && typeof v === "object") {
|
||||||
const o = v as { double?: unknown; unitId?: unknown };
|
const o = v as { double?: unknown; unitId?: unknown };
|
||||||
if (typeof o.double === "number" && Number.isFinite(o.double)) {
|
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? }`);
|
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 {
|
function message(e: unknown): string {
|
||||||
if (e instanceof Error) return e.message;
|
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 (e as { message: string }).message;
|
||||||
}
|
}
|
||||||
return String(e);
|
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
|
// The sandbox has no console/setTimeout/fetch: an extension touching those gets a
|
||||||
// ReferenceError inside QuickJS, which surfaces as a normal per-script failure.
|
// 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.
|
// 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;
|
if (scripts.length === 0) return;
|
||||||
try {
|
try {
|
||||||
await load(engine, scripts);
|
await load(engine, scripts);
|
||||||
|
|
@ -62,20 +79,15 @@ export async function runExtensions(engine: SumEngine, scripts: { name: string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load(engine: SumEngine, scripts: { name: string; code: string }[]): Promise<void> {
|
type RunWithTimeout = <T>(timeoutMs: number, fn: () => T) => T;
|
||||||
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
|
function callGuest(
|
||||||
// at load time and a hung function call on keystroke N are both bounded.
|
vm: QuickJSContext,
|
||||||
let deadline = Infinity;
|
id: string,
|
||||||
vm.runtime.setInterruptHandler(() => now() > deadline);
|
fn: QuickJSHandle,
|
||||||
|
values: ExtensionValue[],
|
||||||
const api = makeApi(engine);
|
runWithTimeout: RunWithTimeout,
|
||||||
|
): ExtensionValue | number {
|
||||||
const callGuest = (id: string, fn: QuickJSHandle, values: ExtensionValue[]): ExtensionValue | number => {
|
|
||||||
// extension-sample.js convention: the callback takes one array of values.
|
// extension-sample.js convention: the callback takes one array of values.
|
||||||
const arr = vm.newArray();
|
const arr = vm.newArray();
|
||||||
try {
|
try {
|
||||||
|
|
@ -92,13 +104,9 @@ async function load(engine: SumEngine, scripts: { name: string; code: string }[]
|
||||||
vm.setProp(arr, i, o);
|
vm.setProp(arr, i, o);
|
||||||
o.dispose();
|
o.dispose();
|
||||||
});
|
});
|
||||||
let res;
|
const res = runWithTimeout(CALL_TIMEOUT_MS, () =>
|
||||||
deadline = now() + CALL_TIMEOUT_MS;
|
vm.callFunction(fn, vm.undefined, [arr]),
|
||||||
try {
|
);
|
||||||
res = vm.callFunction(fn, vm.undefined, [arr]);
|
|
||||||
} finally {
|
|
||||||
deadline = Infinity;
|
|
||||||
}
|
|
||||||
if (res.error) {
|
if (res.error) {
|
||||||
const err = vm.dump(res.error);
|
const err = vm.dump(res.error);
|
||||||
res.error.dispose();
|
res.error.dispose();
|
||||||
|
|
@ -110,8 +118,13 @@ async function load(engine: SumEngine, scripts: { name: string; code: string }[]
|
||||||
} finally {
|
} finally {
|
||||||
arr.dispose();
|
arr.dispose();
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
|
function exposeApi(
|
||||||
|
vm: QuickJSContext,
|
||||||
|
api: ExtensionApi,
|
||||||
|
runWithTimeout: RunWithTimeout,
|
||||||
|
) {
|
||||||
const numi = vm.newObject();
|
const numi = vm.newObject();
|
||||||
const define = (name: string, impl: (...args: QuickJSHandle[]) => void) => {
|
const define = (name: string, impl: (...args: QuickJSHandle[]) => void) => {
|
||||||
const fn = vm.newFunction(name, (...args) => {
|
const fn = vm.newFunction(name, (...args) => {
|
||||||
|
|
@ -127,13 +140,19 @@ async function load(engine: SumEngine, scripts: { name: string; code: string }[]
|
||||||
|
|
||||||
define("setVariable", (nameHandle, valueHandle) => {
|
define("setVariable", (nameHandle, valueHandle) => {
|
||||||
const name = vm.dump(nameHandle);
|
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"));
|
api.setVariable(name, toExtValue(vm.dump(valueHandle), "value"));
|
||||||
});
|
});
|
||||||
|
|
||||||
define("addUnit", (specHandle) => {
|
define("addUnit", (specHandle) => {
|
||||||
const spec = vm.dump(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 }");
|
throw new Error("expects { id, phrases, baseUnitId, ratio }");
|
||||||
}
|
}
|
||||||
api.addUnit({
|
api.addUnit({
|
||||||
|
|
@ -147,28 +166,33 @@ async function load(engine: SumEngine, scripts: { name: string; code: string }[]
|
||||||
|
|
||||||
define("addFunction", (specHandle, fnHandle) => {
|
define("addFunction", (specHandle, fnHandle) => {
|
||||||
const spec = vm.dump(specHandle);
|
const spec = vm.dump(specHandle);
|
||||||
if (!spec || typeof spec.id !== "string") throw new Error("expects { id, phrases }");
|
if (!spec || typeof spec.id !== "string")
|
||||||
if (!fnHandle || vm.typeof(fnHandle) !== "function") throw new Error("expects a function");
|
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
|
// outlives this callback, so it needs its own reference; the context is never disposed
|
||||||
const guestFn = fnHandle.dup();
|
const guestFn = fnHandle.dup();
|
||||||
const id = spec.id;
|
const id = spec.id;
|
||||||
api.addFunction({ id, phrases: typeof spec.phrases === "string" ? spec.phrases : id }, (values) =>
|
api.addFunction(
|
||||||
callGuest(id, guestFn, values),
|
{ id, phrases: typeof spec.phrases === "string" ? spec.phrases : id },
|
||||||
|
(values) => callGuest(vm, id, guestFn, values, runWithTimeout),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
vm.setProp(vm.global, "numi", numi);
|
vm.setProp(vm.global, "numi", numi);
|
||||||
numi.dispose();
|
numi.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateScripts(
|
||||||
|
vm: QuickJSContext,
|
||||||
|
scripts: { name: string; code: string }[],
|
||||||
|
runWithTimeout: RunWithTimeout,
|
||||||
|
) {
|
||||||
for (const s of scripts) {
|
for (const s of scripts) {
|
||||||
try {
|
try {
|
||||||
let res;
|
const res = runWithTimeout(LOAD_TIMEOUT_MS, () =>
|
||||||
deadline = now() + LOAD_TIMEOUT_MS;
|
vm.evalCode(s.code, s.name),
|
||||||
try {
|
);
|
||||||
res = vm.evalCode(s.code, s.name);
|
|
||||||
} finally {
|
|
||||||
deadline = Infinity;
|
|
||||||
}
|
|
||||||
if (res.error) {
|
if (res.error) {
|
||||||
const err = vm.dump(res.error);
|
const err = vm.dump(res.error);
|
||||||
res.error.dispose();
|
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