@arnilo/liveframe-runner-quickjs 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Niloy Rahman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,48 @@
1
- # Temporary Holding Version
1
+ # @arnilo/liveframe-runner-quickjs
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ An isolated [Liveframe](https://github.com/ashiqrniloy/liveframe) runner. Widget code runs in [QuickJS](https://bellard.org/quickjs/) (WebAssembly, via `quickjs-emscripten`) inside a Bun child process that the runner owns and can kill. A host gets a real isolation boundary for hostile widget code without building a sandbox.
4
+
5
+ Requires Bun ≥ 1.4.2 and `@arnilo/liveframe` `~0.3.0`. No Node runtime is supported.
6
+
7
+ ```ts
8
+ import { quickjsRunner } from "@arnilo/liveframe-runner-quickjs";
9
+
10
+ const runner = await quickjsRunner({ memoryBytes: 32 << 20, stackBytes: 256 << 10, timeoutMs: 250 });
11
+ const result = await runner.render(pkg, inputs, { catalog, capabilities, surfaceId: "home", revision: 1 });
12
+ runner.close(); // kills the child
13
+ ```
14
+
15
+ It is a `Runner` (spec §7.5): use it wherever the app passes one, for example `renderPlacement({ runner, … })` from `/host`, or `runner:` in the `liveframe mcp` config. Call `close()` when done.
16
+
17
+ ## Options
18
+
19
+ | Option | Default | Meaning |
20
+ | --- | --- | --- |
21
+ | `memoryBytes` | 32 MiB | QuickJS heap limit per runtime |
22
+ | `stackBytes` | 256 KiB | QuickJS stack limit. Not 1 MiB: QuickJS recurses on the host stack, and at 1 MiB some call shapes overflow it first, which aborts the engine |
23
+ | `timeoutMs` | 250 | Soft deadline: QuickJS's interrupt handler stops the render. A render's `options.timeoutMs` overrides it |
24
+ | `reuse` | `"none"` | `"none"`: every render gets a new runtime in a new wasm instance. `"package"`: one runtime per package hash is kept, so module state survives between renders. Faster; for code you trust to behave but have not tested, never for hostile code |
25
+ | `maxRuntimes` | 16 | Runtimes kept with `reuse: "package"`, least recently used evicted first |
26
+
27
+ ## What it does
28
+
29
+ - **No host API in the realm.** Widget code sees only ECMAScript built-ins. `fetch`, `process`, `Bun`, `Deno`, `require`, `WebAssembly`, timers and `console` are `undefined`; `globalThis.constructor.constructor("return this")()`, `eval` and `new Function` reach only the sandbox; `import()` goes to a loader that serves only the Liveframe kit, the package and its pinned libraries.
30
+ - **A channel widget code cannot write to.** The parent–child channel is not reachable from the realm, so a widget cannot answer for another render.
31
+ - **Law 2 on the host.** The host verifies the package hash and the library pins before anything is loaded and validates every tree the child returns. A malformed or forged answer is `render-failed`.
32
+ - **Limits.** Heap, stack and time are `limit-exceeded`. A render stuck in one native call (QuickJS cannot interrupt those) is ended by killing the child 100 ms after the soft deadline; the next render starts a new child.
33
+ - **Templates run on the host.** A template package runs no author code, so it needs no sandbox and renders in the parent.
34
+ - **Pending jobs never run.** The runner never calls `executePendingJobs()`; a package whose top-level `await` does not settle is `render-failed`. A wasm instance whose engine throws to the host is dropped, never reused.
35
+
36
+ ## What it does not protect against
37
+
38
+ - **The child runs with the host's privileges** (with an empty environment). Only QuickJS stands between widget code and the file system and network. A defect in QuickJS or in the wasm runtime is not covered. Run the child inside an OS sandbox (bubblewrap, a container) for defence in depth.
39
+ - **CPU side channels and timing.**
40
+ - **Memory pressure** on the host up to `memoryBytes` per runtime, plus the wasm instance and the child's baseline. With `reuse: "package"`, up to `maxRuntimes` runtimes at once. Measured (Linux, plan 031 Task 8): the child holds about 52 MiB resident at start, 74 MiB steady with `reuse: "none"` and about 90 MiB with `reuse: "package"` over five packages, with no growth across 500 and 1000 renders. The host parses at most what the heap allows, and a tree beyond the validator's limits is `invalid-prop` (a 14 MiB text node was).
41
+ - **Renders run one at a time.** The runner owns one child, so a render waits for the one before it. A hostile widget that holds its render costs every render queued behind it up to `timeoutMs` + 100 ms plus about 250 ms to replace the child (measured: hold, kill, recover and next render 246 ms). Give untrusted authors their own runner, or keep their `timeoutMs` low.
42
+ - **Only Linux has been measured.** macOS and Windows are expected to work (WebAssembly and `Bun.spawn`) and are not claimed.
43
+
44
+ ## Cost
45
+
46
+ Measured on one Linux machine (Bun 1.4.4), agent-home (679 nodes): a fresh render is about 70 ms, of which about 30 ms is the fixed cost of a runtime and the kit; the child starts in about 21 ms; killing a stuck child and rendering the next request takes about 140–190 ms. See `plans/evidence/031-runner-options.md` in the repository. Templates and trusted code are cheaper with `templateRunner()` and `workerRunner()`.
47
+
48
+ Prove a runner with `runnerSuite()` from `@arnilo/liveframe/conformance`; this package passes the contract and isolation cases.
@@ -0,0 +1 @@
1
+ export {};
package/dist/child.js ADDED
@@ -0,0 +1,18 @@
1
+ // The child process entry: it holds the QuickJS wasm module and renders what its parent sends.
2
+ // Run only by `quickjsRunner()`; the parent owns the channel, the deadline and `SIGKILL`.
3
+ import { fileURLToPath } from "node:url";
4
+ import { createEngine } from "./runtime.js";
5
+ const send = (message) => {
6
+ process.send?.(message);
7
+ };
8
+ const wasm = await WebAssembly.compile(await Bun.file(fileURLToPath(import.meta.resolve("@jitl/quickjs-wasmfile-release-sync/wasm"))).arrayBuffer());
9
+ const kit = await Bun.file(fileURLToPath(import.meta.resolve("@arnilo/liveframe/runner-kit.bundle.js"))).text();
10
+ const engine = createEngine(wasm, kit, { maxRuntimes: Number(Bun.argv[2]) || 16 });
11
+ process.on("disconnect", () => process.exit(0));
12
+ process.on("message", async (message) => {
13
+ const request = message;
14
+ if (request.type !== "render")
15
+ return;
16
+ send({ type: "result", id: request.id, ...(await engine.render(request)) });
17
+ });
18
+ send({ type: "ready" });
@@ -0,0 +1,35 @@
1
+ import type { Runner } from "@arnilo/liveframe/preview";
2
+ export type QuickJsRunnerOptions = {
3
+ /** QuickJS's heap limit per runtime (default 32 MiB). */
4
+ readonly memoryBytes?: number;
5
+ /**
6
+ * QuickJS's stack limit (default 256 KiB). QuickJS recurses on the host stack: at 1 MiB some call
7
+ * shapes overflow it first, and the engine then aborts (decision 2026-11-08-2310).
8
+ */
9
+ readonly stackBytes?: number;
10
+ /** The default per-render limit in ms (default 250); a render's `options.timeoutMs` overrides it. */
11
+ readonly timeoutMs?: number;
12
+ /**
13
+ * `"none"` (default): every render gets a new runtime in a new wasm instance. `"package"`: a runtime
14
+ * per package hash is kept between renders, so module state survives. Faster; for code you trust
15
+ * to behave but have not tested, not for hostile code.
16
+ */
17
+ readonly reuse?: "none" | "package";
18
+ /** Runtimes kept with `reuse: "package"`, least recently used evicted first (default 16). */
19
+ readonly maxRuntimes?: number;
20
+ };
21
+ export type QuickJsRunner = Runner & {
22
+ close(): void;
23
+ };
24
+ /**
25
+ * An isolated runner: widget code runs in QuickJS (WebAssembly) inside a Bun child process this
26
+ * runner owns. The realm holds no host API, has a heap and stack limit and a soft deadline; a render
27
+ * that outlives the soft deadline plus 100 ms (one native call QuickJS cannot interrupt) is ended by
28
+ * killing the child, which the next render replaces. The host verifies the package hash and the
29
+ * library pins before anything is loaded, and validates every tree the child returns (law 2).
30
+ * Template packages run no author code and render on the host. `close()` kills the child.
31
+ *
32
+ * The child runs with the host's privileges and an empty environment; only QuickJS stands between
33
+ * widget code and the file system and network. Wrap it in an OS sandbox for defence in depth.
34
+ */
35
+ export declare function quickjsRunner(options?: QuickJsRunnerOptions): Promise<QuickJsRunner>;
package/dist/index.js ADDED
@@ -0,0 +1,181 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { renderTemplatePackage } from "@arnilo/liveframe/runner-kit";
3
+ import { hashMatches, prepare } from "./load.js";
4
+ import { accept, failed, limitFailure } from "./result.js";
5
+ /** The hard kill comes this long after the soft deadline: QuickJS cannot interrupt one native call. */
6
+ const KILL_GRACE_MS = 100;
7
+ const childEntry = fileURLToPath(new URL(`./child${import.meta.url.endsWith(".ts") ? ".ts" : ".js"}`, import.meta.url));
8
+ function positive(name, value) {
9
+ if (!Number.isInteger(value) || value <= 0)
10
+ throw new RangeError(`${name} must be a positive integer`);
11
+ return value;
12
+ }
13
+ /**
14
+ * An isolated runner: widget code runs in QuickJS (WebAssembly) inside a Bun child process this
15
+ * runner owns. The realm holds no host API, has a heap and stack limit and a soft deadline; a render
16
+ * that outlives the soft deadline plus 100 ms (one native call QuickJS cannot interrupt) is ended by
17
+ * killing the child, which the next render replaces. The host verifies the package hash and the
18
+ * library pins before anything is loaded, and validates every tree the child returns (law 2).
19
+ * Template packages run no author code and render on the host. `close()` kills the child.
20
+ *
21
+ * The child runs with the host's privileges and an empty environment; only QuickJS stands between
22
+ * widget code and the file system and network. Wrap it in an OS sandbox for defence in depth.
23
+ */
24
+ export async function quickjsRunner(options = {}) {
25
+ const memoryBytes = positive("memoryBytes", options.memoryBytes ?? 32 << 20);
26
+ const stackBytes = positive("stackBytes", options.stackBytes ?? 256 << 10);
27
+ const defaultTimeoutMs = positive("timeoutMs", options.timeoutMs ?? 250);
28
+ const maxRuntimes = positive("maxRuntimes", options.maxRuntimes ?? 16);
29
+ const reuse = options.reuse ?? "none";
30
+ let current;
31
+ let pending;
32
+ let nextId = 1;
33
+ let closed = false;
34
+ let chain = Promise.resolve();
35
+ function settle(answer) {
36
+ const active = pending;
37
+ pending = undefined;
38
+ active?.settle(answer);
39
+ }
40
+ /** Kills the child and forgets it; the next render starts a new one. */
41
+ function retire(child) {
42
+ if (current === child)
43
+ current = undefined;
44
+ child.proc.kill("SIGKILL");
45
+ }
46
+ function start() {
47
+ let ready;
48
+ let refuse;
49
+ const started = new Promise((resolve, reject) => {
50
+ ready = resolve;
51
+ refuse = reject;
52
+ });
53
+ const proc = Bun.spawn([process.execPath, childEntry, String(maxRuntimes)], {
54
+ env: {},
55
+ stdio: ["ignore", "ignore", "ignore"],
56
+ serialization: "json",
57
+ ipc(message) {
58
+ const reply = message;
59
+ if (current?.proc !== proc)
60
+ return;
61
+ if (reply.type === "ready")
62
+ ready();
63
+ else if (reply.type === "result" && pending?.id === reply.id)
64
+ settle("json" in reply ? { json: reply.json } : { error: reply.error });
65
+ else if (reply.type === "result") {
66
+ // An answer for a render nobody asked for: the child is not trusted any more.
67
+ retire(child);
68
+ settle({ error: "The sandbox answered a render it was not asked for" });
69
+ }
70
+ },
71
+ onExit(_, code) {
72
+ if (current?.proc !== proc)
73
+ return;
74
+ current = undefined;
75
+ refuse(new Error(`The QuickJS child process exited before it was ready (code ${code})`));
76
+ settle({ error: `The QuickJS child process exited (code ${code})` });
77
+ },
78
+ });
79
+ // The child never keeps the host alive; its death with the host is `disconnect` in the child
80
+ // and, when the child is stuck in a native call, this hook.
81
+ proc.unref();
82
+ const child = { proc, ready: started };
83
+ current = child;
84
+ return child;
85
+ }
86
+ async function exchange(job, limits) {
87
+ let child = current ?? start();
88
+ try {
89
+ await child.ready;
90
+ }
91
+ catch (error) {
92
+ return { refused: error instanceof Error ? error.message : "The QuickJS child process did not start" };
93
+ }
94
+ // `closed` may have been set while the child started.
95
+ if (closed)
96
+ return { refused: "Runner closed" };
97
+ child = current ?? child;
98
+ const id = nextId++;
99
+ return new Promise((resolve) => {
100
+ const hard = setTimeout(() => {
101
+ retire(child);
102
+ settle({ killed: true });
103
+ }, limits.timeoutMs + KILL_GRACE_MS);
104
+ pending = {
105
+ id,
106
+ settle(answer) {
107
+ clearTimeout(hard);
108
+ resolve(answer);
109
+ },
110
+ };
111
+ child.proc.send({ type: "render", id, ...job, limits });
112
+ });
113
+ }
114
+ const killAtExit = () => current?.proc.kill("SIGKILL");
115
+ process.once("exit", killAtExit);
116
+ const first = start();
117
+ try {
118
+ await first.ready;
119
+ }
120
+ catch (error) {
121
+ process.off("exit", killAtExit);
122
+ throw error;
123
+ }
124
+ async function renderCode(pkg, inputs, runnerOptions) {
125
+ const linked = prepare(pkg, runnerOptions.libraries);
126
+ if (!linked.ok)
127
+ return linked;
128
+ const timeoutMs = runnerOptions.timeoutMs ?? defaultTimeoutMs;
129
+ const { catalog, capabilities, surfaceId, revision, limits } = runnerOptions;
130
+ let request;
131
+ try {
132
+ request = JSON.stringify({
133
+ inputs,
134
+ options: { catalog, capabilities, manifest: pkg.manifest, surfaceId, revision, ...(limits ? { limits } : {}) },
135
+ });
136
+ }
137
+ catch {
138
+ return failed("render-failed", "The render inputs must be JSON");
139
+ }
140
+ const answer = await exchange({
141
+ code: linked.value.code,
142
+ libraries: linked.value.libraries,
143
+ request,
144
+ ...(reuse === "package" ? { key: pkg.hash } : {}),
145
+ }, { memoryBytes, stackBytes, timeoutMs });
146
+ if ("refused" in answer)
147
+ return failed("render-failed", answer.refused);
148
+ if ("killed" in answer)
149
+ return failed("limit-exceeded", `Render stopped: timeout (killed after ${timeoutMs + KILL_GRACE_MS} ms)`);
150
+ if ("error" in answer)
151
+ return limitFailure(answer.error) ?? failed("render-failed", answer.error.slice(0, 500));
152
+ return accept(answer.json, { catalog, capabilities, manifest: pkg.manifest }, pkg.hash, limits ? { limits } : undefined);
153
+ }
154
+ return {
155
+ render(pkg, inputs, runnerOptions) {
156
+ if (closed)
157
+ return Promise.resolve(failed("render-failed", "Runner closed"));
158
+ if (!hashMatches(pkg))
159
+ return Promise.resolve(failed("hash-mismatch", "Package content does not match its hash", "/hash"));
160
+ if (pkg.template !== undefined)
161
+ return Promise.resolve(renderTemplate(pkg, pkg.template, inputs, runnerOptions, defaultTimeoutMs));
162
+ const run = chain.then(() => closed ? failed("render-failed", "Runner closed") : renderCode(pkg, inputs, runnerOptions));
163
+ chain = run.catch(() => undefined);
164
+ return run;
165
+ },
166
+ close() {
167
+ closed = true;
168
+ process.off("exit", killAtExit);
169
+ if (current)
170
+ retire(current);
171
+ settle({ error: "Runner closed" });
172
+ },
173
+ };
174
+ }
175
+ /** A template is data the library evaluates in bounded steps: no author code runs, so no sandbox is needed. */
176
+ function renderTemplate(pkg, template, inputs, options, defaultTimeoutMs) {
177
+ const timeoutMs = options.timeoutMs ?? defaultTimeoutMs;
178
+ const start = Date.now();
179
+ const result = renderTemplatePackage(template, inputs, { ...options, manifest: pkg.manifest });
180
+ return Date.now() - start > timeoutMs ? failed("limit-exceeded", `Render exceeded ${timeoutMs} ms`) : result;
181
+ }
package/dist/load.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ import type { Result } from "@arnilo/liveframe";
2
+ import { type WidgetPackage } from "@arnilo/liveframe/pack";
3
+ /** What the sandbox is given: the package's linked code and its libraries' by specifier. */
4
+ export type Linked = {
5
+ readonly code: string;
6
+ readonly libraries: Readonly<Record<string, string>>;
7
+ };
8
+ /** A package's hash against its content. The caller refuses a package that fails it before loading anything. */
9
+ export declare function hashMatches(pkg: WidgetPackage): boolean;
10
+ /** Links `pkg` and the libraries its pins name, or says why it cannot be loaded. */
11
+ export declare function prepare(pkg: WidgetPackage, supplied: readonly WidgetPackage[] | undefined): Result<Linked>;
package/dist/load.js ADDED
@@ -0,0 +1,80 @@
1
+ import { packageHash } from "@arnilo/liveframe/pack";
2
+ import { linkPackage } from "@arnilo/liveframe/runner-kit";
3
+ /** The bounds `verifyPackage` enforces on a package's library graph (spec §7.3). */
4
+ const MAX_GRAPH_DEPTH = 4;
5
+ const MAX_GRAPH_PACKAGES = 32;
6
+ const keyOf = (id, version) => `${id}@${version}`;
7
+ const specifierOf = (id, version) => `liveframe:lib/${keyOf(id, version)}`;
8
+ function fail(kind, pointer, message) {
9
+ return { ok: false, errors: [{ kind, pointer, message }] };
10
+ }
11
+ /** A package's hash against its content. The caller refuses a package that fails it before loading anything. */
12
+ export function hashMatches(pkg) {
13
+ try {
14
+ return packageHash(pkg) === pkg.hash;
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
20
+ /**
21
+ * The libraries `pkg` needs, dependencies first and each once: every pin must name a supplied library
22
+ * with that hash (`not-found`, `hash-mismatch`), each library's content is re-hashed, and the graph stays
23
+ * within the bounds `verifyPackage` enforces.
24
+ */
25
+ function collect(pkg, supplied) {
26
+ if (!pkg.manifest.dependencies?.length)
27
+ return { ok: true, value: [] };
28
+ const byKey = new Map((supplied ?? []).map((lib) => [keyOf(lib.manifest.id, lib.manifest.version), lib]));
29
+ const ordered = new Map();
30
+ const visit = (owner, trail) => {
31
+ for (const [i, pin] of (owner.manifest.dependencies ?? []).entries()) {
32
+ const at = `/manifest/dependencies/${i}`;
33
+ const key = keyOf(pin.id, pin.version);
34
+ const lib = byKey.get(key);
35
+ if (!lib)
36
+ return fail("not-found", at, `No library package was supplied for ${key}`);
37
+ if (lib.hash !== pin.hash)
38
+ return fail("hash-mismatch", `${at}/hash`, `${key} does not match the pinned hash`);
39
+ if (trail.includes(key))
40
+ return fail("invalid-document", at, `Dependency cycle at ${key}`);
41
+ if (trail.length >= MAX_GRAPH_DEPTH)
42
+ return fail("limit-exceeded", at, `Expected dependencies at most ${MAX_GRAPH_DEPTH} deep`);
43
+ if (ordered.has(lib.hash))
44
+ continue;
45
+ if (!hashMatches(lib))
46
+ return fail("hash-mismatch", at, `${key} content does not match its hash`);
47
+ const inner = visit(lib, [...trail, key]);
48
+ if (!inner.ok)
49
+ return inner;
50
+ ordered.set(lib.hash, lib);
51
+ if (ordered.size > MAX_GRAPH_PACKAGES)
52
+ return fail("limit-exceeded", at, `Expected at most ${MAX_GRAPH_PACKAGES} libraries in all`);
53
+ }
54
+ return { ok: true, value: undefined };
55
+ };
56
+ const walked = visit(pkg, []);
57
+ return walked.ok ? { ok: true, value: [...ordered.values()] } : walked;
58
+ }
59
+ /** Sends the SDK specifiers to the kit module and leaves library specifiers for the sandbox's loader. */
60
+ function link(code, at) {
61
+ const linked = linkPackage(code, (specifier) => specifier.startsWith("liveframe:lib/") ? specifier : "liveframe:kit");
62
+ return linked.ok ? linked : { ok: false, errors: linked.errors.map((error) => ({ ...error, pointer: at })) };
63
+ }
64
+ /** Links `pkg` and the libraries its pins name, or says why it cannot be loaded. */
65
+ export function prepare(pkg, supplied) {
66
+ const libraries = collect(pkg, supplied);
67
+ if (!libraries.ok)
68
+ return libraries;
69
+ const code = link(pkg.code, "/code");
70
+ if (!code.ok)
71
+ return code;
72
+ const linkedLibraries = {};
73
+ for (const lib of libraries.value) {
74
+ const linked = link(lib.code, "/code");
75
+ if (!linked.ok)
76
+ return linked;
77
+ linkedLibraries[specifierOf(lib.manifest.id, lib.manifest.version)] = linked.value;
78
+ }
79
+ return { ok: true, value: { code: code.value, libraries: linkedLibraries } };
80
+ }
@@ -0,0 +1,36 @@
1
+ /** The limits one render runs under inside the sandbox. */
2
+ export type Limits = {
3
+ readonly memoryBytes: number;
4
+ readonly stackBytes: number;
5
+ /** The soft deadline: QuickJS's interrupt handler stops the render this long after it starts. */
6
+ readonly timeoutMs: number;
7
+ };
8
+ /** What the host asks the child to render. `libraries` maps `liveframe:lib/<id>@<version>` to linked code. */
9
+ export type RenderRequest = {
10
+ readonly type: "render";
11
+ readonly id: number;
12
+ readonly code: string;
13
+ readonly libraries: Readonly<Record<string, string>>;
14
+ readonly request: string;
15
+ readonly limits: Limits;
16
+ /** Set when the runtime is kept between renders (`reuse: "package"`): the package hash. */
17
+ readonly key?: string;
18
+ };
19
+ export type ChildMessage = {
20
+ readonly type: "ready";
21
+ } | {
22
+ readonly type: "result";
23
+ readonly id: number;
24
+ readonly json: string;
25
+ } | {
26
+ readonly type: "result";
27
+ readonly id: number;
28
+ readonly error: string;
29
+ };
30
+ export type Reason = "timeout" | "memory" | "stack";
31
+ /**
32
+ * Which limit a QuickJS message names. The engine reports a stopped render as an ordinary message
33
+ * (spike finding F7), so the host classifies by text: a widget that throws the same words only
34
+ * changes the kind of its own failure.
35
+ */
36
+ export declare function limitOf(message: string): Reason | undefined;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Which limit a QuickJS message names. The engine reports a stopped render as an ordinary message
3
+ * (spike finding F7), so the host classifies by text: a widget that throws the same words only
4
+ * changes the kind of its own failure.
5
+ */
6
+ export function limitOf(message) {
7
+ if (/\binterrupted\b/i.test(message))
8
+ return "timeout";
9
+ if (/stack overflow|maximum call stack/i.test(message))
10
+ return "stack";
11
+ if (/out of memory|string too long|too large|invalid array length/i.test(message))
12
+ return "memory";
13
+ return undefined;
14
+ }
@@ -0,0 +1,18 @@
1
+ import type { UiTree } from "@arnilo/liveframe";
2
+ import { type Capabilities, type Catalog, type CompiledValidator, type LiveframeError, type Manifest, type Result } from "@arnilo/liveframe";
3
+ export declare const failed: (kind: LiveframeError["kind"], message: string, pointer?: string) => Result<never>;
4
+ /** The limit a stopped render hit, as the `limit-exceeded` it is reported as. */
5
+ export declare function limitFailure(message: string): Result<never> | undefined;
6
+ type Install = {
7
+ readonly catalog: Catalog;
8
+ readonly capabilities: Capabilities;
9
+ readonly manifest: Manifest;
10
+ };
11
+ export declare function validatorFor(install: Install, hash: string): CompiledValidator;
12
+ /**
13
+ * The host's answer to the sandbox's one JSON string (law 2): the text is parsed here, a failure list is
14
+ * shape-checked and bounded, and a tree is validated by the host's own compiled validator before anyone
15
+ * sees it. Anything else is `render-failed`.
16
+ */
17
+ export declare function accept(json: string, install: Install, hash: string, limits: Parameters<CompiledValidator["validateTree"]>[1]): Result<UiTree>;
18
+ export {};
package/dist/result.js ADDED
@@ -0,0 +1,120 @@
1
+ import { compileValidator, } from "@arnilo/liveframe";
2
+ import { limitOf } from "./protocol.js";
3
+ const KINDS = new Set([
4
+ "invalid-document",
5
+ "insufficient-contrast",
6
+ "unknown-name",
7
+ "incomplete-design-system",
8
+ "colour-outside-theme",
9
+ "catalog-mismatch",
10
+ "cyclic-extends",
11
+ "forbidden-import",
12
+ "transpile-failed",
13
+ "hash-mismatch",
14
+ "version-conflict",
15
+ "stale-patch",
16
+ "not-found",
17
+ "host-element",
18
+ "unknown-component",
19
+ "invalid-prop",
20
+ "invalid-children",
21
+ "missing-key",
22
+ "duplicate-key",
23
+ "undeclared-event",
24
+ "undeclared-action",
25
+ "function-prop",
26
+ "async-render",
27
+ "render-failed",
28
+ "limit-exceeded",
29
+ ]);
30
+ /** One more than the validator's own bound: it appends a marker after the 20th error. */
31
+ const MAX_ERRORS = 21;
32
+ const MAX_TEXT = 1000;
33
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
34
+ export const failed = (kind, message, pointer = "") => ({
35
+ ok: false,
36
+ errors: [{ kind, pointer, message }],
37
+ });
38
+ /** The limit a stopped render hit, as the `limit-exceeded` it is reported as. */
39
+ export function limitFailure(message) {
40
+ const reason = limitOf(message);
41
+ return reason === undefined ? undefined : failed("limit-exceeded", `Render stopped: ${reason}`);
42
+ }
43
+ /** Failure messages from the sandbox are untrusted text: bounded, and no host paths can be in them. */
44
+ function sanitized(errors) {
45
+ if (!Array.isArray(errors) || errors.length === 0 || errors.length > MAX_ERRORS)
46
+ return undefined;
47
+ const clean = [];
48
+ for (const error of errors) {
49
+ if (!isRecord(error) ||
50
+ typeof error.kind !== "string" ||
51
+ !KINDS.has(error.kind) ||
52
+ typeof error.pointer !== "string" ||
53
+ typeof error.message !== "string")
54
+ return undefined;
55
+ const limit = limitFailure(error.message);
56
+ if (limit)
57
+ return limit;
58
+ clean.push({
59
+ kind: error.kind,
60
+ pointer: error.pointer.slice(0, MAX_TEXT),
61
+ message: error.message.slice(0, MAX_TEXT),
62
+ ...(typeof error.expected === "string" ? { expected: error.expected.slice(0, MAX_TEXT) } : {}),
63
+ ...(typeof error.nodeId === "string" ? { nodeId: error.nodeId.slice(0, MAX_TEXT) } : {}),
64
+ });
65
+ }
66
+ return { ok: false, errors: clean };
67
+ }
68
+ /** Compiled validators per `(catalog, capabilities)` and verified package hash; the 64 most recent are kept. */
69
+ const validators = new WeakMap();
70
+ const MAX_VALIDATORS = 64;
71
+ export function validatorFor(install, hash) {
72
+ let byCapabilities = validators.get(install.catalog);
73
+ if (!byCapabilities) {
74
+ byCapabilities = new WeakMap();
75
+ validators.set(install.catalog, byCapabilities);
76
+ }
77
+ let byHash = byCapabilities.get(install.capabilities);
78
+ if (!byHash) {
79
+ byHash = new Map();
80
+ byCapabilities.set(install.capabilities, byHash);
81
+ }
82
+ const known = byHash.get(hash);
83
+ if (known) {
84
+ byHash.delete(hash);
85
+ byHash.set(hash, known);
86
+ return known;
87
+ }
88
+ const validator = compileValidator(install);
89
+ byHash.set(hash, validator);
90
+ const oldest = byHash.keys().next();
91
+ if (byHash.size > MAX_VALIDATORS && !oldest.done)
92
+ byHash.delete(oldest.value);
93
+ return validator;
94
+ }
95
+ /**
96
+ * The host's answer to the sandbox's one JSON string (law 2): the text is parsed here, a failure list is
97
+ * shape-checked and bounded, and a tree is validated by the host's own compiled validator before anyone
98
+ * sees it. Anything else is `render-failed`.
99
+ */
100
+ export function accept(json, install, hash, limits) {
101
+ let parsed;
102
+ try {
103
+ parsed = JSON.parse(json);
104
+ }
105
+ catch {
106
+ return failed("render-failed", "The sandbox returned a malformed result");
107
+ }
108
+ if (!isRecord(parsed))
109
+ return failed("render-failed", "The sandbox returned a malformed result");
110
+ if (parsed.ok === false)
111
+ return sanitized(parsed.errors) ?? failed("render-failed", "The sandbox returned a malformed result");
112
+ if (parsed.ok !== true || !("value" in parsed))
113
+ return failed("render-failed", "The sandbox returned a malformed result");
114
+ try {
115
+ return validatorFor(install, hash).validateTree(parsed.value, limits);
116
+ }
117
+ catch (error) {
118
+ return failed("render-failed", error instanceof Error ? error.message.slice(0, MAX_TEXT) : "Validation failed");
119
+ }
120
+ }
@@ -0,0 +1,34 @@
1
+ import type { Limits } from "./protocol.js";
2
+ /** What one render needs inside the sandbox; the host linked `code` and the libraries already. */
3
+ export type Job = {
4
+ readonly code: string;
5
+ readonly libraries: Readonly<Record<string, string>>;
6
+ readonly request: string;
7
+ readonly limits: Limits;
8
+ /** Keep the runtime under this key for the next render (`reuse: "package"`). */
9
+ readonly key?: string;
10
+ };
11
+ export type Outcome = {
12
+ readonly json: string;
13
+ } | {
14
+ readonly error: string;
15
+ };
16
+ export type Engine = {
17
+ render(job: Job): Promise<Outcome>;
18
+ /** Runtimes kept for `reuse: "package"`. */
19
+ kept(): number;
20
+ /** Instances dropped because the engine threw to the host; a leaked handle shows up here. */
21
+ tainted(): number;
22
+ close(): void;
23
+ };
24
+ /**
25
+ * The QuickJS side of the runner. `wasm` is compiled once and shared; every sandbox gets its own
26
+ * instance of it, so no heap is shared between renders. `kit` is the runner-kit bundle's text.
27
+ *
28
+ * Rules (decision 2026-11-08-2310): `executePendingJobs()` is never called, so a hostile microtask
29
+ * loop never runs and a package whose top-level `await` does not settle is a failed render; a
30
+ * sandbox whose engine throws to the host, `dispose()` included, is dropped and never reused.
31
+ */
32
+ export declare function createEngine(wasm: WebAssembly.Module, kit: string, options?: {
33
+ maxRuntimes?: number;
34
+ }): Engine;
@@ -0,0 +1,144 @@
1
+ import releaseSyncModule from "@jitl/quickjs-wasmfile-release-sync";
2
+ import { newQuickJSWASMModuleFromVariant, newVariant, shouldInterruptAfterDeadline, } from "quickjs-emscripten-core";
3
+ /** The package types describe its CommonJS build; Bun loads the ESM build, whose default export is the variant. */
4
+ const releaseSync = releaseSyncModule;
5
+ /**
6
+ * The entry module: it holds the render function in the sandbox's own global. It is the only code that
7
+ * touches the request, as one JSON string in and one out; nothing of the host is exposed to the realm.
8
+ */
9
+ const entry = `import { renderModule } from "liveframe:kit";
10
+ import widget from "liveframe:pkg";
11
+ globalThis.__render = (text) => {
12
+ const request = JSON.parse(text);
13
+ return JSON.stringify(renderModule(widget, request.inputs, request.options));
14
+ };`;
15
+ /**
16
+ * The QuickJS side of the runner. `wasm` is compiled once and shared; every sandbox gets its own
17
+ * instance of it, so no heap is shared between renders. `kit` is the runner-kit bundle's text.
18
+ *
19
+ * Rules (decision 2026-11-08-2310): `executePendingJobs()` is never called, so a hostile microtask
20
+ * loop never runs and a package whose top-level `await` does not settle is a failed render; a
21
+ * sandbox whose engine throws to the host, `dispose()` included, is dropped and never reused.
22
+ */
23
+ export function createEngine(wasm, kit, options = {}) {
24
+ const variant = newVariant(releaseSync, { wasmModule: wasm });
25
+ const maxRuntimes = options.maxRuntimes ?? 16;
26
+ /** Kept sandboxes, least recently used first. */
27
+ const kept = new Map();
28
+ let taintedCount = 0;
29
+ function drop(sandbox) {
30
+ try {
31
+ sandbox.context.dispose();
32
+ sandbox.runtime.dispose();
33
+ }
34
+ catch {
35
+ taintedCount++;
36
+ }
37
+ }
38
+ async function open(job) {
39
+ const quickjs = await newQuickJSWASMModuleFromVariant(variant);
40
+ const runtime = quickjs.newRuntime();
41
+ runtime.setMemoryLimit(job.limits.memoryBytes);
42
+ runtime.setMaxStackSize(job.limits.stackBytes);
43
+ const sources = { ...job.libraries, "liveframe:kit": kit, "liveframe:pkg": job.code };
44
+ runtime.setModuleLoader((name) => sources[name] ?? { error: new Error("module not found") });
45
+ const context = runtime.newContext();
46
+ const sandbox = { runtime, context };
47
+ try {
48
+ runtime.setInterruptHandler(shouldInterruptAfterDeadline(Date.now() + job.limits.timeoutMs));
49
+ const loaded = context.evalCode(entry, "entry.js", { type: "module" });
50
+ if (loaded.error) {
51
+ const message = describe(context, loaded.error);
52
+ drop(sandbox);
53
+ return message;
54
+ }
55
+ loaded.value.dispose();
56
+ const render = context.getProp(context.global, "__render");
57
+ const ready = context.typeof(render) === "function";
58
+ render.dispose();
59
+ if (!ready) {
60
+ drop(sandbox);
61
+ return "The package did not finish loading: top-level await is not supported";
62
+ }
63
+ return sandbox;
64
+ }
65
+ catch (error) {
66
+ taintedCount++;
67
+ return `QuickJS failed: ${describeHost(error)}`;
68
+ }
69
+ }
70
+ function call(sandbox, job) {
71
+ const { runtime, context } = sandbox;
72
+ runtime.setInterruptHandler(shouldInterruptAfterDeadline(Date.now() + job.limits.timeoutMs));
73
+ const render = context.getProp(context.global, "__render");
74
+ const text = context.newString(job.request);
75
+ const result = context.callFunction(render, context.undefined, text);
76
+ text.dispose();
77
+ render.dispose();
78
+ if (result.error)
79
+ return { error: describe(context, result.error) };
80
+ const value = result.value;
81
+ const json = context.typeof(value) === "string" ? context.getString(value) : undefined;
82
+ value.dispose();
83
+ return json === undefined ? { error: "The render function did not return a string" } : { json };
84
+ }
85
+ return {
86
+ async render(job) {
87
+ let sandbox = job.key === undefined ? undefined : kept.get(job.key);
88
+ if (sandbox && job.key !== undefined) {
89
+ kept.delete(job.key);
90
+ kept.set(job.key, sandbox);
91
+ }
92
+ if (!sandbox) {
93
+ const opened = await open(job);
94
+ if (typeof opened === "string")
95
+ return { error: opened };
96
+ sandbox = opened;
97
+ if (job.key !== undefined) {
98
+ kept.set(job.key, sandbox);
99
+ const oldest = kept.entries().next();
100
+ if (kept.size > maxRuntimes && !oldest.done) {
101
+ kept.delete(oldest.value[0]);
102
+ drop(oldest.value[1]);
103
+ }
104
+ }
105
+ }
106
+ let outcome;
107
+ try {
108
+ outcome = call(sandbox, job);
109
+ }
110
+ catch (error) {
111
+ taintedCount++;
112
+ outcome = { error: `QuickJS failed: ${describeHost(error)}` };
113
+ if (job.key !== undefined)
114
+ kept.delete(job.key);
115
+ // The instance threw to the host: it is not disposed again and never reused.
116
+ return outcome;
117
+ }
118
+ // A kept runtime is only reused after a clean render: a stopped or failed one may hold half-built state.
119
+ if ("error" in outcome || job.key === undefined) {
120
+ if (job.key !== undefined)
121
+ kept.delete(job.key);
122
+ drop(sandbox);
123
+ }
124
+ return outcome;
125
+ },
126
+ kept: () => kept.size,
127
+ tainted: () => taintedCount,
128
+ close() {
129
+ for (const sandbox of kept.values())
130
+ drop(sandbox);
131
+ kept.clear();
132
+ },
133
+ };
134
+ }
135
+ /** The message of an error value the engine produced, disposing the handle. */
136
+ function describe(context, handle) {
137
+ const error = context.dump(handle);
138
+ handle.dispose();
139
+ const message = typeof error === "string" ? error : error?.message;
140
+ return String(message ?? "Render failed").slice(0, 500);
141
+ }
142
+ function describeHost(error) {
143
+ return (error instanceof Error ? error.message : String(error)).slice(0, 200);
144
+ }
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "@arnilo/liveframe-runner-quickjs",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.0",
4
+ "description": "An isolated Liveframe runner: widget code runs in QuickJS inside a child process the runner can kill, so a host gets a real isolation boundary without building a sandbox.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "engines": {
8
+ "bun": ">=1.4.2"
9
+ },
10
+ "author": "Niloy Rahman",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/ashiqrniloy/liveframe.git",
14
+ "directory": "packages/runner-quickjs"
15
+ },
16
+ "homepage": "https://github.com/ashiqrniloy/liveframe#readme",
17
+ "bugs": {
18
+ "url": "https://github.com/ashiqrniloy/liveframe/issues"
19
+ },
20
+ "keywords": [
21
+ "liveframe",
22
+ "runner",
23
+ "sandbox",
24
+ "quickjs",
25
+ "bun"
26
+ ],
27
+ "publishConfig": {
28
+ "access": "public",
29
+ "tag": "next"
30
+ },
31
+ "exports": {
32
+ ".": {
33
+ "types": "./dist/index.d.ts",
34
+ "default": "./dist/index.js"
35
+ },
36
+ "./package.json": "./package.json"
37
+ },
38
+ "files": [
39
+ "dist"
40
+ ],
41
+ "sideEffects": [
42
+ "./dist/child.js"
43
+ ],
44
+ "peerDependencies": {
45
+ "@arnilo/liveframe": "~0.3.0"
46
+ },
47
+ "dependencies": {
48
+ "@jitl/quickjs-wasmfile-release-sync": "0.32.0",
49
+ "quickjs-emscripten-core": "0.32.0"
50
+ }
51
+ }