diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f023e2083..01b373228 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,6 +89,9 @@ jobs: - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 + - name: Check browser VM source drift + run: python3 scripts/check_zapcode_browser.py + - name: Check bashkit for wasm32-unknown-unknown run: cargo check -p bashkit -p bashkit-scripted-tool --target wasm32-unknown-unknown --features bashkit/jq,bashkit/wasm_js,bashkit/terminal env: @@ -175,6 +178,18 @@ jobs: export CHROME_EXECUTABLE=$(node --input-type=module -e 'const {chromium} = await import(process.env.PLAYWRIGHT_MODULE); console.log(chromium.executablePath())') node crates/bashkit-wasm/scripts/test-streaming-browser.mjs + - name: Build optional runtime bundle + run: BASHKIT_WASM_VARIANT=runtimes BASHKIT_WASM_FEATURES=terminal,python,typescript bash crates/bashkit-wasm/scripts/build.sh release + + - name: Embedded runtime callback tests + run: BASHKIT_WASM_VARIANT=runtimes node --test crates/bashkit-wasm/__test__/runtime-functions.test.mjs + + - name: Browser runtime callbacks (Chromium and module workers) + run: | + export PLAYWRIGHT_MODULE="$RUNNER_TEMP/bashkit-browser-test/node_modules/playwright/index.mjs" + export CHROME_EXECUTABLE=$(node --input-type=module -e 'const {chromium} = await import(process.env.PLAYWRIGHT_MODULE); console.log(chromium.executablePath())') + node crates/bashkit-wasm/scripts/test-runtime-browser.mjs + - name: Browser compatibility and transfer-size gates run: | node crates/bashkit-wasm/bench/compat.mjs diff --git a/.github/workflows/publish-wasm.yml b/.github/workflows/publish-wasm.yml index c712b19da..7e2612d11 100644 --- a/.github/workflows/publish-wasm.yml +++ b/.github/workflows/publish-wasm.yml @@ -72,6 +72,26 @@ jobs: - name: Integration tests (headless Node) run: node --test "crates/bashkit-wasm/__test__/*.test.mjs" + - name: Browser streaming (main thread and worker) + run: | + npm install --prefix "$RUNNER_TEMP/bashkit-browser-test" --no-save playwright@1.62.1 + export PLAYWRIGHT_MODULE="$RUNNER_TEMP/bashkit-browser-test/node_modules/playwright/index.mjs" + node "$RUNNER_TEMP/bashkit-browser-test/node_modules/playwright/cli.js" install --with-deps chromium + export CHROME_EXECUTABLE=$(node --input-type=module -e 'const {chromium} = await import(process.env.PLAYWRIGHT_MODULE); console.log(chromium.executablePath())') + node crates/bashkit-wasm/scripts/test-streaming-browser.mjs + + - name: Build optional runtime bundle + run: BASHKIT_WASM_VARIANT=runtimes BASHKIT_WASM_FEATURES=terminal,python,typescript bash crates/bashkit-wasm/scripts/build.sh release + + - name: Embedded runtime callback tests + run: BASHKIT_WASM_VARIANT=runtimes node --test crates/bashkit-wasm/__test__/runtime-functions.test.mjs + + - name: Browser runtime callbacks (Chromium and module workers) + run: | + export PLAYWRIGHT_MODULE="$RUNNER_TEMP/bashkit-browser-test/node_modules/playwright/index.mjs" + export CHROME_EXECUTABLE=$(node --input-type=module -e 'const {chromium} = await import(process.env.PLAYWRIGHT_MODULE); console.log(chromium.executablePath())') + node crates/bashkit-wasm/scripts/test-runtime-browser.mjs + - name: Browser compatibility and transfer-size gates run: | node crates/bashkit-wasm/bench/compat.mjs diff --git a/Cargo.lock b/Cargo.lock index a4fb66fc7..d41ed3467 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -396,6 +396,12 @@ dependencies = [ "num-bigint 0.4.8", "num-traits", "os_display", + "oxc_allocator", + "oxc_ast", + "oxc_parser", + "oxc_span", + "oxc_syntax", + "postcard", "pretty_assertions", "proptest", "rand 0.10.3", @@ -559,6 +565,7 @@ dependencies = [ "bashkit", "console_error_panic_hook", "futures-util", + "getrandom 0.3.4", "getrandom 0.4.3", "js-sys", "send_wrapper", diff --git a/contracts/capability-parity.json b/contracts/capability-parity.json index a3e5ba4b9..b4d61f938 100644 --- a/contracts/capability-parity.json +++ b/contracts/capability-parity.json @@ -207,7 +207,7 @@ }, "browser_wasm": { "label": "Browser WASM", - "supported": ["execution_limits", "cancellation", "deadlines", "output_caps", "cwd", "env", "custom_builtins", "virtual_filesystem", "snapshots"], + "supported": ["execution_limits", "cancellation", "deadlines", "output_caps", "cwd", "env", "custom_builtins", "virtual_filesystem", "snapshots", "runtime_python", "runtime_typescript", "host_functions_python", "host_functions_typescript", "interactive_terminal"], "evidence": { "execution_limits": ["crates/bashkit-wasm/__test__/bashkit-wasm.test.mjs#options: maxLoopIterations bounds runaway loops"], "cancellation": ["crates/bashkit-wasm/__test__/bashkit-wasm.test.mjs#cancel aborts in-flight work and remains sticky until clearCancel"], @@ -217,7 +217,12 @@ "env": ["crates/bashkit-wasm/__test__/bashkit-wasm.test.mjs#options: env and cwd"], "custom_builtins": ["crates/bashkit-wasm/__test__/bashkit-wasm.test.mjs#reset preserves registered custom builtins"], "virtual_filesystem": ["crates/bashkit-wasm/__test__/bashkit-wasm.test.mjs#bash.fs() returns a live handle"], - "snapshots": ["crates/bashkit-wasm/__test__/bashkit-wasm.test.mjs#commit and checkout restore persistent state"] + "snapshots": ["crates/bashkit-wasm/__test__/bashkit-wasm.test.mjs#commit and checkout restore persistent state"], + "runtime_python": ["crates/bashkit-wasm/__test__/runtime-functions.test.mjs#both browser runtimes share VFS"], + "runtime_typescript": ["crates/bashkit-wasm/__test__/runtime-functions.test.mjs#both browser runtimes share VFS"], + "host_functions_python": ["crates/bashkit-wasm/__test__/runtime-functions.test.mjs#browser host functions, reset and VFS"], + "host_functions_typescript": ["crates/bashkit-wasm/__test__/runtime-functions.test.mjs#browser host functions, reset and VFS"], + "interactive_terminal": ["crates/bashkit-wasm/__test__/terminal.test.mjs#terminal: vi edits a VFS file"] }, "unsupported": { "runtime_env": "The slim wasm package exposes no post-construction host mutation API; env is construction-only.", @@ -227,12 +232,7 @@ "network_policy": "The slim browser build has no network builtin or host socket access.", "transport_hooks": "The slim browser build has no HTTP transport.", "mounts": "Browser WASM exposes one in-memory VFS and no host filesystem mounts.", - "runtime_python": "The slim browser build omits embedded language runtimes.", - "runtime_typescript": "The slim browser build omits embedded language runtimes.", - "runtime_sqlite": "The slim browser build omits embedded language runtimes.", - "interactive_terminal": "Not exposed in the slim browser package yet.", - "host_functions_python": "The slim browser build currently omits embedded language runtimes.", - "host_functions_typescript": "The slim browser build currently omits embedded language runtimes." + "runtime_sqlite": "The slim browser build omits embedded language runtimes." } }, "c_abi": { diff --git a/crates/bashkit-wasm/Cargo.toml b/crates/bashkit-wasm/Cargo.toml index 7c2cddda9..64ac9eeb9 100644 --- a/crates/bashkit-wasm/Cargo.toml +++ b/crates/bashkit-wasm/Cargo.toml @@ -8,8 +8,9 @@ # app, including embedded/iframe contexts. See knowledge/runtimes/browser-package.md. # # Feature surface mirrors the `wasm` CI job (jq). http_client, -# ssh, sqlite, python, realfs, and interop are intentionally excluded: they need +# ssh, sqlite, realfs, and interop are intentionally excluded: they need # sockets, threads, or a host filesystem that the browser sandbox lacks. +# Python/TypeScript are optional features, published through the /runtimes import. # # JS callbacks (js_sys::Function / JsFuture) are `!Send`, but bashkit's `Builtin` # trait is `Send + Sync` (async_trait). wasm32 is single-threaded, so we bridge @@ -32,6 +33,8 @@ publish = false # `bashkit/terminal`; `scripts/build.sh` turns it on for the npm package because # it costs ~60 KB gzipped and stays inert until a page constructs `Terminal`. terminal = ["bashkit/terminal"] +python = ["bashkit/python", "dep:getrandom03"] +typescript = ["bashkit/typescript"] [lib] crate-type = ["cdylib", "rlib"] @@ -67,3 +70,10 @@ getrandom = { workspace = true, features = ["wasm_js"] } [package.metadata.wasm-pack.profile.release] # Keep the shipped .wasm small; the interpreter is large. wasm-opt = ["-Oz"] + +# Monty also uses getrandom 0.3; select its browser backend explicitly. +[dependencies.getrandom03] +package = "getrandom" +version = "0.3" +features = ["wasm_js"] +optional = true diff --git a/crates/bashkit-wasm/README.md b/crates/bashkit-wasm/README.md index eb16047e7..90625544a 100644 --- a/crates/bashkit-wasm/README.md +++ b/crates/bashkit-wasm/README.md @@ -259,7 +259,7 @@ filesystem there is no built-in VFS underneath to supply it. Plain bash plus the built-in text tooling (`grep`, `sed`, `awk`, `jq`, `find`, …) and `jq`. Not included in the browser build: outbound HTTP (`curl`/`wget`), -`ssh`, `sqlite`, and embedded `python`, these need sockets, threads, or a host +`ssh` and `sqlite`, these need sockets, threads, or a host filesystem the browser sandbox doesn't provide. Bridge to the network through a custom builtin (see above) so requests go through your app's own `fetch`. @@ -320,3 +320,16 @@ node crates/bashkit-wasm/bench/artifact.mjs crates/bashkit-wasm/pkg --check The [browser benchmark methodology](https://github.com/everruns/bashkit/blob/main/crates/bashkit-wasm/bench/README.md) describes reproducible cold-start and execution experiments and the measured profile tradeoff. + +## Optional Python/TypeScript and host functions + +Import `@everruns/bashkit-wasm/runtimes` for the runtime-enabled single-threaded +bundle; the default import stays slim. Enable `python`/`typescript` explicitly +and pass `externalFunctions`/`externalHandler` (Python) or +`typescriptExternalFunctions`/`typescriptExternalHandler` (TypeScript). +Callbacks support JSON values and an execution-local AbortSignal. Use async +`Bash.execute`, with one active execution per instance. See the +[host function guide](../../docs/start-browser.md#python-and-typescript-host-functions). + +Build both variants with `scripts/build.sh release`, followed by +`BASHKIT_WASM_VARIANT=runtimes BASHKIT_WASM_FEATURES=terminal,python,typescript scripts/build.sh release`. diff --git a/crates/bashkit-wasm/__test__/runtime-functions.test.mjs b/crates/bashkit-wasm/__test__/runtime-functions.test.mjs new file mode 100644 index 000000000..e1c7cdc71 --- /dev/null +++ b/crates/bashkit-wasm/__test__/runtime-functions.test.mjs @@ -0,0 +1,464 @@ +// Optional runtimes are tested against the real single-threaded wasm binary. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { AsyncLocalStorage } from "node:async_hooks"; +import { Worker } from "node:worker_threads"; +import { execFileSync } from "node:child_process"; +const variant = process.env.BASHKIT_WASM_VARIANT; +if (variant === "runtimes") { + const { initBashkit, Bash } = await import("../pkg/runtimes/index.js"); + await initBashkit( + await readFile( + new URL("../pkg/runtimes/bashkit_wasm_bg.wasm", import.meta.url), + ), + ); + for (const language of ["python", "typescript"]) { + const call = + language === "python" + ? `python -c 'print(lookup(21, multiplier=2))'` + : `ts -c 'await lookup(21, 2)'`; + const options = (handler) => + language === "python" + ? { + python: true, + externalFunctions: ["lookup"], + externalHandler: handler, + } + : { + typescript: true, + typescriptExternalFunctions: ["lookup"], + typescriptExternalHandler: (name, args, context) => + handler(name, args.slice(0, 1), { multiplier: args[1] }, context), + }; + test(`${language} browser host functions, reset and VFS`, async () => { + const bash = new Bash( + options(async (name, args, kwargs, { signal }) => { + assert.equal(name, "lookup"); + assert.equal(signal.aborted, false); + await new Promise((resolve) => setTimeout(resolve, 1)); + return args[0] * kwargs.multiplier; + }), + ); + for (let i = 0; i < 2; i++) { + const result = await bash.execute(call); + assert.equal(result.exitCode, 0, result.stderr); + assert.equal(result.stdout, "42\n"); + assert.notEqual(bash.executeSync(call).exitCode, 0); + bash.reset(); + } + }); + test(`${language} browser pending host work aborts on timeout`, async () => { + let aborted = false, + started = false; + const bash = new Bash({ + ...options(async (_name, _args, _kwargs, { signal }) => { + started = true; + await new Promise((_resolve, reject) => + signal.addEventListener( + "abort", + () => { + aborted = true; + reject(new Error("aborted")); + }, + { once: true }, + ), + ); + }), + timeoutMs: 200, + }); + await bash.execute(call).then( + (r) => assert.notEqual(r.exitCode, 0), + () => {}, + ); + assert.equal(started, true); + assert.equal(aborted, true); + assert.equal((await bash.execute("echo reusable")).stdout, "reusable\n"); + }); + + test(`${language} browser cancellation and busy access are isolated`, async () => { + let ready; + const started = new Promise((r) => { + ready = r; + }); + let signal; + const bash = new Bash( + options(async (_name, _args, _kwargs, context) => { + signal = context.signal; + ready(); + await new Promise((_resolve, reject) => + signal.addEventListener( + "abort", + () => reject(new Error("aborted")), + { once: true }, + ), + ); + }), + ); + const controller = new AbortController(); + const execution = bash.execute(call, { signal: controller.signal }); + await started; + await assert.rejects(bash.execute("echo concurrent"), /busy/); + assert.throws(() => bash.reset(), /busy/); + controller.abort(); + await execution.catch(() => {}); + assert.equal(signal.aborted, true); + bash.clearCancel(); + assert.equal((await bash.execute("echo reusable")).stdout, "reusable\n"); + }); + + test(`${language} browser recursive execution and invalid values fail safely`, async () => { + let bash; + bash = new Bash(options(() => bash.execute("echo recursive"))); + assert.notEqual((await bash.execute(call)).exitCode, 0); + for (const value of [NaN, new Date(), () => 42, 2 ** 60]) { + const invalid = new Bash(options(() => value)); + assert.notEqual((await invalid.execute(call)).exitCode, 0); + } + let called = false; + const badGuest = new Bash( + options(() => { + called = true; + return 42; + }), + ); + const script = + language === "python" + ? `python -c 'lookup({1: 2})'` + : `ts -c 'await lookup(() => 42)'`; + assert.notEqual((await badGuest.execute(script)).exitCode, 0); + assert.equal(called, false); + }); + + test(`${language} browser CPU deadlines use a real clock`, async () => { + const bash = new Bash({ [language]: true, timeoutMs: 50 }); + const script = + language === "python" + ? `python -c 'while True: pass'` + : `ts -c 'while (true) {}'`; + const start = performance.now(); + await bash.execute(script).then( + (r) => assert.notEqual(r.exitCode, 0), + () => {}, + ); + assert.ok(performance.now() - start < 3000); + assert.equal((await bash.execute("echo reusable")).stdout, "reusable\n"); + }); + } + + test("both browser runtimes share VFS and host functions survive checkout", async () => { + const bash = new Bash({ + python: true, + typescript: true, + externalFunctions: ["lookup"], + externalHandler: () => 21, + typescriptExternalFunctions: ["double"], + typescriptExternalHandler: (_name, args) => Number(args[0]) * 2, + }); + const result = await bash.execute( + `python -c 'from pathlib import Path; n = Path("/tmp/number").write_text(str(lookup()))'; ts -c 'await double(await readFile("/tmp/number"))'`, + ); + assert.equal(result.stdout, "42\n"); + const commit = bash.commit(); + bash.reset(); + bash.checkout(commit.id, commit.objects); + assert.equal( + ( + await bash.execute( + `ts -c 'await double(await readFile("/tmp/number"))'`, + ) + ).stdout, + "42\n", + ); + assert.equal( + (await bash.execute(`python -c 'print(lookup())'`)).stdout, + "21\n", + ); + }); + + test("browser WASM callbacks stay independent across worker environments", async () => { + await Promise.all( + Array.from( + { length: 4 }, + () => + new Promise((resolve, reject) => { + const worker = new Worker( + new URL("./runtime-worker.mjs", import.meta.url), + ); + worker.on("error", reject); + worker.on("exit", (code) => + code ? reject(new Error(`worker exited ${code}`)) : resolve(), + ); + }), + ), + ); + }); + test("published archive includes slim and optional runtime entry points", () => { + const [archive] = JSON.parse( + execFileSync("npm", ["pack", "--dry-run", "--json", "--ignore-scripts"], { + cwd: new URL("../pkg/", import.meta.url), + encoding: "utf8", + timeout: 30000, + }), + ); + const files = new Set(archive.files.map((file) => file.path)); + for (const prefix of ["", "runtimes/"]) { + for (const name of [ + "index.js", + "index.d.ts", + "runtime-functions.js", + "bashkit_wasm.js", + "bashkit_wasm_bg.wasm", + "build-info.json", + ]) { + assert.ok(files.has(prefix + name), `archive lacks ${prefix + name}`); + } + } + }); + + test("WASM host callbacks keep independent caller async contexts", async () => { + const request = new AsyncLocalStorage(); + const make = () => + new Bash({ + python: true, + typescript: true, + externalFunctions: ["lookup"], + externalHandler: async () => request.getStore(), + typescriptExternalFunctions: ["lookup"], + typescriptExternalHandler: async () => request.getStore(), + }); + const a = make(), + b = make(); + const results = await Promise.all([ + request.run("a", () => a.execute("python -c 'print(lookup())'")), + request.run("b", () => b.execute("ts -c 'await lookup()'")), + ]); + assert.deepEqual( + results.map((r) => r.stdout), + ["a\n", "b\n"], + ); + }); + + test("one WASM execution can await concurrent host calls", async () => { + let count = 0, + release; + const gate = new Promise((resolve) => { + release = resolve; + }); + const arrive = async () => { + if (++count === 2) release(); + await gate; + return 42; + }; + const bash = new Bash({ + python: true, + typescript: true, + timeoutMs: 2000, + externalFunctions: ["lookup"], + externalHandler: arrive, + typescriptExternalFunctions: ["lookup"], + typescriptExternalHandler: arrive, + }); + const result = await bash.execute( + "python -c 'print(lookup())' & ts -c 'await lookup()' & wait", + ); + assert.equal(result.exitCode, 0, result.stderr); + assert.equal(count, 2); + assert.deepEqual(result.stdout.trim().split("\n"), ["42", "42"]); + }); + test("runtime streaming reports a callback failure once without host paths", async () => { + const bash = new Bash({ + python: true, + externalFunctions: ["lookup"], + externalHandler: () => 42, + }); + let calls = 0; + await assert.rejects( + bash.executeWithOutput( + "echo before; python -c 'print(lookup())'; echo after", + () => { + calls++; + throw new Error("output failed in /home/host/private.js"); + }, + ), + (error) => + /output failed/.test(error.message) && + !error.message.includes("private.js"), + ); + assert.equal(calls, 1); + assert.equal((await bash.execute("echo reusable")).stdout, "reusable\n"); + }); + + for (const language of ["python", "typescript"]) { + const script = + language === "python" + ? "python -c 'print(lookup())'" + : "ts -c 'await lookup()'"; + const runtime = (handler) => + language === "python" + ? { + python: true, + externalFunctions: ["lookup"], + externalHandler: (_n, _a, _k, context) => handler(context), + } + : { + typescript: true, + typescriptExternalFunctions: ["lookup"], + typescriptExternalHandler: (_n, _a, context) => handler(context), + }; + test( + `${language} host functions share live commands, byte sinks and capture`, + { timeout: 5000 }, + async () => { + let signal; + const bash = new Bash({ + ...runtime(async (context) => { + signal = context.signal; + await new Promise((resolve) => setTimeout(resolve, 1)); + return 42; + }), + commands: { + async copy(ctx) { + for await (const bytes of ctx.stdin) + await ctx.stdout.write(bytes); + return 0; + }, + }, + }); + for (let i = 0; i < 2; i++) { + const chunks = []; + const result = await bash.execute(`${script} | copy`, { + capture: 2, + async stdout(bytes) { + assert.equal(signal.aborted, false); + await new Promise((resolve) => setTimeout(resolve, 1)); + assert.equal(signal.aborted, false); + chunks.push(bytes); + }, + }); + assert.equal(result.exitCode, 0, result.stderr); + assert.equal(result.stdout, "42"); + assert.equal(result.stdoutTruncated, true); + assert.equal(Buffer.concat(chunks).toString(), "42\n"); + assert.equal(signal.aborted, true); + bash.reset(); + } + }, + ); + test( + `${language} live cancellation revokes pending host calls and permits reuse`, + { timeout: 5000 }, + async () => { + let ready, signal; + const started = new Promise((resolve) => { + ready = resolve; + }); + const bash = new Bash( + runtime(async (context) => { + signal = context.signal; + ready(); + await new Promise(() => {}); + }), + ); + await assert.rejects( + bash.execute(script, { capture: 64, signal: AbortSignal.abort() }), + (error) => error.name === "AbortError", + ); + assert.equal(signal, undefined); + const controller = new AbortController(); + const pending = bash.execute(script, { + capture: 64, + signal: controller.signal, + }); + await started; + controller.abort(); + await assert.rejects(pending, /cancelled/); + assert.equal(signal.aborted, true); + assert.equal( + (await bash.execute("echo recovered")).stdout, + "recovered\n", + ); + }, + ); + test( + `${language} live sink deadlines revoke the host session`, + { timeout: 5000 }, + async () => { + let signal, + delivered = false; + const bash = new Bash({ + ...runtime((context) => { + signal = context.signal; + return 42; + }), + timeoutMs: 200, + }); + await assert.rejects( + bash.execute(script, { + stdout: async () => { + delivered = true; + assert.equal(signal.aborted, false); + await new Promise(() => {}); + }, + }), + /timed out|timeout|deadline/i, + ); + assert.equal(delivered, true); + assert.equal(signal.aborted, true); + assert.equal( + (await bash.execute("echo reusable")).stdout, + "reusable\n", + ); + }, + ); + } + test( + "live execution preserves each host callback caller context", + { timeout: 5000 }, + async () => { + const request = new AsyncLocalStorage(); + const make = () => + new Bash({ + typescript: true, + typescriptExternalFunctions: ["lookup"], + typescriptExternalHandler: async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + return request.getStore(); + }, + }); + const a = make(), + b = make(); + const options = { + capture: 64, + async stdout() { + await new Promise((resolve) => setTimeout(resolve, 1)); + }, + }; + const results = await Promise.all([ + request.run("a", () => a.execute("ts -c 'await lookup()'", options)), + request.run("b", () => b.execute("ts -c 'await lookup()'", options)), + ]); + assert.deepEqual( + results.map((result) => result.stdout), + ["a\n", "b\n"], + ); + }, + ); + test("text observers reject incompatible live options before host work", async () => { + let calls = 0; + const bash = new Bash({ + python: true, + externalFunctions: ["lookup"], + externalHandler: () => ++calls, + }); + await assert.rejects( + bash.execute("python -c 'print(lookup())'", { + capture: 64, + onOutput() {}, + }), + /byte sinks/, + ); + assert.equal(calls, 0); + assert.equal((await bash.execute("echo reusable")).stdout, "reusable\n"); + }); +} diff --git a/crates/bashkit-wasm/__test__/runtime-worker.mjs b/crates/bashkit-wasm/__test__/runtime-worker.mjs new file mode 100644 index 000000000..ce434eb13 --- /dev/null +++ b/crates/bashkit-wasm/__test__/runtime-worker.mjs @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { Bash, initBashkit } from "../pkg/runtimes/index.js"; +await initBashkit( + await readFile( + new URL("../pkg/runtimes/bashkit_wasm_bg.wasm", import.meta.url), + ), +); +const bash = new Bash({ + python: true, + typescript: true, + externalFunctions: ["lookup"], + externalHandler: async () => 42, + typescriptExternalFunctions: ["lookup"], + typescriptExternalHandler: async () => 42, +}); +for (let i = 0; i < 5; i++) { + const result = await bash.execute( + `python -c 'print(lookup())'; ts -c 'await lookup()'`, + ); + assert.equal(result.stdout, "42\n42\n"); +} +bash.free(); diff --git a/crates/bashkit-wasm/bench/artifact.mjs b/crates/bashkit-wasm/bench/artifact.mjs index 593997361..31b7c36f8 100644 --- a/crates/bashkit-wasm/bench/artifact.mjs +++ b/crates/bashkit-wasm/bench/artifact.mjs @@ -6,7 +6,7 @@ import { createHash } from "node:crypto"; import { gzipSync, brotliCompressSync, constants } from "node:zlib"; export function artifact(pkg) { - const files = ["index.js", "bashkit_wasm.js", "bashkit_wasm_bg.wasm"].map(name => { + const files = ["index.js", "runtime-functions.js", "bashkit_wasm.js", "bashkit_wasm_bg.wasm"].filter(name => existsSync(resolve(pkg, name))).map(name => { const bytes = readFileSync(resolve(pkg, name)); return { name, sha256: createHash("sha256").update(bytes).digest("hex"), raw: bytes.length, gzip: gzipSync(bytes, { level: 9 }).length, diff --git a/crates/bashkit-wasm/bench/cold-start.mjs b/crates/bashkit-wasm/bench/cold-start.mjs index d749e49c8..d16c898b2 100644 --- a/crates/bashkit-wasm/bench/cold-start.mjs +++ b/crates/bashkit-wasm/bench/cold-start.mjs @@ -1,7 +1,7 @@ // Manual browser experiment. CI gates deterministic bytes/semantics, not noisy wall time. // Supply PLAYWRIGHT_MODULE when Playwright is installed outside this workspace. import { createServer } from "node:http"; -import { readFileSync } from "node:fs"; +import { readFileSync, existsSync } from "node:fs"; import { resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { brotliCompressSync, constants } from "node:zlib"; @@ -19,7 +19,8 @@ const conditions = { }; const routes = new Map(); packages.forEach((pkg, i) => { - for (const file of ["index.js", "bashkit_wasm.js", "bashkit_wasm_bg.wasm"]) { + for (const file of ["index.js", "runtime-functions.js", "bashkit_wasm.js", "bashkit_wasm_bg.wasm"]) { + if (!existsSync(resolve(pkg, file))) continue; routes.set(`/${i}/${file}`, { type: file.endsWith("wasm") ? "application/wasm" : "text/javascript", bytes: brotliCompressSync(readFileSync(resolve(pkg, file)), { params: { [constants.BROTLI_PARAM_QUALITY]: 11 } }) }); } diff --git a/crates/bashkit-wasm/js/index.d.ts b/crates/bashkit-wasm/js/index.d.ts index 9a963b37f..2fcb02c6b 100644 --- a/crates/bashkit-wasm/js/index.d.ts +++ b/crates/bashkit-wasm/js/index.d.ts @@ -35,9 +35,7 @@ export interface BuiltinRequest { * `Promise` of it. Async callbacks are only awaited by {@link Bash.execute} * (not `executeSync`). Throwing / rejecting becomes stderr with exit code 1. */ -export type CustomBuiltin = ( - ctx: BuiltinRequest, -) => string | Promise; +export type CustomBuiltin = (ctx: BuiltinRequest) => string | Promise; /** Live byte I/O; handles are revoked when their execution ends. */ export interface CommandContext { @@ -52,8 +50,12 @@ export interface CommandContext { readonly stdout: { write(bytes: Uint8Array): Promise }; readonly stderr: { write(bytes: Uint8Array): Promise }; } -export type CommandCallback = (ctx: CommandContext) => Promise; +export type CommandCallback = ( + ctx: CommandContext, +) => Promise; export interface ExecuteOptions { + /** Text observer; use byte sinks with capture or AbortSignal. */ + onOutput?: (stdout: string, stderr: string) => void; /** Maximum captured bytes per stream. Without capture, sinks retain no output. */ capture?: number; stdout?: (bytes: Uint8Array) => void | Promise; @@ -138,7 +140,39 @@ export interface HostFileSystem { } /** Options for constructing a {@link Bash} instance. */ +/** Values shared by Python, TypeScript and the browser host (depth <= 64). */ +export type RuntimeValue = + | null + | boolean + | number + | string + | RuntimeValue[] + | { [key: string]: RuntimeValue }; +export interface HostFunctionContext { + signal: AbortSignal; +} +export type ExternalHandler = ( + name: string, + args: RuntimeValue[], + kwargs: Record, + context: HostFunctionContext, +) => RuntimeValue | undefined | Promise; +export type TypeScriptExternalHandler = ( + name: string, + args: RuntimeValue[], + context: HostFunctionContext, +) => RuntimeValue | undefined | Promise; + export interface BashOptions { + /** Requires the optional /runtimes bundle; disabled by default. */ + python?: boolean; + /** Requires the optional /runtimes bundle; disabled by default. */ + typescript?: boolean; + externalFunctions?: string[]; + externalHandler?: ExternalHandler; + typescriptExternalFunctions?: string[]; + typescriptExternalHandler?: TypeScriptExternalHandler; + /** Resource-policy baseline; individual limit options override it. */ profile?: ExecutionProfileName; username?: string; @@ -276,7 +310,14 @@ export class Bash { } /** Options for constructing a {@link Terminal}. */ -export interface TerminalOptions extends Omit { +export interface TerminalOptions extends Omit< + BashOptions, + | "commands" + | "externalFunctions" + | "externalHandler" + | "typescriptExternalFunctions" + | "typescriptExternalHandler" +> { /** Rows (default 24, min 2). */ rows?: number; /** Columns (default 80, min 2). */ @@ -285,8 +326,7 @@ export interface TerminalOptions extends Omit { /** Result of {@link Terminal.runUntilIdle}. */ export type TerminalStatus = - | { status: "idle" } - | { status: "exited"; exitCode: number }; + { status: "idle" } | { status: "exited"; exitCode: number }; /** * Interactive bash session on an in-memory terminal: keystrokes in, diff --git a/crates/bashkit-wasm/js/index.js b/crates/bashkit-wasm/js/index.js index a9017e35c..38239b911 100644 --- a/crates/bashkit-wasm/js/index.js +++ b/crates/bashkit-wasm/js/index.js @@ -7,31 +7,100 @@ import init, * as glue from "./bashkit_wasm.js"; +import { validateHandlers, createDispatcher } from "./runtime-functions.js"; const { ExecResult } = glue; +const configs = new WeakMap(); +/** Browser interpreter. One active execution per instance; callbacks are execution-local. */ export class Bash extends glue.Bash { - constructor(options) { + constructor(options = {}) { + options ??= {}; + validateHandlers(options); super(options); - for (const [name, callback] of Object.entries(options?.commands ?? {})) this.addCommand(name, callback); + configs.set(this, { options: { ...options }, running: false }); + for (const [name, callback] of Object.entries(options.commands ?? {})) + this.addCommand(name, callback); } - async execute(commands, options) { - const signal = options?.signal; - if (signal?.aborted) throw new DOMException("execution cancelled", "AbortError"); + + execute(commands, options = {}) { + const state = configs.get(this); + if (state.running) + return Promise.reject( + new Error( + "bash instance is busy (reentrant execution is not supported)", + ), + ); + if (options.signal?.aborted) + return Promise.reject( + new DOMException("execution cancelled", "AbortError"), + ); + if ( + options.onOutput && + (options.signal || + options.capture !== undefined || + options.stdout || + options.stderr) + ) + return Promise.reject( + new TypeError( + "use stdout/stderr byte sinks instead of combining onOutput with live execution options", + ), + ); + const controller = new AbortController(); let aborted = false; - const onAbort = () => { aborted = true; this.cancel(); }; - signal?.addEventListener("abort", onAbort, { once: true }); - try { return await super.execute(commands, options); } - finally { - signal?.removeEventListener("abort", onAbort); + const cancel = () => { + aborted = true; + this.cancel(); + controller.abort(); + }; + state.running = true; + options.signal?.addEventListener("abort", cancel, { once: true }); + try { + const host = + state.options.externalHandler || + state.options.typescriptExternalHandler; + const result = host + ? super.executeWithRuntime( + commands, + createDispatcher(state.options, controller.signal), + options.onOutput, + options, + ) + : options.onOutput + ? super.executeWithOutput(commands, options.onOutput) + : super.execute(commands, options); + return result.finally(() => { + state.running = false; + options.signal?.removeEventListener("abort", cancel); + if (aborted) this.clearCancel(); + controller.abort(); + }); + } catch (error) { + state.running = false; + options.signal?.removeEventListener("abort", cancel); if (aborted) this.clearCancel(); + controller.abort(); + return Promise.reject(error); } } + executeSync(commands, options) { - if (options && (options.capture !== undefined || options.stdout || options.stderr)) { + if ( + options && + (options.capture !== undefined || options.stdout || options.stderr) + ) throw new TypeError("streaming I/O requires async execute()"); - } return super.executeSync(commands); } + + executeWithOutput(commands, onOutput) { + return this.execute(commands, { onOutput }); + } + + reset() { + if (configs.get(this).running) throw new Error("bash instance is busy"); + return super.reset(); + } } /** diff --git a/crates/bashkit-wasm/js/runtime-functions.js b/crates/bashkit-wasm/js/runtime-functions.js new file mode 100644 index 000000000..76f56f7bb --- /dev/null +++ b/crates/bashkit-wasm/js/runtime-functions.js @@ -0,0 +1,138 @@ +// JSON interop follows the native JS package; this module has no Node imports. +function runtimeValue(value, depth = 0) { + if (depth > 64) + throw new RangeError("host value nesting depth exceeds maximum of 64"); + if (value == null) return null; + if (typeof value === "string" || typeof value === "boolean") return value; + if (typeof value === "number") { + if (!Number.isFinite(value)) + throw new TypeError("host numbers must be finite"); + if (Number.isInteger(value) && !Number.isSafeInteger(value)) { + throw new RangeError("host integers must be safe JavaScript integers"); + } + return value; + } + if (Array.isArray(value)) + return value.map((item) => runtimeValue(item, depth + 1)); + if ( + typeof value === "object" && + [null, Object.prototype].includes(Object.getPrototypeOf(value)) + ) { + // fromEntries creates own data properties, including the __proto__ key. + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + runtimeValue(item, depth + 1), + ]), + ); + } + throw new TypeError("external handlers require JSON values"); +} +async function abortable(invoke, signal) { + if (signal.aborted) throw new Error("host execution is no longer active"); + let abort; + const cancelled = new Promise((_resolve, reject) => { + abort = () => reject(new Error("host execution is no longer active")); + signal.addEventListener("abort", abort, { once: true }); + }); + try { + return await Promise.race([Promise.resolve().then(invoke), cancelled]); + } finally { + signal.removeEventListener("abort", abort); + } +} + +export function validateHandlers(options = {}) { + for (const [runtime, names, handler] of [ + ["python", "externalFunctions", "externalHandler"], + ["typescript", "typescriptExternalFunctions", "typescriptExternalHandler"], + ]) { + if (options[names]?.length && typeof options[handler] !== "function") + throw new TypeError(`${handler} is required with external functions`); + if ( + options[handler] !== undefined && + (typeof options[handler] !== "function" || options[runtime] !== true) + ) + throw new TypeError( + `${handler} requires ${runtime}: true and a callable handler`, + ); + } +} + +function dispatchRequest(options, signal) { + return async (request) => { + try { + const { language, name, args, kwargs } = JSON.parse(request); + const values = runtimeValue(args); + const context = { signal }; + const result = await abortable( + () => + language === "python" + ? options.externalHandler( + name, + values, + runtimeValue(kwargs), + context, + ) + : options.typescriptExternalHandler(name, values, context), + signal, + ); + return JSON.stringify({ status: "ok", value: runtimeValue(result) }); + } catch (error) { + // THREAT[TM-INF-028]: no host paths or stack traces cross this boundary. + const message = (error instanceof Error ? error.message : String(error)) + .replace(/file:\/\/[^\s]*/g, "") + .replace(/(?") + .replace(/[A-Za-z]:[\\/][^\s]*/g, "") + .slice(0, 256); + return JSON.stringify({ + status: "error", + value: message || "host callback failed", + }); + } + }; +} + +// THREAT[TM-ISO-029]: wasm-bindgen shares a task wakeup queue between instances. +// Register the +// receiving promise in the execute caller's context, then launch each host +// invocation there. This preserves AsyncLocalStorage/async context in JS hosts +// without importing Node APIs, and lets independent host calls run concurrently. +export function createDispatcher(options, signal) { + const dispatch = dispatchRequest(options, signal); + let queue = [], + wake; + let incoming = new Promise((resolve) => { + wake = resolve; + }); + const abort = () => { + for (const job of queue.splice(0)) + job.reject(new Error("host execution is no longer active")); + wake(); + }; + signal.addEventListener("abort", abort, { once: true }); + void (async () => { + try { + while (!signal.aborted) { + await incoming; + if (signal.aborted) break; + incoming = new Promise((resolve) => { + wake = resolve; + }); + for (const job of queue.splice(0)) { + void dispatch(job.request).then(job.resolve, job.reject); + } + } + } finally { + signal.removeEventListener("abort", abort); + } + })(); + return (request) => { + if (signal.aborted) + return Promise.reject(new Error("host execution is no longer active")); + return new Promise((resolve, reject) => { + queue.push({ request, resolve, reject }); + wake(); + }); + }; +} diff --git a/crates/bashkit-wasm/package.json b/crates/bashkit-wasm/package.json index 92cf145c1..96df5b988 100644 --- a/crates/bashkit-wasm/package.json +++ b/crates/bashkit-wasm/package.json @@ -1,7 +1,7 @@ { "name": "@everruns/bashkit-wasm", "version": "0.19.0", - "description": "Sandboxed bash interpreter for the browser (WebAssembly). Single-threaded — no SharedArrayBuffer, no COOP/COEP headers.", + "description": "Sandboxed bash interpreter for the browser (WebAssembly). Single-threaded \u2014 no SharedArrayBuffer, no COOP/COEP headers.", "type": "module", "main": "index.js", "module": "index.js", @@ -12,11 +12,18 @@ "default": "./index.js" }, "./bashkit_wasm_bg.wasm": "./bashkit_wasm_bg.wasm", - "./build-info.json": "./build-info.json" + "./build-info.json": "./build-info.json", + "./runtimes": { + "types": "./runtimes/index.d.ts", + "default": "./runtimes/index.js" + }, + "./runtimes/bashkit_wasm_bg.wasm": "./runtimes/bashkit_wasm_bg.wasm" }, "sideEffects": [ "./index.js", - "./bashkit_wasm.js" + "./bashkit_wasm.js", + "./runtimes/index.js", + "./runtimes/bashkit_wasm.js" ], "files": [ "index.js", @@ -26,7 +33,9 @@ "bashkit_wasm_bg.wasm", "bashkit_wasm_bg.wasm.d.ts", "build-info.json", - "README.md" + "README.md", + "runtime-functions.js", + "runtimes/" ], "license": "MIT", "author": "Mykhailo Chalyi ", diff --git a/crates/bashkit-wasm/scripts/build-info.mjs b/crates/bashkit-wasm/scripts/build-info.mjs index 264d049e5..380bfc7ec 100644 --- a/crates/bashkit-wasm/scripts/build-info.mjs +++ b/crates/bashkit-wasm/scripts/build-info.mjs @@ -22,7 +22,7 @@ try { // from the package version; CI/release checkouts always have both fields. } const info = { - version: JSON.parse(readFileSync(resolve(pkg, "package.json"))).version, + version: JSON.parse(readFileSync(new URL("../package.json", import.meta.url))).version, sourceRevision, sourceDirty, profile, diff --git a/crates/bashkit-wasm/scripts/build.sh b/crates/bashkit-wasm/scripts/build.sh index b4a462775..ed0ef4dba 100755 --- a/crates/bashkit-wasm/scripts/build.sh +++ b/crates/bashkit-wasm/scripts/build.sh @@ -10,7 +10,11 @@ set -euo pipefail CRATE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" WORKSPACE_ROOT="$(cd "$CRATE_DIR/../.." && pwd)" -OUT_DIR="$CRATE_DIR/pkg" +case "${BASHKIT_WASM_VARIANT:-}" in + ""|runtimes) ;; + *) echo "unsupported wasm variant" >&2; exit 1 ;; +esac +OUT_DIR="$CRATE_DIR/pkg${BASHKIT_WASM_VARIANT:+/$BASHKIT_WASM_VARIANT}" TARGET=wasm32-unknown-unknown PROFILE="${1:-wasm-release}" # Cargo features, comma-separated. The npm package ships `terminal` (the @@ -57,8 +61,9 @@ fi # Ship the hand-authored wrapper + option/context types alongside the generated glue. cp "$CRATE_DIR/js/index.js" "$OUT_DIR/index.js" +cp "$CRATE_DIR/js/runtime-functions.js" "$OUT_DIR/runtime-functions.js" cp "$CRATE_DIR/js/index.d.ts" "$OUT_DIR/index.d.ts" -cp "$CRATE_DIR/package.json" "$OUT_DIR/package.json" +if [[ -z "${BASHKIT_WASM_VARIANT:-}" ]]; then cp "$CRATE_DIR/package.json" "$OUT_DIR/package.json"; fi cp "$CRATE_DIR/README.md" "$OUT_DIR/README.md" node "$CRATE_DIR/scripts/build-info.mjs" "$OUT_DIR" "$PROFILE" "$FEATURES" "$WASM_OPT_VERSION" diff --git a/crates/bashkit-wasm/scripts/test-runtime-browser.mjs b/crates/bashkit-wasm/scripts/test-runtime-browser.mjs new file mode 100644 index 000000000..1716a6f00 --- /dev/null +++ b/crates/bashkit-wasm/scripts/test-runtime-browser.mjs @@ -0,0 +1,116 @@ +// Real Chromium + module workers, deliberately served without isolation headers. +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { readFileSync, existsSync } from "node:fs"; +const { chromium } = await import( + process.env.PLAYWRIGHT_MODULE ?? "playwright" +); +const base = new URL("../pkg/runtimes/", import.meta.url); +const files = new Map( + [ + "index.js", + "runtime-functions.js", + "bashkit_wasm.js", + "bashkit_wasm_bg.wasm", + ].map((name) => [`/${name}`, readFileSync(new URL(name, base))]), +); +const worker = `import { Bash, initBashkit } from '/index.js'; +await initBashkit(); +const bash = new Bash({typescript:true,typescriptExternalFunctions:['lookup'],typescriptExternalHandler:async()=>42}); +postMessage((await bash.execute("ts -c 'await lookup()'", {capture:64, stdout:async()=>{await Promise.resolve();}})).stdout);`; +const server = createServer((req, res) => { + if (req.url === "/") + return res + .writeHead(200, { "Content-Type": "text/html" }) + .end("Runtime test"); + if (req.url === "/worker.mjs") + return res + .writeHead(200, { "Content-Type": "text/javascript" }) + .end(worker); + if (req.url === "/service") + return res + .writeHead(200, { "Content-Type": "application/json" }) + .end('{"value":42}'); + const file = files.get(req.url); + if (!file) return res.writeHead(404).end(); + res + .writeHead(200, { + "Content-Type": req.url.endsWith(".wasm") + ? "application/wasm" + : "text/javascript", + }) + .end(file); +}); +await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +const executablePath = + process.env.CHROME_EXECUTABLE ?? + process.env.BASHKIT_CHROMIUM ?? + ["/usr/bin/chromium", "/usr/bin/google-chrome"].find(existsSync); +let browser; +try { + browser = await chromium.launch({ executablePath, headless: true }); + const page = await browser.newPage(); + const errors = []; + page.on("pageerror", (error) => errors.push(String(error))); + await page.goto(`http://127.0.0.1:${server.address().port}/`); + const result = await page.evaluate(async () => { + const { Bash, initBashkit } = await import("/index.js"); + await initBashkit(); + const lookup = async (_name, _args, _kwargs, { signal }) => + (await (await fetch("/service", { signal })).json()).value; + const bash = new Bash({ + python: true, + typescript: true, + externalFunctions: ["lookup"], + externalHandler: lookup, + typescriptExternalFunctions: ["lookup"], + typescriptExternalHandler: (name, args, context) => + lookup(name, args, {}, context), + }); + const outputs = [], + streamed = []; + for (const script of [ + "python -c 'print(lookup())'", + "ts -c 'await lookup()'", + ]) { + const result = await bash.execute(script, { + capture: 64, + stdout: async (bytes) => { + await Promise.resolve(); + streamed.push(new TextDecoder().decode(bytes)); + }, + }); + outputs.push(result.stdout); + } + const workers = await Promise.all( + Array.from( + { length: 3 }, + () => + new Promise((resolve, reject) => { + const worker = new Worker("/worker.mjs", { type: "module" }); + worker.onmessage = (event) => { + worker.terminate(); + resolve(event.data); + }; + worker.onerror = (event) => { + worker.terminate(); + reject(new Error(event.message)); + }; + }), + ), + ); + return { isolated: crossOriginIsolated, outputs, streamed, workers }; + }); + assert.equal(result.isolated, false); + assert.deepEqual(result.outputs, ["42\n", "42\n"]); + assert.deepEqual(result.streamed, ["42\n", "42\n"]); + assert.deepEqual(result.workers, ["42\n", "42\n", "42\n"]); + assert.deepEqual(errors, []); + console.log( + "Chromium: Python/TypeScript callbacks, fetch and module workers pass without COOP/COEP", + ); +} finally { + await browser?.close(); + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); +} diff --git a/crates/bashkit-wasm/src/lib.rs b/crates/bashkit-wasm/src/lib.rs index e8b761a46..a967a7c96 100644 --- a/crates/bashkit-wasm/src/lib.rs +++ b/crates/bashkit-wasm/src/lib.rs @@ -35,6 +35,9 @@ use serde::Serialize; use wasm_bindgen::prelude::*; use wasm_bindgen_futures::{JsFuture, future_to_promise}; +#[cfg(any(feature = "python", feature = "typescript"))] +mod runtime_functions; + mod hostfs; mod streaming; use hostfs::HostFs; @@ -365,6 +368,14 @@ fn sanitize_js_error(value: &JsValue) -> String { struct Config { registry: bashkit::BuiltinRegistry, profile: bashkit::ExecutionProfileName, + #[cfg_attr(not(feature = "python"), allow(dead_code))] + python: bool, + #[cfg_attr(not(feature = "typescript"), allow(dead_code))] + typescript: bool, + #[cfg_attr(not(feature = "python"), allow(dead_code))] + external_functions: Vec, + #[cfg_attr(not(feature = "typescript"), allow(dead_code))] + typescript_external_functions: Vec, username: Option, hostname: Option, cwd: Option, @@ -467,7 +478,9 @@ impl Bash { let mut guard = inner.try_borrow_mut().map_err(|_| { JsError::new("bash instance is busy (reentrant execution is not supported)") })?; - match streaming::execute(&mut guard, &commands, options).await { + match streaming::execute(&mut guard, &commands, options, bashkit::ExecOptions::new()) + .await + { Ok(r) => Ok(JsValue::from(ExecResult::from(r))), Err(e) => Err(e), } @@ -521,6 +534,52 @@ impl Bash { }) } + /// Execute with a revocable, per-execution host dispatcher. + #[cfg(any(feature = "python", feature = "typescript"))] + #[wasm_bindgen(js_name = executeWithRuntime)] + #[allow(clippy::await_holding_refcell_ref)] + pub fn execute_with_runtime( + &self, + commands: String, + dispatcher: js_sys::Function, + on_output: Option, + options: JsValue, + ) -> js_sys::Promise { + let inner = self.inner.clone(); + let session = runtime_functions::Session(SendWrapper::new(dispatcher)); + future_to_promise(async move { + let mut guard = inner.try_borrow_mut().map_err(|_| { + JsError::new("bash instance is busy (reentrant execution is not supported)") + })?; + let exec_options = bashkit::ExecOptions::new() + .extensions(bashkit::ExecutionExtensions::new().with(session)); + let error = Arc::new(Mutex::new(None)); + let exec_options = if let Some(output) = on_output { + let output = SendWrapper::new(output); + let error = error.clone(); + exec_options.streaming(Box::new(move |stdout, stderr| { + if error.lock().is_ok_and(|slot| slot.is_some()) { + return; + } + if let Err(value) = output.call2( + &JsValue::NULL, + &JsValue::from_str(stdout), + &JsValue::from_str(stderr), + ) { + *error.lock().expect("output error lock") = Some(sanitize_js_error(&value)); + } + })) + } else { + exec_options + }; + let result = streaming::execute(&mut guard, &commands, options, exec_options).await; + if let Some(error) = error.lock().expect("output error lock").take() { + return Err(JsError::new(&error).into()); + } + result.map(|r| JsValue::from(ExecResult::from(r))) + }) + } + /// Analyze a script without executing or mutating it. pub fn analyze(&self, script: String) -> Result { let analysis = self @@ -891,9 +950,52 @@ fn core_builder(config: &Config, sync_flag: &Arc) -> bashkit::BashBu ); } + #[cfg(feature = "python")] + if config.python { + builder = builder.env("BASHKIT_ALLOW_INPROCESS_PYTHON", "1"); + builder = if config.external_functions.is_empty() { + builder.python() + } else { + builder.python_with_external_handler( + profile.python_limits().clone(), + config.external_functions.clone(), + runtime_functions::python_handler(), + ) + }; + } + #[cfg(feature = "typescript")] + if config.typescript { + builder = if config.typescript_external_functions.is_empty() { + builder.typescript() + } else { + builder.typescript_with_external_handler( + profile.typescript_limits().clone(), + config.typescript_external_functions.clone(), + runtime_functions::typescript_handler(), + ) + }; + } builder.builtin_registry(config.registry.clone()) } +fn read_function_names(options: &JsValue, key: &str) -> Result, JsError> { + let value = js_sys::Reflect::get(options, &JsValue::from_str(key)) + .map_err(|_| JsError::new("cannot read function names"))?; + if value.is_undefined() { + return Ok(Vec::new()); + } + if !js_sys::Array::is_array(&value) { + return Err(JsError::new("function names must be an array")); + } + js_sys::Array::from(&value) + .iter() + .map(|v| { + v.as_string() + .ok_or_else(|| JsError::new("function names must be strings")) + }) + .collect() +} + /// Seed pre-created files as normal writable VFS entries. fn seed_files(fs: &Arc, files: &[(String, String)]) -> Result<(), JsError> { for (path, content) in files { @@ -913,6 +1015,10 @@ fn parse_options(options: &JsValue) -> Result { return Ok(Config { registry: bashkit::BuiltinRegistry::new(), profile: bashkit::ExecutionProfileName::Standard, + python: false, + typescript: false, + external_functions: Vec::new(), + typescript_external_functions: Vec::new(), username: None, hostname: None, cwd: None, @@ -974,6 +1080,34 @@ fn parse_options(options: &JsValue) -> Result { _ => None, }; + let flag = |key: &str| -> Result { + let value = js_sys::Reflect::get(options, &JsValue::from_str(key)) + .map_err(|_| JsError::new("cannot read runtime option"))?; + if value.is_undefined() { + return Ok(false); + } + value + .as_bool() + .ok_or_else(|| JsError::new("runtime flags must be booleans")) + }; + let python = flag("python")?; + let typescript = flag("typescript")?; + if (python && !cfg!(feature = "python")) || (typescript && !cfg!(feature = "typescript")) { + return Err(JsError::new( + "runtime unavailable in this bundle; import @everruns/bashkit-wasm/runtimes", + )); + } + let external_functions = read_function_names(options, "externalFunctions")?; + let typescript_external_functions = + read_function_names(options, "typescriptExternalFunctions")?; + if (!external_functions.is_empty() && !python) + || (!typescript_external_functions.is_empty() && !typescript) + { + return Err(JsError::new( + "external functions require their runtime to be enabled", + )); + } + // customBuiltins: { [name]: (ctx) => string | Promise } let mut builtins = Vec::new(); if let Ok(cb) = js_sys::Reflect::get(options, &JsValue::from_str("customBuiltins")) @@ -999,6 +1133,10 @@ fn parse_options(options: &JsValue) -> Result { Ok(Config { registry: bashkit::BuiltinRegistry::new(), profile, + python, + typescript, + external_functions, + typescript_external_functions, username: get_str("username"), hostname: get_str("hostname"), cwd: get_str("cwd"), diff --git a/crates/bashkit-wasm/src/runtime_functions.rs b/crates/bashkit-wasm/src/runtime_functions.rs new file mode 100644 index 000000000..83f80e544 --- /dev/null +++ b/crates/bashkit-wasm/src/runtime_functions.rs @@ -0,0 +1,83 @@ +// JS functions/futures never leave the sole wasm thread. Only per-execution +// capabilities hold a dispatcher, so cancelled runs cannot call a later host. +use bashkit::{RuntimeCallContext, runtime_value}; +use send_wrapper::SendWrapper; +use serde::Deserialize; +use serde_json::Value; +use std::sync::Arc; +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::JsFuture; + +pub(super) struct Session(pub SendWrapper); + +#[derive(Deserialize)] +#[serde(tag = "status", content = "value", rename_all = "snake_case")] +enum RuntimeResponse { + Ok(Value), + Error(String), +} + +async fn dispatch(request: Value) -> Result { + let lease = RuntimeCallContext::current() + .execution_extension::() + .ok_or_else(|| "host functions require execute() (async)".to_string())?; + let promise = lease + .try_with(|session| { + session + .0 + .call1(&JsValue::NULL, &JsValue::from_str(&request.to_string())) + }) + .map_err(|e| e.to_string())? + .map_err(|e| super::sanitize_js_error(&e))?; + let future = SendWrapper::new(JsFuture::from(js_sys::Promise::resolve(&promise))); + let value = lease + .run(future) + .await + .map_err(|e| e.to_string())? + .map_err(|e| super::sanitize_js_error(&e))?; + let response: RuntimeResponse = + serde_json::from_str(&value.as_string().ok_or("invalid host response")?) + .map_err(|_| "invalid host response")?; + match response { + RuntimeResponse::Ok(value) => { + runtime_value::validate(&value).map_err(|e| e.to_string())?; + Ok(value) + } + RuntimeResponse::Error(message) => Err(message), + } +} + +#[cfg(feature = "python")] +pub(super) fn python_handler() -> bashkit::PythonExternalFnHandler { + Arc::new(|name, args, kwargs| { + Box::pin(async move { + let result = async { + let args = runtime_value::encode_python(&bashkit::MontyObject::Tuple(args)).map_err(|e| e.to_string())?; + let kwargs = runtime_value::encode_python(&bashkit::MontyObject::dict(kwargs)).map_err(|e| e.to_string())?; + let value = dispatch(serde_json::json!({"language": "python", "name": name, "args": args, "kwargs": kwargs})).await?; + runtime_value::decode_python(&value).map_err(|e| e.to_string()) + }.await; + match result { + Ok(value) => bashkit::ExtFunctionResult::Return(value), + Err(message) => bashkit::ExtFunctionResult::Error(bashkit::MontyException::new( + bashkit::ExcType::RuntimeError, + Some(message), + )), + } + }) + }) +} + +#[cfg(feature = "typescript")] +pub(super) fn typescript_handler() -> bashkit::TypeScriptExternalFnHandler { + Arc::new(|name, args| { + Box::pin(async move { + let args = runtime_value::encode_typescript(&bashkit::ZapcodeValue::Array(args)) + .map_err(|e| e.to_string())?; + let value = + dispatch(serde_json::json!({"language":"typescript", "name":name, "args":args})) + .await?; + runtime_value::decode_typescript(&value).map_err(|e| e.to_string()) + }) + }) +} diff --git a/crates/bashkit-wasm/src/streaming.rs b/crates/bashkit-wasm/src/streaming.rs index 27d92fcfe..2972549f2 100644 --- a/crates/bashkit-wasm/src/streaming.rs +++ b/crates/bashkit-wasm/src/streaming.rs @@ -174,6 +174,7 @@ pub(super) async fn execute( bash: &mut CoreBash, script: &str, options: JsValue, + exec_options: bashkit::ExecOptions, ) -> Result { let stdout = sink(&options, "stdout")?; let stderr = sink(&options, "stderr")?; @@ -189,9 +190,12 @@ pub(super) async fn execute( let explicit_output = stdout.is_some() || stderr.is_some() || !capture.is_undefined(); let live = explicit_output || has_signal; if !live { - return bash.exec(script).await.map_err(js_error); + return bash + .exec_with_options(script, exec_options) + .await + .map_err(js_error); } - let mut execution = bash.execute(script); + let mut execution = bash.execute(script).options(exec_options); if has_signal && !explicit_output { // Signal-only execution keeps the buffered convenience result while // using the driver that interrupts pending host Promises. diff --git a/crates/bashkit-wasm/src/terminal.rs b/crates/bashkit-wasm/src/terminal.rs index 6f095959e..f317c1d62 100644 --- a/crates/bashkit-wasm/src/terminal.rs +++ b/crates/bashkit-wasm/src/terminal.rs @@ -41,6 +41,12 @@ impl Terminal { #[wasm_bindgen(constructor)] pub fn new(options: JsValue) -> Result { let config = parse_options(&options)?; + if !config.external_functions.is_empty() || !config.typescript_external_functions.is_empty() + { + return Err(JsError::new( + "runtime host functions require Bash.execute(); Terminal does not accept runtime callbacks", + )); + } let dim = |key: &str, default: u16| -> u16 { js_sys::Reflect::get(&options, &JsValue::from_str(key)) .ok() diff --git a/crates/bashkit/Cargo.toml b/crates/bashkit/Cargo.toml index b00b2981e..64ca91c35 100644 --- a/crates/bashkit/Cargo.toml +++ b/crates/bashkit/Cargo.toml @@ -156,7 +156,6 @@ monty-types = { version = "=0.0.19", optional = true } get-size2 = { version = "=0.10.1", optional = true } # Embedded TypeScript interpreter (optional) -zapcode-core = { version = "1.5.1", optional = true } # Embedded SQLite engine via Turso (optional, BETA upstream). # Pulls a multi-MB transitive dep tree only when the `sqlite` feature is active. @@ -273,7 +272,7 @@ cpython = ["dep:bashkit-cpython-wasm", "dep:wasmtime"] cpython-native = ["cpython", "bashkit-cpython-wasm/native"] # Enable ts/node/deno/bun builtins via embedded ZapCode TypeScript interpreter # Usage: cargo build --features typescript -typescript = ["dep:zapcode-core"] +typescript = ["dep:zapcode-core", "dep:oxc_parser", "dep:oxc_ast", "dep:oxc_allocator", "dep:oxc_span", "dep:oxc_syntax", "dep:postcard", "dep:indexmap", "indexmap/serde", "serde/rc"] # Enable sqlite/sqlite3 builtins backed by Turso (pure-Rust SQLite-compatible # engine). Phase 1 uses turso's `MemoryIO` with a load/flush against the VFS # at command boundaries; Phase 2 plugs the bashkit VFS in via a custom `IO` @@ -435,6 +434,14 @@ tokio = { version = "1", features = ["rt-multi-thread", "fs"] } web-time = { workspace = true, optional = true } [target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies] +# Vendored browser ZapCode uses the same parser/codec versions as upstream. +oxc_parser = { version = "0.117.0", optional = true } +oxc_ast = { version = "0.117.0", optional = true } +oxc_allocator = { version = "0.117.0", optional = true } +oxc_span = { version = "0.117.0", optional = true } +oxc_syntax = { version = "0.117.0", optional = true } +postcard = { version = "1.1.3", features = ["alloc"], optional = true } + gloo-timers = { version = "0.4", features = ["futures"], optional = true } send_wrapper = { version = "0.6", features = ["futures"], optional = true } # Task spawner for the host-call execution driver (see host_call.rs). JS-backed @@ -455,3 +462,6 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(feature, values("i18n-deci # on the workspace Tokio feature set without changing versions or the lock. [target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] tokio = { workspace = true, features = ["process"] } + +[target.'cfg(not(all(target_arch = "wasm32", target_os = "unknown")))'.dependencies] +zapcode-core = { version = "1.5.1", optional = true } diff --git a/crates/bashkit/docs/threat-model.md b/crates/bashkit/docs/threat-model.md index c08032147..fb5091f8b 100644 --- a/crates/bashkit/docs/threat-model.md +++ b/crates/bashkit/docs/threat-model.md @@ -567,6 +567,7 @@ echo $user_input | `$?` leaks into VFS subprocess (TM-ISO-024) | Parent `last_exit_code` visible in child, causing false `set -e` failures | Child resets `last_exit_code`, `nounset_error`, and traps | **MITIGATED** | | Wrapper rebuild drops constructor capabilities (TM-ISO-025) | A binding reset loses limits, policy files, callbacks, or network policy | Canonical capability matrix with executable evidence; rebuilds retain constructor config | **MITIGATED** | | Shared ToolRegistry request context (TM-ISO-026) | Concurrent shell/Python/TypeScript calls leak tenant identity or traces | Per-request `ExecutionExtensions`, task-local runtime routing, and callback-owned context | **MITIGATED** | +| Browser WASM caller context (TM-ISO-029) | Shared wasm-bindgen wakeups deliver a host call in another request's async context | A per-execution promise mailbox launches callbacks in the original caller context; revocable capabilities and AbortSignal end dispatch on cancellation/completion | **MITIGATED** | | Stale request authority and retained host-extension handles (TM-ISO-027) | A late runtime/transport/callback result crosses completion, or a builtin/tool keeps VFS or request context past completion/cancellation | Shared request budget plus one revocable capability lease, cancellation-aware awaits, deterministic late-use failure, RAII closure/release, and explicit `insert_trusted` escape hatch | **MITIGATED** | | Process substitution shared through the VFS (TM-ISO-028) | Tenants on one filesystem read or clobber each other's `<(cmd)` data at `/dev/fd/63` | `/dev/fd/N` (N >= 3) resolves in each interpreter's own fd namespace; nothing reaches the shared filesystem | **MITIGATED** | diff --git a/crates/bashkit/src/builtins/python.rs b/crates/bashkit/src/builtins/python.rs index 07ab8c8af..3f6050ea5 100644 --- a/crates/bashkit/src/builtins/python.rs +++ b/crates/bashkit/src/builtins/python.rs @@ -783,7 +783,7 @@ async fn call_external_with_deadline( let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { return python_external_timeout_error(); }; - match tokio::time::timeout( + match crate::time_compat::timeout( remaining, (external_fns.handler)(function_name, args, kwargs), ) diff --git a/crates/bashkit/src/builtins/typescript.rs b/crates/bashkit/src/builtins/typescript.rs index 55a979745..1aeff28f5 100644 --- a/crates/bashkit/src/builtins/typescript.rs +++ b/crates/bashkit/src/builtins/typescript.rs @@ -15,6 +15,8 @@ //! Supports: `ts -c "code"`, `ts script.ts`, stdin piping, and //! `node`/`deno`/`bun` aliases. +#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] +use crate::zapcode_browser as zapcode_core; use async_trait::async_trait; use std::future::Future; use std::path::{Path, PathBuf}; diff --git a/crates/bashkit/src/lib.rs b/crates/bashkit/src/lib.rs index f1a63e8bc..78e855e29 100644 --- a/crates/bashkit/src/lib.rs +++ b/crates/bashkit/src/lib.rs @@ -598,7 +598,12 @@ pub use builtins::{ TypeScriptLimits, }; // Re-export zapcode-core types needed by external handler consumers. -#[cfg(feature = "typescript")] +#[cfg(all(feature = "typescript", target_arch = "wasm32", target_os = "unknown"))] +pub use zapcode_browser::Value as ZapcodeValue; +#[cfg(all( + feature = "typescript", + not(all(target_arch = "wasm32", target_os = "unknown")) +))] pub use zapcode_core::Value as ZapcodeValue; /// Logging utilities module @@ -8625,3 +8630,10 @@ echo missing fi"#, // server, but the hook is wired and the build succeeded. } } + +// Browser-only upstream source retains VM limits; native builds keep the +// external crate and its public value types. See runtimes/zapcode-runtime.md. +#[cfg(all(feature = "typescript", target_arch = "wasm32", target_os = "unknown"))] +#[rustfmt::skip] +#[allow(dead_code, unused_imports, clippy::all, clippy::unwrap_used)] +mod zapcode_browser; diff --git a/crates/bashkit/src/zapcode_browser/LICENSE b/crates/bashkit/src/zapcode_browser/LICENSE new file mode 100644 index 000000000..d60d20653 --- /dev/null +++ b/crates/bashkit/src/zapcode_browser/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 TheUncharted + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/bashkit/src/zapcode_browser/README.md b/crates/bashkit/src/zapcode_browser/README.md new file mode 100644 index 000000000..650875b64 --- /dev/null +++ b/crates/bashkit/src/zapcode_browser/README.md @@ -0,0 +1,10 @@ +# Browser ZapCode source + +Upstream `zapcode-core` 1.5.1, MIT, https://github.com/TheUncharted/zapcode. +Native builds continue to use the registry crate and its original value types. +The browser copy allows replacing unsupported std clocks while preserving VM +resource enforcement; it adds no JS eval, imports, network or filesystem access. + +`upstream-sha256.json` records every original source file. Mechanical change: +`crate::` becomes `crate::zapcode_browser::`; `lib.rs` becomes `mod.rs`. +Clock changes in sandbox.rs/trace.rs use Bashkit's platform clock shim. diff --git a/crates/bashkit/src/zapcode_browser/compiler/instruction.rs b/crates/bashkit/src/zapcode_browser/compiler/instruction.rs new file mode 100644 index 000000000..c8b8ef0c4 --- /dev/null +++ b/crates/bashkit/src/zapcode_browser/compiler/instruction.rs @@ -0,0 +1,145 @@ +use serde::{Deserialize, Serialize}; + +/// Bytecode instructions for the Zapcode VM. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Instruction { + // Stack + Push(Constant), + Pop, + Dup, + + // Variables + LoadLocal(usize), + StoreLocal(usize), + LoadGlobal(String), + StoreGlobal(String), + DeclareLocal(String), + + // Arithmetic + Add, + Sub, + Mul, + Div, + Rem, + Pow, + Neg, + BitNot, + BitAnd, + BitOr, + BitXor, + Shl, + Shr, + Ushr, + + // Comparison + Eq, + Neq, + StrictEq, + StrictNeq, + Lt, + Lte, + Gt, + Gte, + + // Logical + Not, + + // Objects & Arrays + CreateArray(usize), + CreateObject(usize), + GetProperty(String), + SetProperty(String), + GetIndex, + SetIndex, + Spread, + In, + InstanceOf, + + // Functions + CreateClosure(usize), + Call(usize), + Return, + CallExternal(String, usize), + + // Control flow + Jump(usize), + JumpIfFalse(usize), + JumpIfTrue(usize), + JumpIfNullish(usize), + + // Loops + SetupLoop, + Break, + Continue, + + // Iterators + GetIterator, + IteratorNext, + IteratorDone, + + // Error handling + SetupTry(usize, Option), + Throw, + EndTry, + + // Typeof + TypeOf, + + // Void + Void, + + // Update + Increment, + Decrement, + + // Template literals + ConcatStrings(usize), + + // Destructuring + DestructureObject(Vec), + DestructureArray(usize), + + // Classes + /// Create a class: pops constructor closure (or undefined), then n_methods method closures + /// with method names, then n_static static closures with names, then optional super class. + /// Pushes the class object (an Object with __constructor__, __prototype__, __class_name__). + CreateClass { + name: String, + n_methods: usize, + n_statics: usize, + has_super: bool, + }, + /// Construct: pops class object + args, creates instance, calls constructor, pushes instance. + Construct(usize), + /// Load `this` from the current call frame. + LoadThis, + /// Store a value as the current `this` (used for this.prop = val). + StoreThis, + /// Call super constructor with n args. Pops args, looks up __super__.__constructor__, + /// calls it with current `this`. + CallSuper(usize), + + // Generators + /// Create a generator object from a function index (like CreateClosure but for generators). + CreateGenerator(usize), + /// Yield a value from a generator. Pops the value, suspends execution. + Yield, + + /// Await: if the top-of-stack is a resolved Promise object, unwrap its value. + /// If it's a regular value, leave it as-is. External call suspension is handled + /// by CallExternal before Await is reached. + Await, + + // Misc + Nop, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Constant { + Undefined, + Null, + Bool(bool), + Int(i64), + Float(f64), + String(String), +} diff --git a/crates/bashkit/src/zapcode_browser/compiler/mod.rs b/crates/bashkit/src/zapcode_browser/compiler/mod.rs new file mode 100644 index 000000000..a2685e1ca --- /dev/null +++ b/crates/bashkit/src/zapcode_browser/compiler/mod.rs @@ -0,0 +1,1091 @@ +pub mod instruction; + +use std::collections::{HashMap, HashSet}; + +use crate::zapcode_browser::error::{Result, ZapcodeError}; +use crate::zapcode_browser::parser::ir::*; +use instruction::*; + +/// Compiled program ready for VM execution. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct CompiledProgram { + pub instructions: Vec, + pub functions: Vec, + pub local_names: Vec, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct CompiledFunction { + pub name: Option, + pub params: Vec, + pub instructions: Vec, + pub local_count: usize, + pub local_names: Vec, + pub is_async: bool, + pub is_generator: bool, +} + +struct Compiler { + instructions: Vec, + locals: Vec, + local_indices: HashMap, + functions: Vec, + loop_stack: Vec, + external_functions: HashSet, +} + +struct LoopInfo { + break_patches: Vec, + continue_patches: Vec, +} + +impl Compiler { + fn new(external_functions: HashSet) -> Self { + Self { + instructions: Vec::new(), + locals: Vec::new(), + local_indices: HashMap::new(), + functions: Vec::new(), + loop_stack: Vec::new(), + external_functions, + } + } + + fn emit(&mut self, instr: Instruction) -> usize { + let idx = self.instructions.len(); + self.instructions.push(instr); + idx + } + + fn current_offset(&self) -> usize { + self.instructions.len() + } + + fn patch_jump(&mut self, instr_idx: usize, target: usize) { + match &mut self.instructions[instr_idx] { + Instruction::Jump(t) + | Instruction::JumpIfFalse(t) + | Instruction::JumpIfTrue(t) + | Instruction::JumpIfNullish(t) => { + *t = target; + } + Instruction::SetupTry(catch_target, _) => { + *catch_target = target; + } + _ => {} + } + } + + fn declare_local(&mut self, name: &str) -> usize { + if let Some(&idx) = self.local_indices.get(name) { + return idx; + } + let idx = self.locals.len(); + self.locals.push(name.to_string()); + self.local_indices.insert(name.to_string(), idx); + idx + } + + fn resolve_local(&self, name: &str) -> Option { + self.local_indices.get(name).copied() + } + + fn compile_program(&mut self, program: &Program) -> Result<()> { + // First pass: compile all function definitions + for func_def in &program.functions { + let compiled = self.compile_function_def(func_def)?; + self.functions.push(compiled); + } + + // Second pass: compile body + // For the last statement, if it's an expression, keep the value on the stack + let len = program.body.len(); + for (i, stmt) in program.body.iter().enumerate() { + let is_last = i == len - 1; + if is_last { + if let Statement::Expression { expr, .. } = stmt { + self.compile_expr(expr)?; + // Don't pop — leave value on stack as program result + } else { + self.compile_statement(stmt)?; + } + } else { + self.compile_statement(stmt)?; + } + } + + Ok(()) + } + + fn compile_function_def(&mut self, func: &FunctionDef) -> Result { + let mut func_compiler = Compiler::new(self.external_functions.clone()); + + // Set up parameters as locals + for param in &func.params { + match param { + ParamPattern::Ident(name) => { + func_compiler.declare_local(name); + } + ParamPattern::Rest(name) => { + func_compiler.declare_local(name); + } + ParamPattern::DefaultValue { pattern, .. } => { + if let ParamPattern::Ident(name) = pattern.as_ref() { + func_compiler.declare_local(name); + } + } + ParamPattern::ObjectDestructure(fields) => { + for field in fields { + let name = field.alias.as_ref().unwrap_or(&field.key); + func_compiler.declare_local(name); + } + } + ParamPattern::ArrayDestructure(elems) => { + for elem in elems.iter().flatten() { + if let ParamPattern::Ident(name) = elem { + func_compiler.declare_local(name); + } + } + } + } + } + + for stmt in &func.body { + func_compiler.compile_statement(stmt)?; + } + + // Implicit return undefined + func_compiler.emit(Instruction::Push(Constant::Undefined)); + func_compiler.emit(Instruction::Return); + + Ok(CompiledFunction { + name: func.name.clone(), + params: func.params.clone(), + instructions: func_compiler.instructions, + local_count: func_compiler.locals.len(), + local_names: func_compiler.locals, + is_async: func.is_async, + is_generator: func.is_generator, + }) + } + + fn compile_statement(&mut self, stmt: &Statement) -> Result<()> { + match stmt { + Statement::VariableDecl { declarations, .. } => { + for decl in declarations { + self.compile_var_declarator(decl)?; + } + } + Statement::Expression { expr, .. } => { + self.compile_expr(expr)?; + self.emit(Instruction::Pop); + } + Statement::Return { value, .. } => { + match value { + Some(expr) => self.compile_expr(expr)?, + None => { + self.emit(Instruction::Push(Constant::Undefined)); + } + } + self.emit(Instruction::Return); + } + Statement::If { + test, + consequent, + alternate, + .. + } => { + self.compile_expr(test)?; + let jump_else = self.emit(Instruction::JumpIfFalse(0)); + + for s in consequent { + self.compile_statement(s)?; + } + + if let Some(alt) = alternate { + let jump_end = self.emit(Instruction::Jump(0)); + let else_target = self.current_offset(); + self.patch_jump(jump_else, else_target); + + for s in alt { + self.compile_statement(s)?; + } + let end_target = self.current_offset(); + self.patch_jump(jump_end, end_target); + } else { + let else_target = self.current_offset(); + self.patch_jump(jump_else, else_target); + } + } + Statement::While { test, body, .. } => { + let loop_start = self.current_offset(); + self.loop_stack.push(LoopInfo { + break_patches: Vec::new(), + continue_patches: Vec::new(), + }); + + self.compile_expr(test)?; + let exit_jump = self.emit(Instruction::JumpIfFalse(0)); + + for s in body { + self.compile_statement(s)?; + } + + self.emit(Instruction::Jump(loop_start)); + let loop_end = self.current_offset(); + self.patch_jump(exit_jump, loop_end); + + let loop_info = self.loop_stack.pop().unwrap(); + for patch in loop_info.break_patches { + self.patch_jump(patch, loop_end); + } + for patch in loop_info.continue_patches { + self.patch_jump(patch, loop_start); + } + } + Statement::DoWhile { body, test, .. } => { + let loop_start = self.current_offset(); + self.loop_stack.push(LoopInfo { + break_patches: Vec::new(), + continue_patches: Vec::new(), + }); + + for s in body { + self.compile_statement(s)?; + } + + let continue_target = self.current_offset(); + self.compile_expr(test)?; + self.emit(Instruction::JumpIfTrue(loop_start)); + + let loop_end = self.current_offset(); + let loop_info = self.loop_stack.pop().unwrap(); + for patch in loop_info.break_patches { + self.patch_jump(patch, loop_end); + } + for patch in loop_info.continue_patches { + self.patch_jump(patch, continue_target); + } + } + Statement::For { + init, + test, + update, + body, + .. + } => { + if let Some(init) = init { + self.compile_statement(init)?; + } + + let loop_start = self.current_offset(); + self.loop_stack.push(LoopInfo { + break_patches: Vec::new(), + continue_patches: Vec::new(), + }); + + let exit_jump = if let Some(test) = test { + self.compile_expr(test)?; + Some(self.emit(Instruction::JumpIfFalse(0))) + } else { + None + }; + + for s in body { + self.compile_statement(s)?; + } + + let continue_target = self.current_offset(); + if let Some(update) = update { + self.compile_expr(update)?; + self.emit(Instruction::Pop); + } + + self.emit(Instruction::Jump(loop_start)); + let loop_end = self.current_offset(); + + if let Some(exit) = exit_jump { + self.patch_jump(exit, loop_end); + } + + let loop_info = self.loop_stack.pop().unwrap(); + for patch in loop_info.break_patches { + self.patch_jump(patch, loop_end); + } + for patch in loop_info.continue_patches { + self.patch_jump(patch, continue_target); + } + } + Statement::ForOf { + binding, + iterable, + body, + .. + } => { + self.compile_expr(iterable)?; + self.emit(Instruction::GetIterator); + + let loop_start = self.current_offset(); + self.loop_stack.push(LoopInfo { + break_patches: Vec::new(), + continue_patches: Vec::new(), + }); + + self.emit(Instruction::Dup); + self.emit(Instruction::IteratorNext); + self.emit(Instruction::IteratorDone); + let exit_jump = self.emit(Instruction::JumpIfTrue(0)); + + // Bind the value + match binding { + ForBinding::Ident(name) => { + let idx = self.declare_local(name); + self.emit(Instruction::StoreLocal(idx)); + } + ForBinding::Destructure(_) => { + self.emit(Instruction::Pop); // TODO: destructure + } + } + + for s in body { + self.compile_statement(s)?; + } + + self.emit(Instruction::Jump(loop_start)); + let loop_end = self.current_offset(); + self.patch_jump(exit_jump, loop_end); + self.emit(Instruction::Pop); // pop iterator + + let loop_info = self.loop_stack.pop().unwrap(); + for patch in loop_info.break_patches { + self.patch_jump(patch, loop_end); + } + for patch in loop_info.continue_patches { + self.patch_jump(patch, loop_start); + } + } + Statement::Block { body, .. } => { + for s in body { + self.compile_statement(s)?; + } + } + Statement::Throw { value, .. } => { + self.compile_expr(value)?; + self.emit(Instruction::Throw); + } + Statement::TryCatch { + try_body, + catch_param, + catch_body, + finally_body, + .. + } => { + let setup = self.emit(Instruction::SetupTry(0, None)); + + for s in try_body { + self.compile_statement(s)?; + } + self.emit(Instruction::EndTry); + let jump_past_catch = self.emit(Instruction::Jump(0)); + + // Catch block + let catch_start = self.current_offset(); + self.patch_jump(setup, catch_start); + + if let Some(param) = catch_param { + let idx = self.declare_local(param); + self.emit(Instruction::StoreLocal(idx)); + } else { + self.emit(Instruction::Pop); // discard error + } + + for s in catch_body { + self.compile_statement(s)?; + } + + let after_catch = self.current_offset(); + self.patch_jump(jump_past_catch, after_catch); + + if let Some(finally) = finally_body { + for s in finally { + self.compile_statement(s)?; + } + } + } + Statement::Break { .. } => { + let idx = self.emit(Instruction::Jump(0)); + if let Some(loop_info) = self.loop_stack.last_mut() { + loop_info.break_patches.push(idx); + } + } + Statement::Continue { .. } => { + let idx = self.emit(Instruction::Jump(0)); + if let Some(loop_info) = self.loop_stack.last_mut() { + loop_info.continue_patches.push(idx); + } + } + Statement::FunctionDecl { func_index, .. } => { + self.emit(Instruction::CreateClosure(*func_index)); + let name = if *func_index < self.functions.len() { + self.functions[*func_index].name.clone() + } else { + None + }; + if let Some(name) = name { + // Store as both local and global so recursion works + self.emit(Instruction::Dup); + let idx = self.declare_local(&name); + self.emit(Instruction::StoreLocal(idx)); + self.emit(Instruction::StoreGlobal(name)); + } else { + self.emit(Instruction::Pop); + } + } + Statement::ClassDecl { + name, + super_class, + constructor, + methods, + static_methods, + .. + } => { + self.compile_class( + Some(name), + super_class.as_deref(), + constructor.as_deref(), + methods, + static_methods, + )?; + // Store the class as both local and global + self.emit(Instruction::Dup); + let idx = self.declare_local(name); + self.emit(Instruction::StoreLocal(idx)); + self.emit(Instruction::StoreGlobal(name.clone())); + } + Statement::Switch { + discriminant, + cases, + .. + } => { + self.compile_expr(discriminant)?; + let mut case_jumps = Vec::new(); + let mut default_jump = None; + + // Compile test expressions and jumps + for case in cases { + if let Some(test) = &case.test { + self.emit(Instruction::Dup); + self.compile_expr(test)?; + self.emit(Instruction::StrictEq); + let jump = self.emit(Instruction::JumpIfTrue(0)); + case_jumps.push(jump); + } else { + default_jump = Some(case_jumps.len()); + case_jumps.push(0); // placeholder + } + } + + let jump_end = self.emit(Instruction::Jump(0)); + + // Compile case bodies + let mut body_starts = Vec::new(); + for case in cases { + body_starts.push(self.current_offset()); + for s in &case.consequent { + self.compile_statement(s)?; + } + } + + let end = self.current_offset(); + self.emit(Instruction::Pop); // pop discriminant + + // Patch jumps + for (i, &jump) in case_jumps.iter().enumerate() { + if jump != 0 { + self.patch_jump(jump, body_starts[i]); + } + } + if let Some(default_idx) = default_jump { + // Jump to default case + self.patch_jump(jump_end, body_starts[default_idx]); + } else { + self.patch_jump(jump_end, end); + } + } + } + Ok(()) + } + + fn compile_var_declarator(&mut self, decl: &VarDeclarator) -> Result<()> { + match &decl.pattern { + AssignTarget::Ident(name) => { + let idx = self.declare_local(name); + match &decl.init { + Some(expr) => { + self.compile_expr(expr)?; + self.emit(Instruction::StoreLocal(idx)); + } + None => { + self.emit(Instruction::Push(Constant::Undefined)); + self.emit(Instruction::StoreLocal(idx)); + } + } + } + AssignTarget::ObjectDestructure(fields) => { + if let Some(expr) = &decl.init { + self.compile_expr(expr)?; + } else { + self.emit(Instruction::Push(Constant::Undefined)); + } + for field in fields { + self.emit(Instruction::Dup); + self.emit(Instruction::GetProperty(field.key.clone())); + let name = field.alias.as_ref().unwrap_or(&field.key); + let idx = self.declare_local(name); + self.emit(Instruction::StoreLocal(idx)); + } + self.emit(Instruction::Pop); // pop source object + } + AssignTarget::ArrayDestructure(elems) => { + if let Some(expr) = &decl.init { + self.compile_expr(expr)?; + } else { + self.emit(Instruction::Push(Constant::Undefined)); + } + for (i, elem) in elems.iter().enumerate() { + if let Some(target) = elem { + self.emit(Instruction::Dup); + self.emit(Instruction::Push(Constant::Int(i as i64))); + self.emit(Instruction::GetIndex); + match target { + AssignTarget::Ident(name) => { + let idx = self.declare_local(name); + self.emit(Instruction::StoreLocal(idx)); + } + _ => { + self.emit(Instruction::Pop); // TODO: nested destructure + } + } + } + } + self.emit(Instruction::Pop); // pop source array + } + } + Ok(()) + } + + fn compile_expr(&mut self, expr: &Expr) -> Result<()> { + match expr { + Expr::NumberLit(n) => { + if *n == (*n as i64) as f64 && !n.is_nan() && n.is_finite() { + self.emit(Instruction::Push(Constant::Int(*n as i64))); + } else { + self.emit(Instruction::Push(Constant::Float(*n))); + } + } + Expr::StringLit(s) => { + self.emit(Instruction::Push(Constant::String(s.clone()))); + } + Expr::BoolLit(b) => { + self.emit(Instruction::Push(Constant::Bool(*b))); + } + Expr::NullLit => { + self.emit(Instruction::Push(Constant::Null)); + } + Expr::UndefinedLit => { + self.emit(Instruction::Push(Constant::Undefined)); + } + Expr::TemplateLit { quasis, exprs } => { + let mut parts = 0; + for (i, quasi) in quasis.iter().enumerate() { + if !quasi.is_empty() { + self.emit(Instruction::Push(Constant::String(quasi.clone()))); + parts += 1; + } + if i < exprs.len() { + self.compile_expr(&exprs[i])?; + parts += 1; + } + } + if parts == 0 { + self.emit(Instruction::Push(Constant::String(String::new()))); + } else if parts > 1 { + self.emit(Instruction::ConcatStrings(parts)); + } + } + Expr::RegExpLit { .. } => { + // RegExp not fully supported — push as string for now + self.emit(Instruction::Push(Constant::Undefined)); + } + Expr::Ident(name) => { + if name == "this" { + self.emit(Instruction::LoadThis); + } else if let Some(idx) = self.resolve_local(name) { + self.emit(Instruction::LoadLocal(idx)); + } else { + self.emit(Instruction::LoadGlobal(name.clone())); + } + } + Expr::Array(elements) => { + let mut count = 0; + for elem in elements { + match elem { + Some(e) => { + self.compile_expr(e)?; + count += 1; + } + None => { + self.emit(Instruction::Push(Constant::Undefined)); + count += 1; + } + } + } + self.emit(Instruction::CreateArray(count)); + } + Expr::Object(props) => { + let mut count = 0; + for prop in props { + match prop.kind { + PropKind::Spread => { + self.compile_expr(&prop.value)?; + self.emit(Instruction::Spread); + count += 1; + } + _ => { + self.emit(Instruction::Push(Constant::String(prop.key.clone()))); + self.compile_expr(&prop.value)?; + count += 1; + } + } + } + self.emit(Instruction::CreateObject(count)); + } + Expr::Spread(expr) => { + self.compile_expr(expr)?; + self.emit(Instruction::Spread); + } + Expr::Binary { op, left, right } => { + self.compile_expr(left)?; + self.compile_expr(right)?; + let instr = match op { + BinOp::Add => Instruction::Add, + BinOp::Sub => Instruction::Sub, + BinOp::Mul => Instruction::Mul, + BinOp::Div => Instruction::Div, + BinOp::Rem => Instruction::Rem, + BinOp::Pow => Instruction::Pow, + BinOp::Eq => Instruction::Eq, + BinOp::Neq => Instruction::Neq, + BinOp::StrictEq => Instruction::StrictEq, + BinOp::StrictNeq => Instruction::StrictNeq, + BinOp::Lt => Instruction::Lt, + BinOp::Lte => Instruction::Lte, + BinOp::Gt => Instruction::Gt, + BinOp::Gte => Instruction::Gte, + BinOp::BitAnd => Instruction::BitAnd, + BinOp::BitOr => Instruction::BitOr, + BinOp::BitXor => Instruction::BitXor, + BinOp::Shl => Instruction::Shl, + BinOp::Shr => Instruction::Shr, + BinOp::Ushr => Instruction::Ushr, + BinOp::In => Instruction::In, + BinOp::InstanceOf => Instruction::InstanceOf, + }; + self.emit(instr); + } + Expr::Unary { op, operand } => { + self.compile_expr(operand)?; + match op { + UnaryOp::Neg => { + self.emit(Instruction::Neg); + } + UnaryOp::Not => { + self.emit(Instruction::Not); + } + UnaryOp::BitNot => { + self.emit(Instruction::BitNot); + } + UnaryOp::Void => { + self.emit(Instruction::Void); + } + } + } + Expr::Update { + op, + prefix, + operand, + } => { + // Load current value + self.compile_expr(operand)?; + + if !prefix { + self.emit(Instruction::Dup); // keep pre-value + } + + match op { + UpdateOp::Increment => { + self.emit(Instruction::Increment); + } + UpdateOp::Decrement => { + self.emit(Instruction::Decrement); + } + } + + if *prefix { + self.emit(Instruction::Dup); // keep post-value + } + + // Store back + self.compile_store(operand)?; + + if !prefix { + // Swap to get pre-value on top + // Actually the dup before increment already has it + } + } + Expr::Logical { op, left, right } => match op { + LogicalOp::And => { + self.compile_expr(left)?; + self.emit(Instruction::Dup); + let skip = self.emit(Instruction::JumpIfFalse(0)); + self.emit(Instruction::Pop); + self.compile_expr(right)?; + let end = self.current_offset(); + self.patch_jump(skip, end); + } + LogicalOp::Or => { + self.compile_expr(left)?; + self.emit(Instruction::Dup); + let skip = self.emit(Instruction::JumpIfTrue(0)); + self.emit(Instruction::Pop); + self.compile_expr(right)?; + let end = self.current_offset(); + self.patch_jump(skip, end); + } + LogicalOp::NullishCoalescing => { + self.compile_expr(left)?; + self.emit(Instruction::Dup); + let skip = self.emit(Instruction::JumpIfNullish(0)); + let jump_end = self.emit(Instruction::Jump(0)); + let nullish_target = self.current_offset(); + self.patch_jump(skip, nullish_target); + self.emit(Instruction::Pop); + self.compile_expr(right)?; + let end = self.current_offset(); + self.patch_jump(jump_end, end); + } + }, + Expr::Conditional { + test, + consequent, + alternate, + } => { + self.compile_expr(test)?; + let jump_else = self.emit(Instruction::JumpIfFalse(0)); + self.compile_expr(consequent)?; + let jump_end = self.emit(Instruction::Jump(0)); + let else_target = self.current_offset(); + self.patch_jump(jump_else, else_target); + self.compile_expr(alternate)?; + let end = self.current_offset(); + self.patch_jump(jump_end, end); + } + Expr::Assignment { op, target, value } => { + match op { + AssignOp::Assign => { + self.compile_expr(value)?; + self.emit(Instruction::Dup); + self.compile_store(target)?; + } + _ => { + // Compound assignment: load, operate, store + self.compile_expr(target)?; + self.compile_expr(value)?; + match op { + AssignOp::AddAssign => { + self.emit(Instruction::Add); + } + AssignOp::SubAssign => { + self.emit(Instruction::Sub); + } + AssignOp::MulAssign => { + self.emit(Instruction::Mul); + } + AssignOp::DivAssign => { + self.emit(Instruction::Div); + } + AssignOp::RemAssign => { + self.emit(Instruction::Rem); + } + AssignOp::PowAssign => { + self.emit(Instruction::Pow); + } + AssignOp::BitAndAssign => { + self.emit(Instruction::BitAnd); + } + AssignOp::BitOrAssign => { + self.emit(Instruction::BitOr); + } + AssignOp::BitXorAssign => { + self.emit(Instruction::BitXor); + } + AssignOp::ShlAssign => { + self.emit(Instruction::Shl); + } + AssignOp::ShrAssign => { + self.emit(Instruction::Shr); + } + AssignOp::UshrAssign => { + self.emit(Instruction::Ushr); + } + _ => {} + } + self.emit(Instruction::Dup); + self.compile_store(target)?; + } + } + } + Expr::Sequence(exprs) => { + for (i, e) in exprs.iter().enumerate() { + self.compile_expr(e)?; + if i < exprs.len() - 1 { + self.emit(Instruction::Pop); + } + } + } + Expr::Member { + object, + property, + optional, + } => { + self.compile_expr(object)?; + if *optional { + self.emit(Instruction::Dup); + let skip = self.emit(Instruction::JumpIfNullish(0)); + self.emit(Instruction::GetProperty(property.clone())); + let end = self.emit(Instruction::Jump(0)); + let nullish = self.current_offset(); + self.patch_jump(skip, nullish); + self.emit(Instruction::Pop); + self.emit(Instruction::Push(Constant::Undefined)); + let after = self.current_offset(); + self.patch_jump(end, after); + } else { + self.emit(Instruction::GetProperty(property.clone())); + } + } + Expr::ComputedMember { + object, + property, + optional, + } => { + self.compile_expr(object)?; + if *optional { + self.emit(Instruction::Dup); + let skip = self.emit(Instruction::JumpIfNullish(0)); + self.compile_expr(property)?; + self.emit(Instruction::GetIndex); + let end = self.emit(Instruction::Jump(0)); + let nullish = self.current_offset(); + self.patch_jump(skip, nullish); + self.emit(Instruction::Pop); + self.emit(Instruction::Push(Constant::Undefined)); + let after = self.current_offset(); + self.patch_jump(end, after); + } else { + self.compile_expr(property)?; + self.emit(Instruction::GetIndex); + } + } + Expr::Call { callee, args, .. } => { + // Check if this is a super() call + if let Expr::Ident(name) = callee.as_ref() { + if name == "super" { + for arg in args { + self.compile_expr(arg)?; + } + self.emit(Instruction::CallSuper(args.len())); + return Ok(()); + } + } + // Check if this is a direct call to an external function + if let Expr::Ident(name) = callee.as_ref() { + if self.external_functions.contains(name) { + // Emit args then CallExternal (no callee push needed) + for arg in args { + self.compile_expr(arg)?; + } + self.emit(Instruction::CallExternal(name.clone(), args.len())); + return Ok(()); + } + } + self.compile_expr(callee)?; + for arg in args { + self.compile_expr(arg)?; + } + self.emit(Instruction::Call(args.len())); + } + Expr::New { callee, args } => { + self.compile_expr(callee)?; + for arg in args { + self.compile_expr(arg)?; + } + self.emit(Instruction::Construct(args.len())); + } + Expr::ArrowFunction { func_index } | Expr::FunctionExpr { func_index } => { + self.emit(Instruction::CreateClosure(*func_index)); + } + Expr::Await(expr) => { + self.compile_expr(expr)?; + // Emit Await instruction to unwrap Promise objects. + // External call suspension is already handled by CallExternal + // before this point — Await only handles internal promise values. + self.emit(Instruction::Await); + } + Expr::Yield { value, delegate: _ } => { + // Compile the yielded value (or undefined if none) + match value { + Some(expr) => self.compile_expr(expr)?, + None => { + self.emit(Instruction::Push(Constant::Undefined)); + } + } + // Yield instruction: suspends the generator, pops value, pushes received value on resume + self.emit(Instruction::Yield); + } + Expr::TypeOf(operand) => { + self.compile_expr(operand)?; + self.emit(Instruction::TypeOf); + } + Expr::ClassExpr { + name, + super_class, + constructor, + methods, + static_methods, + } => { + self.compile_class( + name.as_deref(), + super_class.as_deref(), + constructor.as_deref(), + methods, + static_methods, + )?; + } + } + Ok(()) + } + + fn compile_class( + &mut self, + name: Option<&str>, + super_class: Option<&str>, + constructor: Option<&FunctionDef>, + methods: &[ClassMethod], + static_methods: &[ClassMethod], + ) -> Result<()> { + let class_name = name.unwrap_or("AnonymousClass").to_string(); + + // Push super class if present + if let Some(sc) = super_class { + if let Some(idx) = self.resolve_local(sc) { + self.emit(Instruction::LoadLocal(idx)); + } else { + self.emit(Instruction::LoadGlobal(sc.to_string())); + } + } + + // Push static methods: name, closure pairs + for sm in static_methods { + self.emit(Instruction::Push(Constant::String(sm.name.clone()))); + let compiled = self.compile_function_def(&sm.func)?; + let func_idx = self.functions.len(); + self.functions.push(compiled); + self.emit(Instruction::CreateClosure(func_idx)); + } + + // Push instance methods: name, closure pairs + for m in methods { + self.emit(Instruction::Push(Constant::String(m.name.clone()))); + let compiled = self.compile_function_def(&m.func)?; + let func_idx = self.functions.len(); + self.functions.push(compiled); + self.emit(Instruction::CreateClosure(func_idx)); + } + + // Push constructor closure (or undefined if none) + if let Some(ctor) = constructor { + let compiled = self.compile_function_def(ctor)?; + let func_idx = self.functions.len(); + self.functions.push(compiled); + self.emit(Instruction::CreateClosure(func_idx)); + } else { + self.emit(Instruction::Push(Constant::Undefined)); + } + + self.emit(Instruction::CreateClass { + name: class_name, + n_methods: methods.len(), + n_statics: static_methods.len(), + has_super: super_class.is_some(), + }); + + Ok(()) + } + + fn compile_store(&mut self, target: &Expr) -> Result<()> { + match target { + Expr::Ident(name) if name == "this" => { + self.emit(Instruction::StoreThis); + } + Expr::Ident(name) => { + if let Some(idx) = self.resolve_local(name) { + self.emit(Instruction::StoreLocal(idx)); + } else { + self.emit(Instruction::StoreGlobal(name.clone())); + } + } + Expr::Member { + object, property, .. + } => { + self.compile_expr(object)?; + self.emit(Instruction::SetProperty(property.clone())); + // SetProperty pushes the modified object back — store it to the parent + self.compile_store(object)?; + } + Expr::ComputedMember { + object, property, .. + } => { + self.compile_expr(object)?; + self.compile_expr(property)?; + self.emit(Instruction::SetIndex); + // SetIndex pushes the modified object back — store it to the parent + self.compile_store(object)?; + } + _ => { + return Err(ZapcodeError::CompileError( + "invalid assignment target".to_string(), + )); + } + } + Ok(()) + } +} + +pub fn compile(program: &Program) -> Result { + compile_with_externals(program, HashSet::new()) +} + +pub fn compile_with_externals( + program: &Program, + external_functions: HashSet, +) -> Result { + let mut compiler = Compiler::new(external_functions); + compiler.compile_program(program)?; + + Ok(CompiledProgram { + instructions: compiler.instructions, + functions: compiler.functions, + local_names: compiler.locals, + }) +} diff --git a/crates/bashkit/src/zapcode_browser/error.rs b/crates/bashkit/src/zapcode_browser/error.rs new file mode 100644 index 000000000..84e3177c1 --- /dev/null +++ b/crates/bashkit/src/zapcode_browser/error.rs @@ -0,0 +1,45 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ZapcodeError { + #[error("parse error: {0}")] + ParseError(String), + + #[error("unsupported syntax at {span}: {description}")] + UnsupportedSyntax { span: String, description: String }, + + #[error("compile error: {0}")] + CompileError(String), + + #[error("runtime error: {0}")] + RuntimeError(String), + + #[error("type error: {0}")] + TypeError(String), + + #[error("reference error: {0} is not defined")] + ReferenceError(String), + + #[error("unknown external function: {0}")] + UnknownExternalFunction(String), + + #[error("memory limit exceeded: {0}")] + MemoryLimitExceeded(String), + + #[error("execution time limit exceeded")] + TimeLimitExceeded, + + #[error("stack overflow (depth {0})")] + StackOverflow(usize), + + #[error("allocation limit exceeded")] + AllocationLimitExceeded, + + #[error("snapshot error: {0}")] + SnapshotError(String), + + #[error("sandbox violation: {0}")] + SandboxViolation(String), +} + +pub type Result = std::result::Result; diff --git a/crates/bashkit/src/zapcode_browser/mod.rs b/crates/bashkit/src/zapcode_browser/mod.rs new file mode 100644 index 000000000..1eb32c60e --- /dev/null +++ b/crates/bashkit/src/zapcode_browser/mod.rs @@ -0,0 +1,56 @@ +//! # zapcode-core +//! +//! A minimal, secure TypeScript interpreter for AI agent code execution. +//! +//! ## Architecture +//! +//! ```text +//! TypeScript source +//! │ +//! ▼ +//! ┌─────────┐ +//! │ parser │ oxc_parser → ZapcodeIR (parser/ir.rs) +//! └────┬────┘ +//! ▼ +//! ┌──────────┐ +//! │ compiler │ ZapcodeIR → stack-based bytecode (compiler/instruction.rs) +//! └────┬─────┘ +//! ▼ +//! ┌─────────┐ +//! │ vm │ Execute bytecode, snapshot at external calls, resume later +//! └────┬────┘ +//! ▼ +//! VmState::Complete(value) | VmState::Suspended { snapshot } +//! ``` +//! +//! ## Key modules +//! +//! - [`parser`] — Walks the oxc AST and emits [`parser::ir::ZapcodeIR`] +//! - [`compiler`] — Lowers IR to [`compiler::instruction::Instruction`] bytecode +//! - [`vm`] — Stack-based VM that executes bytecode; entry point is [`ZapcodeRun`] +//! - [`value`] — Runtime value types ([`Value`], closures, generators) +//! - [`snapshot`] — Serialize/deserialize VM state for suspension and resumption +//! - [`sandbox`] — Resource limits (memory, time, stack depth, allocations) +//! - [`error`] — Error types used across all modules +//! +//! ## Security model +//! +//! The sandbox is enforced at the language level: no filesystem, network, env, +//! `eval`, `import`, or `require`. The only way guest code can interact with the +//! host is through registered external functions that suspend the VM. + +pub mod compiler; +pub mod error; +pub mod parser; +pub mod sandbox; +pub mod snapshot; +pub mod trace; +pub mod value; +pub mod vm; + +pub use error::ZapcodeError; +pub use sandbox::ResourceLimits; +pub use snapshot::ZapcodeSnapshot; +pub use trace::{ExecutionTrace, TraceSpan, TraceStatus}; +pub use value::Value; +pub use vm::{RunResult, VmState, ZapcodeRun}; diff --git a/crates/bashkit/src/zapcode_browser/parser/ir.rs b/crates/bashkit/src/zapcode_browser/parser/ir.rs new file mode 100644 index 000000000..bcafac8d3 --- /dev/null +++ b/crates/bashkit/src/zapcode_browser/parser/ir.rs @@ -0,0 +1,382 @@ +use serde::{Deserialize, Serialize}; + +/// Result type for parsing a class body: (constructor, instance methods, static methods). +pub type ClassBodyParts = (Option>, Vec, Vec); + +/// Span information for error reporting. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct Span { + pub start: u32, + pub end: u32, +} + +impl std::fmt::Display for Span { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}..{}", self.start, self.end) + } +} + +impl From for Span { + fn from(s: oxc_span::Span) -> Self { + Span { + start: s.start, + end: s.end, + } + } +} + +/// A complete program — a list of statements. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Program { + pub body: Vec, + pub functions: Vec, +} + +/// Function definition stored separately (hoisted). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FunctionDef { + pub name: Option, + pub params: Vec, + pub body: Vec, + pub is_async: bool, + pub is_generator: bool, + pub is_arrow: bool, + pub span: Span, +} + +/// Parameter pattern (simple name or destructuring). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ParamPattern { + Ident(String), + ObjectDestructure(Vec), + ArrayDestructure(Vec>), + Rest(String), + DefaultValue { + pattern: Box, + default: Expr, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DestructureField { + pub key: String, + pub alias: Option, + pub default: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Statement { + VariableDecl { + kind: VarKind, + declarations: Vec, + span: Span, + }, + Expression { + expr: Expr, + span: Span, + }, + Return { + value: Option, + span: Span, + }, + If { + test: Expr, + consequent: Vec, + alternate: Option>, + span: Span, + }, + While { + test: Expr, + body: Vec, + span: Span, + }, + ForOf { + binding: ForBinding, + iterable: Expr, + body: Vec, + span: Span, + }, + For { + init: Option>, + test: Option, + update: Option, + body: Vec, + span: Span, + }, + Block { + body: Vec, + span: Span, + }, + Throw { + value: Expr, + span: Span, + }, + TryCatch { + try_body: Vec, + catch_param: Option, + catch_body: Vec, + finally_body: Option>, + span: Span, + }, + Break { + span: Span, + }, + Continue { + span: Span, + }, + FunctionDecl { + func_index: usize, + span: Span, + }, + Switch { + discriminant: Expr, + cases: Vec, + span: Span, + }, + DoWhile { + body: Vec, + test: Expr, + span: Span, + }, + ClassDecl { + name: String, + super_class: Option, + constructor: Option>, + methods: Vec, + static_methods: Vec, + span: Span, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClassMethod { + pub name: String, + pub func: FunctionDef, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SwitchCase { + pub test: Option, + pub consequent: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ForBinding { + Ident(String), + Destructure(ParamPattern), +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub enum VarKind { + Const, + Let, + Var, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VarDeclarator { + pub pattern: AssignTarget, + pub init: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum AssignTarget { + Ident(String), + ObjectDestructure(Vec), + ArrayDestructure(Vec>), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Expr { + // Literals + NumberLit(f64), + StringLit(String), + BoolLit(bool), + NullLit, + UndefinedLit, + TemplateLit { + quasis: Vec, + exprs: Vec, + }, + RegExpLit { + pattern: String, + flags: String, + }, + + // Identifiers + Ident(String), + + // Compound + Array(Vec>), + Object(Vec), + Spread(Box), + + // Operations + Binary { + op: BinOp, + left: Box, + right: Box, + }, + Unary { + op: UnaryOp, + operand: Box, + }, + Update { + op: UpdateOp, + prefix: bool, + operand: Box, + }, + Logical { + op: LogicalOp, + left: Box, + right: Box, + }, + Conditional { + test: Box, + consequent: Box, + alternate: Box, + }, + Assignment { + op: AssignOp, + target: Box, + value: Box, + }, + Sequence(Vec), + + // Access + Member { + object: Box, + property: String, + optional: bool, + }, + ComputedMember { + object: Box, + property: Box, + optional: bool, + }, + + // Calls + Call { + callee: Box, + args: Vec, + optional: bool, + }, + New { + callee: Box, + args: Vec, + }, + + // Functions + ArrowFunction { + func_index: usize, + }, + FunctionExpr { + func_index: usize, + }, + + // Async + Await(Box), + + // Generators + Yield { + value: Option>, + delegate: bool, + }, + + // Typeof + TypeOf(Box), + + // Classes + ClassExpr { + name: Option, + super_class: Option, + constructor: Option>, + methods: Vec, + static_methods: Vec, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ObjProperty { + pub kind: PropKind, + pub key: String, + pub value: Expr, + pub computed: bool, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub enum PropKind { + Init, + Get, + Set, + Method, + Shorthand, + Spread, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub enum BinOp { + Add, + Sub, + Mul, + Div, + Rem, + Pow, + Eq, + Neq, + StrictEq, + StrictNeq, + Lt, + Lte, + Gt, + Gte, + BitAnd, + BitOr, + BitXor, + Shl, + Shr, + Ushr, + In, + InstanceOf, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub enum UnaryOp { + Neg, + Not, + BitNot, + Void, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub enum UpdateOp { + Increment, + Decrement, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub enum LogicalOp { + And, + Or, + NullishCoalescing, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub enum AssignOp { + Assign, + AddAssign, + SubAssign, + MulAssign, + DivAssign, + RemAssign, + PowAssign, + BitAndAssign, + BitOrAssign, + BitXorAssign, + ShlAssign, + ShrAssign, + UshrAssign, + NullishAssign, + AndAssign, + OrAssign, +} diff --git a/crates/bashkit/src/zapcode_browser/parser/mod.rs b/crates/bashkit/src/zapcode_browser/parser/mod.rs new file mode 100644 index 000000000..2414a262c --- /dev/null +++ b/crates/bashkit/src/zapcode_browser/parser/mod.rs @@ -0,0 +1,1259 @@ +pub mod ir; + +use ir::*; +use oxc_allocator::Allocator; +use oxc_ast::ast; +use oxc_parser::Parser; +use oxc_span::SourceType; + +use crate::zapcode_browser::error::{Result, ZapcodeError}; + +pub fn parse(source: &str) -> Result { + // Auto-wrap trailing object literals: `{ key: value }` → `({ key: value })` + // This avoids the JS ambiguity where `{` at statement start is a block. + let source = wrap_trailing_object(source); + + let allocator = Allocator::default(); + let source_type = SourceType::tsx(); + let ret = Parser::new(&allocator, &source, source_type).parse(); + + if !ret.errors.is_empty() { + let msgs: Vec = ret.errors.iter().map(|e| e.to_string()).collect(); + return Err(ZapcodeError::ParseError(msgs.join("\n"))); + } + + let mut lowerer = AstLowerer::new(&source); + lowerer.lower_program(&ret.program)?; + + Ok(Program { + body: lowerer.body, + functions: lowerer.functions, + }) +} + +/// If the source ends with a `{ ... }` block that looks like an object literal +/// (contains `key: value` or `key,` patterns), wrap it in `(...)` so oxc +/// parses it as an expression instead of a block statement. +fn wrap_trailing_object(source: &str) -> String { + let trimmed = source.trim_end(); + + // Must end with `}` + if !trimmed.ends_with('}') { + return source.to_string(); + } + + // Find the matching `{` + // + // Limitation: this brace scanner is a simple depth-counting heuristic that + // does not account for braces inside string literals, comments, or template + // literals. This is acceptable for preprocessing because: + // 1. The input is AI-generated tool output (simple expressions), not + // arbitrary user code likely to contain embedded brace characters. + // 2. This is only a heuristic for trailing-object extraction — if the + // heuristic produces malformed output, oxc will catch the parse error + // downstream, so correctness is never silently lost. + // + // TODO: revisit this heuristic if real-world failures are reported (e.g. + // strings containing unbalanced braces causing incorrect extraction). + let mut depth = 0; + let mut open_pos = None; + for (i, ch) in trimmed.char_indices().rev() { + match ch { + '}' => depth += 1, + '{' => { + depth -= 1; + if depth == 0 { + open_pos = Some(i); + break; + } + } + _ => {} + } + } + + let open_pos = match open_pos { + Some(pos) => pos, + None => return source.to_string(), + }; + + // The `{` must be at the start of a statement (preceded by newline, semicolon, or start) + let before = trimmed[..open_pos].trim_end(); + if !before.is_empty() { + let last_char = before.chars().last().unwrap(); + // If preceded by =, (, return, =>, etc. — it's already in expression context + if matches!(last_char, '=' | '(' | ',' | ':' | '>' | '[') { + return source.to_string(); + } + // If preceded by a keyword that takes a block, don't wrap + let last_word = before + .rsplit(|c: char| !c.is_alphanumeric() && c != '_') + .next() + .unwrap_or(""); + if matches!( + last_word, + "if" | "else" + | "for" + | "while" + | "do" + | "try" + | "catch" + | "finally" + | "class" + | "function" + | "switch" + ) { + return source.to_string(); + } + } + + // Check the content between braces looks like object literal syntax + let inner = &trimmed[open_pos + 1..trimmed.len() - 1].trim(); + if inner.is_empty() { + return source.to_string(); + } + + // Heuristic: contains `identifier:` pattern (key-value) or commas between identifiers + let looks_like_object = inner.contains(':') || { + // Check for shorthand properties: `{ a, b }` pattern + inner.split(',').all(|part| { + let p = part.trim(); + !p.is_empty() + && p.chars() + .all(|c| c.is_alphanumeric() || c == '_' || c == ' ') + }) + }; + + if !looks_like_object { + return source.to_string(); + } + + // Wrap in parentheses with a semicolon to prevent it being parsed + // as a function call on the preceding expression (e.g. `1({a})`) + let close_pos = source.rfind('}').unwrap(); + let mut result = String::with_capacity(source.len() + 3); + result.push_str(&source[..open_pos]); + result.push_str(";("); + result.push_str(&source[open_pos..=close_pos]); + result.push(')'); + if close_pos + 1 < source.len() { + result.push_str(&source[close_pos + 1..]); + } + result +} + +struct AstLowerer<'a> { + #[allow(dead_code)] + source: &'a str, + body: Vec, + functions: Vec, +} + +impl<'a> AstLowerer<'a> { + fn new(source: &'a str) -> Self { + Self { + source, + body: Vec::new(), + functions: Vec::new(), + } + } + + fn span(&self, s: oxc_span::Span) -> Span { + s.into() + } + + fn unsupported(&self, span: oxc_span::Span, desc: &str) -> ZapcodeError { + ZapcodeError::UnsupportedSyntax { + span: format!("{}..{}", span.start, span.end), + description: desc.to_string(), + } + } + + fn lower_program(&mut self, program: &ast::Program<'_>) -> Result<()> { + // Handle directives (e.g., "use strict", but also bare string literals) + for directive in &program.directives { + let span = self.span(directive.span); + let expr = Expr::StringLit(directive.directive.to_string()); + self.body.push(Statement::Expression { expr, span }); + } + for stmt in &program.body { + let s = self.lower_statement(stmt)?; + self.body.push(s); + } + Ok(()) + } + + fn lower_statement(&mut self, stmt: &ast::Statement<'_>) -> Result { + match stmt { + ast::Statement::VariableDeclaration(decl) => self.lower_var_decl(decl), + ast::Statement::ExpressionStatement(expr_stmt) => { + let span = self.span(expr_stmt.span); + let expr = self.lower_expr(&expr_stmt.expression)?; + Ok(Statement::Expression { expr, span }) + } + ast::Statement::ReturnStatement(ret) => { + let span = self.span(ret.span); + let value = match &ret.argument { + Some(arg) => Some(self.lower_expr(arg)?), + None => None, + }; + Ok(Statement::Return { value, span }) + } + ast::Statement::IfStatement(if_stmt) => self.lower_if(if_stmt), + ast::Statement::WhileStatement(while_stmt) => { + let span = self.span(while_stmt.span); + let test = self.lower_expr(&while_stmt.test)?; + let body = self.lower_statement_as_block(&while_stmt.body)?; + Ok(Statement::While { test, body, span }) + } + ast::Statement::DoWhileStatement(do_while) => { + let span = self.span(do_while.span); + let body = self.lower_statement_as_block(&do_while.body)?; + let test = self.lower_expr(&do_while.test)?; + Ok(Statement::DoWhile { body, test, span }) + } + ast::Statement::ForStatement(for_stmt) => self.lower_for(for_stmt), + ast::Statement::ForInStatement(s) => { + Err(self.unsupported(s.span, "for...in loops are not supported, use for...of")) + } + ast::Statement::ForOfStatement(for_of) => self.lower_for_of(for_of), + ast::Statement::BlockStatement(block) => { + let span = self.span(block.span); + let body = self.lower_statements(&block.body)?; + Ok(Statement::Block { body, span }) + } + ast::Statement::ThrowStatement(throw) => { + let span = self.span(throw.span); + let value = self.lower_expr(&throw.argument)?; + Ok(Statement::Throw { value, span }) + } + ast::Statement::TryStatement(try_stmt) => self.lower_try(try_stmt), + ast::Statement::BreakStatement(s) => Ok(Statement::Break { + span: self.span(s.span), + }), + ast::Statement::ContinueStatement(s) => Ok(Statement::Continue { + span: self.span(s.span), + }), + ast::Statement::FunctionDeclaration(func) => self.lower_func_decl(func), + ast::Statement::ClassDeclaration(class) => self.lower_class_decl(class), + ast::Statement::SwitchStatement(switch) => self.lower_switch(switch), + ast::Statement::EmptyStatement(_) => Ok(Statement::Expression { + expr: Expr::UndefinedLit, + span: Span { start: 0, end: 0 }, + }), + ast::Statement::LabeledStatement(labeled) => self.lower_statement(&labeled.body), + ast::Statement::TSTypeAliasDeclaration(s) => Ok(Statement::Expression { + expr: Expr::UndefinedLit, + span: self.span(s.span), + }), + ast::Statement::TSInterfaceDeclaration(s) => Ok(Statement::Expression { + expr: Expr::UndefinedLit, + span: self.span(s.span), + }), + ast::Statement::TSEnumDeclaration(s) => { + Err(self.unsupported(s.span, "TypeScript enums are not supported")) + } + ast::Statement::ImportDeclaration(s) => Err(ZapcodeError::SandboxViolation(format!( + "import declarations are forbidden in the sandbox (at {}..{})", + s.span.start, s.span.end + ))), + ast::Statement::ExportDefaultDeclaration(s) => { + Err(ZapcodeError::SandboxViolation(format!( + "export declarations are forbidden in the sandbox (at {}..{})", + s.span.start, s.span.end + ))) + } + ast::Statement::ExportNamedDeclaration(s) => { + Err(ZapcodeError::SandboxViolation(format!( + "export declarations are forbidden in the sandbox (at {}..{})", + s.span.start, s.span.end + ))) + } + ast::Statement::ExportAllDeclaration(s) => { + Err(ZapcodeError::SandboxViolation(format!( + "export declarations are forbidden in the sandbox (at {}..{})", + s.span.start, s.span.end + ))) + } + ast::Statement::DebuggerStatement(s) => { + Err(self.unsupported(s.span, "unsupported statement type")) + } + ast::Statement::WithStatement(s) => { + Err(self.unsupported(s.span, "unsupported statement type")) + } + ast::Statement::TSModuleDeclaration(s) => { + Err(self.unsupported(s.span, "unsupported statement type")) + } + ast::Statement::TSGlobalDeclaration(s) => { + Err(self.unsupported(s.span, "unsupported statement type")) + } + ast::Statement::TSImportEqualsDeclaration(s) => { + Err(self.unsupported(s.span, "unsupported statement type")) + } + ast::Statement::TSExportAssignment(s) => { + Err(self.unsupported(s.span, "unsupported statement type")) + } + ast::Statement::TSNamespaceExportDeclaration(s) => { + Err(self.unsupported(s.span, "unsupported statement type")) + } + } + } + + fn lower_statements(&mut self, stmts: &[ast::Statement<'_>]) -> Result> { + stmts.iter().map(|s| self.lower_statement(s)).collect() + } + + fn lower_statement_as_block(&mut self, stmt: &ast::Statement<'_>) -> Result> { + match stmt { + ast::Statement::BlockStatement(block) => self.lower_statements(&block.body), + other => Ok(vec![self.lower_statement(other)?]), + } + } + + fn lower_var_decl(&mut self, decl: &ast::VariableDeclaration<'_>) -> Result { + let span = self.span(decl.span); + let kind = match decl.kind { + ast::VariableDeclarationKind::Const => VarKind::Const, + ast::VariableDeclarationKind::Let => VarKind::Let, + ast::VariableDeclarationKind::Var => VarKind::Var, + ast::VariableDeclarationKind::Using | ast::VariableDeclarationKind::AwaitUsing => { + return Err(self.unsupported(decl.span, "using declarations are not supported")); + } + }; + let mut declarations = Vec::new(); + for declarator in &decl.declarations { + let pattern = self.lower_binding_pattern(&declarator.id)?; + let init = match &declarator.init { + Some(expr) => Some(self.lower_expr(expr)?), + None => None, + }; + declarations.push(VarDeclarator { pattern, init }); + } + Ok(Statement::VariableDecl { + kind, + declarations, + span, + }) + } + + fn lower_binding_pattern(&mut self, pat: &ast::BindingPattern<'_>) -> Result { + match pat { + ast::BindingPattern::BindingIdentifier(id) => { + Ok(AssignTarget::Ident(id.name.to_string())) + } + ast::BindingPattern::ObjectPattern(obj) => { + let mut fields = Vec::new(); + for prop in &obj.properties { + let key = property_key_to_string(&prop.key); + let alias = match &prop.value { + ast::BindingPattern::BindingIdentifier(id) => { + let name = id.name.to_string(); + if name != key { + Some(name) + } else { + None + } + } + _ => None, + }; + let default = match &prop.value { + ast::BindingPattern::AssignmentPattern(assign) => { + Some(self.lower_expr(&assign.right)?) + } + _ => None, + }; + fields.push(DestructureField { + key, + alias, + default, + }); + } + Ok(AssignTarget::ObjectDestructure(fields)) + } + ast::BindingPattern::ArrayPattern(arr) => { + let mut elements = Vec::new(); + for elem in &arr.elements { + match elem { + Some(pat) => elements.push(Some(self.lower_binding_pattern(pat)?)), + None => elements.push(None), + } + } + Ok(AssignTarget::ArrayDestructure(elements)) + } + ast::BindingPattern::AssignmentPattern(assign) => { + self.lower_binding_pattern(&assign.left) + } + } + } + + fn lower_binding_pattern_to_param( + &mut self, + pat: &ast::BindingPattern<'_>, + ) -> Result { + match pat { + ast::BindingPattern::BindingIdentifier(id) => { + Ok(ParamPattern::Ident(id.name.to_string())) + } + ast::BindingPattern::ObjectPattern(obj) => { + let mut fields = Vec::new(); + for prop in &obj.properties { + let key = property_key_to_string(&prop.key); + let alias = match &prop.value { + ast::BindingPattern::BindingIdentifier(id) => { + let name = id.name.to_string(); + if name != key { + Some(name) + } else { + None + } + } + _ => None, + }; + fields.push(DestructureField { + key, + alias, + default: None, + }); + } + Ok(ParamPattern::ObjectDestructure(fields)) + } + ast::BindingPattern::ArrayPattern(arr) => { + let mut elems = Vec::new(); + for elem in &arr.elements { + match elem { + Some(p) => elems.push(Some(self.lower_binding_pattern_to_param(p)?)), + None => elems.push(None), + } + } + Ok(ParamPattern::ArrayDestructure(elems)) + } + ast::BindingPattern::AssignmentPattern(assign) => { + let inner = self.lower_binding_pattern_to_param(&assign.left)?; + let default = self.lower_expr(&assign.right)?; + Ok(ParamPattern::DefaultValue { + pattern: Box::new(inner), + default, + }) + } + } + } + + fn lower_if(&mut self, if_stmt: &ast::IfStatement<'_>) -> Result { + let span = self.span(if_stmt.span); + let test = self.lower_expr(&if_stmt.test)?; + let consequent = self.lower_statement_as_block(&if_stmt.consequent)?; + let alternate = match &if_stmt.alternate { + Some(alt) => Some(self.lower_statement_as_block(alt)?), + None => None, + }; + Ok(Statement::If { + test, + consequent, + alternate, + span, + }) + } + + fn lower_for(&mut self, for_stmt: &ast::ForStatement<'_>) -> Result { + let span = self.span(for_stmt.span); + let init = match &for_stmt.init { + Some(init) => match init { + ast::ForStatementInit::VariableDeclaration(decl) => { + Some(Box::new(self.lower_var_decl(decl)?)) + } + other => { + if let Some(expr_ref) = other.as_expression() { + let expr = self.lower_expr(expr_ref)?; + Some(Box::new(Statement::Expression { expr, span })) + } else { + return Err(ZapcodeError::CompileError( + "unsupported for-loop initializer".to_string(), + )); + } + } + }, + None => None, + }; + let test = match &for_stmt.test { + Some(t) => Some(self.lower_expr(t)?), + None => None, + }; + let update = match &for_stmt.update { + Some(u) => Some(self.lower_expr(u)?), + None => None, + }; + let body = self.lower_statement_as_block(&for_stmt.body)?; + Ok(Statement::For { + init, + test, + update, + body, + span, + }) + } + + fn lower_for_of(&mut self, for_of: &ast::ForOfStatement<'_>) -> Result { + let span = self.span(for_of.span); + let binding = match &for_of.left { + ast::ForStatementLeft::VariableDeclaration(decl) => { + if let Some(declarator) = decl.declarations.first() { + match &declarator.id { + ast::BindingPattern::BindingIdentifier(id) => { + ForBinding::Ident(id.name.to_string()) + } + _ => { + let pat = self.lower_binding_pattern_to_param(&declarator.id)?; + ForBinding::Destructure(pat) + } + } + } else { + return Err(self.unsupported(for_of.span, "empty for-of binding")); + } + } + _ => return Err(self.unsupported(for_of.span, "unsupported for-of left-hand side")), + }; + let iterable = self.lower_expr(&for_of.right)?; + let body = self.lower_statement_as_block(&for_of.body)?; + Ok(Statement::ForOf { + binding, + iterable, + body, + span, + }) + } + + fn lower_try(&mut self, try_stmt: &ast::TryStatement<'_>) -> Result { + let span = self.span(try_stmt.span); + let try_body = self.lower_statements(&try_stmt.block.body)?; + let (catch_param, catch_body) = match &try_stmt.handler { + Some(handler) => { + let param = handler.param.as_ref().and_then(|p| match &p.pattern { + ast::BindingPattern::BindingIdentifier(id) => Some(id.name.to_string()), + _ => None, + }); + let body = self.lower_statements(&handler.body.body)?; + (param, body) + } + None => (None, Vec::new()), + }; + let finally_body = match &try_stmt.finalizer { + Some(block) => Some(self.lower_statements(&block.body)?), + None => None, + }; + Ok(Statement::TryCatch { + try_body, + catch_param, + catch_body, + finally_body, + span, + }) + } + + fn lower_func_decl(&mut self, func: &ast::Function<'_>) -> Result { + let span = self.span(func.span); + let func_def = self.lower_function(func)?; + let func_index = self.functions.len(); + self.functions.push(func_def); + Ok(Statement::FunctionDecl { func_index, span }) + } + + fn lower_function(&mut self, func: &ast::Function<'_>) -> Result { + let name = func.id.as_ref().map(|id| id.name.to_string()); + let params = self.lower_formal_params(&func.params)?; + let body = match &func.body { + Some(body) => self.lower_statements(&body.statements)?, + None => Vec::new(), + }; + Ok(FunctionDef { + name, + params, + body, + is_async: func.r#async, + is_generator: func.generator, + is_arrow: false, + span: self.span(func.span), + }) + } + + fn lower_formal_params( + &mut self, + params: &ast::FormalParameters<'_>, + ) -> Result> { + let mut result = Vec::new(); + for param in ¶ms.items { + let pat = self.lower_binding_pattern_to_param(¶m.pattern)?; + result.push(pat); + } + if let Some(rest) = ¶ms.rest { + match &rest.rest.argument { + ast::BindingPattern::BindingIdentifier(id) => { + result.push(ParamPattern::Rest(id.name.to_string())); + } + _ => { + return Err(self.unsupported( + rest.span, + "complex rest parameter patterns are not supported", + )); + } + } + } + Ok(result) + } + + fn lower_class_decl(&mut self, class: &ast::Class<'_>) -> Result { + let span = self.span(class.span); + let name = class + .id + .as_ref() + .map(|id| id.name.to_string()) + .unwrap_or_else(|| "AnonymousClass".to_string()); + + let super_class = match &class.super_class { + Some(expr) => { + if let ast::Expression::Identifier(id) = expr { + Some(id.name.to_string()) + } else { + return Err(self.unsupported( + class.span, + "computed super class expressions are not supported", + )); + } + } + None => None, + }; + + let (constructor, methods, static_methods) = self.lower_class_body(&class.body)?; + + Ok(Statement::ClassDecl { + name, + super_class, + constructor, + methods, + static_methods, + span, + }) + } + + fn lower_class_expr(&mut self, class: &ast::Class<'_>) -> Result { + let name = class.id.as_ref().map(|id| id.name.to_string()); + + let super_class = match &class.super_class { + Some(expr) => { + if let ast::Expression::Identifier(id) = expr { + Some(id.name.to_string()) + } else { + return Err(self.unsupported( + class.span, + "computed super class expressions are not supported", + )); + } + } + None => None, + }; + + let (constructor, methods, static_methods) = self.lower_class_body(&class.body)?; + + Ok(Expr::ClassExpr { + name, + super_class, + constructor, + methods, + static_methods, + }) + } + + fn lower_class_body(&mut self, body: &ast::ClassBody<'_>) -> Result { + let mut constructor = None; + let mut methods = Vec::new(); + let mut static_methods = Vec::new(); + + for element in &body.body { + match element { + ast::ClassElement::MethodDefinition(method) => { + let method_name = match &method.key { + ast::PropertyKey::StaticIdentifier(id) => id.name.to_string(), + ast::PropertyKey::StringLiteral(s) => s.value.to_string(), + _ => continue, // skip computed method names + }; + + let func = &method.value; + let params = self.lower_formal_params(&func.params)?; + let body_stmts = match &func.body { + Some(body) => self.lower_statements(&body.statements)?, + None => Vec::new(), + }; + + let func_def = FunctionDef { + name: Some(method_name.clone()), + params, + body: body_stmts, + is_async: func.r#async, + is_generator: false, + is_arrow: false, + span: self.span(func.span), + }; + + match method.kind { + ast::MethodDefinitionKind::Constructor => { + constructor = Some(Box::new(func_def)); + } + ast::MethodDefinitionKind::Method => { + if method.r#static { + static_methods.push(ClassMethod { + name: method_name, + func: func_def, + }); + } else { + methods.push(ClassMethod { + name: method_name, + func: func_def, + }); + } + } + ast::MethodDefinitionKind::Get | ast::MethodDefinitionKind::Set => { + // Getters/setters: treat as regular methods for now + if method.r#static { + static_methods.push(ClassMethod { + name: method_name, + func: func_def, + }); + } else { + methods.push(ClassMethod { + name: method_name, + func: func_def, + }); + } + } + } + } + ast::ClassElement::PropertyDefinition(_) => { + // Class property declarations (e.g., `name: string;`) are type-level + // and are handled at runtime through constructor assignments. + // Skip them in the IR. + } + ast::ClassElement::AccessorProperty(s) => { + return Err(self + .unsupported(s.span, "accessor properties in classes are not supported")); + } + ast::ClassElement::TSIndexSignature(_) => { + // TypeScript-only, skip + } + ast::ClassElement::StaticBlock(s) => { + return Err(self.unsupported(s.span, "static blocks are not supported")); + } + } + } + + Ok((constructor, methods, static_methods)) + } + + fn lower_switch(&mut self, switch: &ast::SwitchStatement<'_>) -> Result { + let span = self.span(switch.span); + let discriminant = self.lower_expr(&switch.discriminant)?; + let mut cases = Vec::new(); + for case in &switch.cases { + let test = match &case.test { + Some(t) => Some(self.lower_expr(t)?), + None => None, + }; + let consequent = self.lower_statements(&case.consequent)?; + cases.push(SwitchCase { test, consequent }); + } + Ok(Statement::Switch { + discriminant, + cases, + span, + }) + } + + fn lower_expr(&mut self, expr: &ast::Expression<'_>) -> Result { + match expr { + ast::Expression::NumericLiteral(lit) => Ok(Expr::NumberLit(lit.value)), + ast::Expression::StringLiteral(lit) => Ok(Expr::StringLit(lit.value.to_string())), + ast::Expression::BooleanLiteral(lit) => Ok(Expr::BoolLit(lit.value)), + ast::Expression::NullLiteral(_) => Ok(Expr::NullLit), + ast::Expression::TemplateLiteral(tpl) => { + let quasis: Vec = + tpl.quasis.iter().map(|q| q.value.raw.to_string()).collect(); + let exprs: Result> = + tpl.expressions.iter().map(|e| self.lower_expr(e)).collect(); + Ok(Expr::TemplateLit { + quasis, + exprs: exprs?, + }) + } + ast::Expression::RegExpLiteral(re) => Ok(Expr::RegExpLit { + pattern: format!("{:?}", re.regex.pattern), + flags: re.regex.flags.to_string(), + }), + ast::Expression::Identifier(id) => { + let name = id.name.to_string(); + match name.as_str() { + "undefined" => Ok(Expr::UndefinedLit), + "NaN" => Ok(Expr::NumberLit(f64::NAN)), + "Infinity" => Ok(Expr::NumberLit(f64::INFINITY)), + "eval" => Err(ZapcodeError::SandboxViolation( + "eval is forbidden in the sandbox".to_string(), + )), + "Function" => Err(ZapcodeError::SandboxViolation( + "Function constructor is forbidden in the sandbox".to_string(), + )), + "process" => Err(ZapcodeError::SandboxViolation( + "process is forbidden in the sandbox".to_string(), + )), + "globalThis" | "global" => Err(ZapcodeError::SandboxViolation( + "globalThis/global is forbidden in the sandbox".to_string(), + )), + "require" => Err(ZapcodeError::SandboxViolation( + "require is forbidden in the sandbox".to_string(), + )), + _ => Ok(Expr::Ident(name)), + } + } + ast::Expression::ArrayExpression(arr) => { + let mut elements = Vec::new(); + for elem in &arr.elements { + match elem { + ast::ArrayExpressionElement::SpreadElement(spread) => { + let expr = self.lower_expr(&spread.argument)?; + elements.push(Some(Expr::Spread(Box::new(expr)))); + } + ast::ArrayExpressionElement::Elision(_) => { + elements.push(None); + } + other => { + let expr_ref = other.to_expression(); + let expr = self.lower_expr(expr_ref)?; + elements.push(Some(expr)); + } + } + } + Ok(Expr::Array(elements)) + } + ast::Expression::ObjectExpression(obj) => { + let mut props = Vec::new(); + for prop in &obj.properties { + match prop { + ast::ObjectPropertyKind::ObjectProperty(p) => { + let key = self.lower_property_key(&p.key)?; + let computed = p.computed; + + if p.shorthand { + props.push(ObjProperty { + kind: PropKind::Shorthand, + key: key.clone(), + value: Expr::Ident(key), + computed: false, + }); + } else if p.method { + let value = self.lower_expr(&p.value)?; + props.push(ObjProperty { + kind: PropKind::Method, + key, + value, + computed, + }); + } else { + let value = self.lower_expr(&p.value)?; + props.push(ObjProperty { + kind: PropKind::Init, + key, + value, + computed, + }); + } + } + ast::ObjectPropertyKind::SpreadProperty(spread) => { + let expr = self.lower_expr(&spread.argument)?; + props.push(ObjProperty { + kind: PropKind::Spread, + key: String::new(), + value: expr, + computed: false, + }); + } + } + } + Ok(Expr::Object(props)) + } + ast::Expression::BinaryExpression(bin) => { + let op = lower_binary_op(bin.operator)?; + let left = self.lower_expr(&bin.left)?; + let right = self.lower_expr(&bin.right)?; + Ok(Expr::Binary { + op, + left: Box::new(left), + right: Box::new(right), + }) + } + ast::Expression::UnaryExpression(unary) => { + if matches!(unary.operator, ast::UnaryOperator::Typeof) { + let operand = self.lower_expr(&unary.argument)?; + return Ok(Expr::TypeOf(Box::new(operand))); + } + if matches!(unary.operator, ast::UnaryOperator::Delete) { + return Err(self.unsupported(unary.span, "delete operator is not supported")); + } + let op = match unary.operator { + ast::UnaryOperator::UnaryNegation => UnaryOp::Neg, + ast::UnaryOperator::LogicalNot => UnaryOp::Not, + ast::UnaryOperator::BitwiseNot => UnaryOp::BitNot, + ast::UnaryOperator::Void => UnaryOp::Void, + ast::UnaryOperator::UnaryPlus => { + let operand = self.lower_expr(&unary.argument)?; + return Ok(Expr::Binary { + op: BinOp::Mul, + left: Box::new(operand), + right: Box::new(Expr::NumberLit(1.0)), + }); + } + _ => { + return Err(self.unsupported(unary.span, "unsupported unary operator")); + } + }; + let operand = self.lower_expr(&unary.argument)?; + Ok(Expr::Unary { + op, + operand: Box::new(operand), + }) + } + ast::Expression::UpdateExpression(update) => { + let op = match update.operator { + ast::UpdateOperator::Increment => UpdateOp::Increment, + ast::UpdateOperator::Decrement => UpdateOp::Decrement, + }; + let operand = self.lower_simple_assign_target(&update.argument)?; + Ok(Expr::Update { + op, + prefix: update.prefix, + operand: Box::new(operand), + }) + } + ast::Expression::LogicalExpression(logical) => { + let op = match logical.operator { + ast::LogicalOperator::And => LogicalOp::And, + ast::LogicalOperator::Or => LogicalOp::Or, + ast::LogicalOperator::Coalesce => LogicalOp::NullishCoalescing, + }; + let left = self.lower_expr(&logical.left)?; + let right = self.lower_expr(&logical.right)?; + Ok(Expr::Logical { + op, + left: Box::new(left), + right: Box::new(right), + }) + } + ast::Expression::ConditionalExpression(cond) => { + let test = self.lower_expr(&cond.test)?; + let consequent = self.lower_expr(&cond.consequent)?; + let alternate = self.lower_expr(&cond.alternate)?; + Ok(Expr::Conditional { + test: Box::new(test), + consequent: Box::new(consequent), + alternate: Box::new(alternate), + }) + } + ast::Expression::AssignmentExpression(assign) => { + let op = lower_assign_op(assign.operator); + let target = self.lower_assignment_target(&assign.left)?; + let value = self.lower_expr(&assign.right)?; + Ok(Expr::Assignment { + op, + target: Box::new(target), + value: Box::new(value), + }) + } + ast::Expression::SequenceExpression(seq) => { + let exprs: Result> = + seq.expressions.iter().map(|e| self.lower_expr(e)).collect(); + Ok(Expr::Sequence(exprs?)) + } + ast::Expression::CallExpression(call) => { + let callee = self.lower_expr(&call.callee)?; + let args = self.lower_args(&call.arguments)?; + Ok(Expr::Call { + callee: Box::new(callee), + args, + optional: call.optional, + }) + } + ast::Expression::NewExpression(new_expr) => { + let callee = self.lower_expr(&new_expr.callee)?; + let args = self.lower_args(&new_expr.arguments)?; + Ok(Expr::New { + callee: Box::new(callee), + args, + }) + } + ast::Expression::StaticMemberExpression(member) => { + let object = self.lower_expr(&member.object)?; + let property = member.property.name.to_string(); + Ok(Expr::Member { + object: Box::new(object), + property, + optional: member.optional, + }) + } + ast::Expression::ComputedMemberExpression(member) => { + let object = self.lower_expr(&member.object)?; + let property = self.lower_expr(&member.expression)?; + Ok(Expr::ComputedMember { + object: Box::new(object), + property: Box::new(property), + optional: member.optional, + }) + } + ast::Expression::PrivateFieldExpression(s) => { + Err(self.unsupported(s.span, "private fields are not supported")) + } + ast::Expression::ArrowFunctionExpression(arrow) => { + let params = self.lower_formal_params(&arrow.params)?; + let body = if arrow.expression { + match arrow.body.statements.first() { + Some(ast::Statement::ExpressionStatement(expr)) => { + let ret_expr = self.lower_expr(&expr.expression)?; + vec![Statement::Return { + value: Some(ret_expr), + span: self.span(arrow.span), + }] + } + _ => self.lower_statements(&arrow.body.statements)?, + } + } else { + self.lower_statements(&arrow.body.statements)? + }; + let func_index = self.functions.len(); + self.functions.push(FunctionDef { + name: None, + params, + body, + is_async: arrow.r#async, + is_generator: false, + is_arrow: true, + span: self.span(arrow.span), + }); + Ok(Expr::ArrowFunction { func_index }) + } + ast::Expression::FunctionExpression(func) => { + let func_def = self.lower_function(func)?; + let func_index = self.functions.len(); + self.functions.push(func_def); + Ok(Expr::FunctionExpr { func_index }) + } + ast::Expression::AwaitExpression(await_expr) => { + let expr = self.lower_expr(&await_expr.argument)?; + Ok(Expr::Await(Box::new(expr))) + } + ast::Expression::ParenthesizedExpression(paren) => self.lower_expr(&paren.expression), + ast::Expression::ChainExpression(chain) => self.lower_chain_expr(&chain.expression), + ast::Expression::TaggedTemplateExpression(s) => { + Err(self.unsupported(s.span, "tagged template expressions are not supported")) + } + ast::Expression::ThisExpression(_) => Ok(Expr::Ident("this".to_string())), + ast::Expression::Super(_) => Ok(Expr::Ident("super".to_string())), + ast::Expression::YieldExpression(yield_expr) => { + let value = match &yield_expr.argument { + Some(arg) => Some(Box::new(self.lower_expr(arg)?)), + None => None, + }; + Ok(Expr::Yield { + value, + delegate: yield_expr.delegate, + }) + } + ast::Expression::ClassExpression(class) => self.lower_class_expr(class), + ast::Expression::MetaProperty(s) => { + Err(self.unsupported(s.span, "meta properties are not supported")) + } + ast::Expression::ImportExpression(s) => Err(ZapcodeError::SandboxViolation(format!( + "dynamic import() is forbidden in the sandbox (at {}..{})", + s.span.start, s.span.end + ))), + ast::Expression::TSAsExpression(ts) => self.lower_expr(&ts.expression), + ast::Expression::TSSatisfiesExpression(ts) => self.lower_expr(&ts.expression), + ast::Expression::TSNonNullExpression(ts) => self.lower_expr(&ts.expression), + ast::Expression::TSTypeAssertion(ts) => self.lower_expr(&ts.expression), + ast::Expression::TSInstantiationExpression(ts) => self.lower_expr(&ts.expression), + _ => Err(ZapcodeError::UnsupportedSyntax { + span: "unknown".to_string(), + description: "unsupported expression type".to_string(), + }), + } + } + + fn lower_chain_expr(&mut self, expr: &ast::ChainElement<'_>) -> Result { + match expr { + ast::ChainElement::CallExpression(call) => { + let callee = self.lower_expr(&call.callee)?; + let args = self.lower_args(&call.arguments)?; + Ok(Expr::Call { + callee: Box::new(callee), + args, + optional: call.optional, + }) + } + ast::ChainElement::StaticMemberExpression(member) => { + let object = self.lower_expr(&member.object)?; + Ok(Expr::Member { + object: Box::new(object), + property: member.property.name.to_string(), + optional: member.optional, + }) + } + ast::ChainElement::ComputedMemberExpression(member) => { + let object = self.lower_expr(&member.object)?; + let property = self.lower_expr(&member.expression)?; + Ok(Expr::ComputedMember { + object: Box::new(object), + property: Box::new(property), + optional: member.optional, + }) + } + ast::ChainElement::PrivateFieldExpression(s) => { + Err(self.unsupported(s.span, "private fields are not supported")) + } + ast::ChainElement::TSNonNullExpression(ts) => self.lower_expr(&ts.expression), + } + } + + fn lower_args(&mut self, args: &[ast::Argument<'_>]) -> Result> { + let mut result = Vec::new(); + for arg in args { + match arg { + ast::Argument::SpreadElement(spread) => { + let expr = self.lower_expr(&spread.argument)?; + result.push(Expr::Spread(Box::new(expr))); + } + other => { + let expr_ref = other.to_expression(); + let expr = self.lower_expr(expr_ref)?; + result.push(expr); + } + } + } + Ok(result) + } + + fn lower_property_key(&mut self, key: &ast::PropertyKey<'_>) -> Result { + Ok(property_key_to_string_from_key(key)) + } + + fn lower_assignment_target(&mut self, target: &ast::AssignmentTarget<'_>) -> Result { + match target { + ast::AssignmentTarget::AssignmentTargetIdentifier(id) => { + Ok(Expr::Ident(id.name.to_string())) + } + ast::AssignmentTarget::StaticMemberExpression(member) => { + let object = self.lower_expr(&member.object)?; + Ok(Expr::Member { + object: Box::new(object), + property: member.property.name.to_string(), + optional: false, + }) + } + ast::AssignmentTarget::ComputedMemberExpression(member) => { + let object = self.lower_expr(&member.object)?; + let property = self.lower_expr(&member.expression)?; + Ok(Expr::ComputedMember { + object: Box::new(object), + property: Box::new(property), + optional: false, + }) + } + _ => Err(ZapcodeError::CompileError( + "unsupported assignment target".to_string(), + )), + } + } + + fn lower_simple_assign_target( + &mut self, + target: &ast::SimpleAssignmentTarget<'_>, + ) -> Result { + match target { + ast::SimpleAssignmentTarget::AssignmentTargetIdentifier(id) => { + Ok(Expr::Ident(id.name.to_string())) + } + ast::SimpleAssignmentTarget::StaticMemberExpression(member) => { + let object = self.lower_expr(&member.object)?; + Ok(Expr::Member { + object: Box::new(object), + property: member.property.name.to_string(), + optional: false, + }) + } + ast::SimpleAssignmentTarget::ComputedMemberExpression(member) => { + let object = self.lower_expr(&member.object)?; + let property = self.lower_expr(&member.expression)?; + Ok(Expr::ComputedMember { + object: Box::new(object), + property: Box::new(property), + optional: false, + }) + } + _ => Err(ZapcodeError::CompileError( + "unsupported update target".to_string(), + )), + } + } +} + +fn property_key_to_string(key: &ast::PropertyKey<'_>) -> String { + property_key_to_string_from_key(key) +} + +fn property_key_to_string_from_key(key: &ast::PropertyKey<'_>) -> String { + match key { + ast::PropertyKey::StaticIdentifier(id) => id.name.to_string(), + ast::PropertyKey::StringLiteral(s) => s.value.to_string(), + ast::PropertyKey::NumericLiteral(n) => n.value.to_string(), + _ => "".to_string(), + } +} + +fn lower_binary_op(op: ast::BinaryOperator) -> Result { + match op { + ast::BinaryOperator::Addition => Ok(BinOp::Add), + ast::BinaryOperator::Subtraction => Ok(BinOp::Sub), + ast::BinaryOperator::Multiplication => Ok(BinOp::Mul), + ast::BinaryOperator::Division => Ok(BinOp::Div), + ast::BinaryOperator::Remainder => Ok(BinOp::Rem), + ast::BinaryOperator::Exponential => Ok(BinOp::Pow), + ast::BinaryOperator::Equality => Ok(BinOp::Eq), + ast::BinaryOperator::Inequality => Ok(BinOp::Neq), + ast::BinaryOperator::StrictEquality => Ok(BinOp::StrictEq), + ast::BinaryOperator::StrictInequality => Ok(BinOp::StrictNeq), + ast::BinaryOperator::LessThan => Ok(BinOp::Lt), + ast::BinaryOperator::LessEqualThan => Ok(BinOp::Lte), + ast::BinaryOperator::GreaterThan => Ok(BinOp::Gt), + ast::BinaryOperator::GreaterEqualThan => Ok(BinOp::Gte), + ast::BinaryOperator::BitwiseAnd => Ok(BinOp::BitAnd), + ast::BinaryOperator::BitwiseOR => Ok(BinOp::BitOr), + ast::BinaryOperator::BitwiseXOR => Ok(BinOp::BitXor), + ast::BinaryOperator::ShiftLeft => Ok(BinOp::Shl), + ast::BinaryOperator::ShiftRight => Ok(BinOp::Shr), + ast::BinaryOperator::ShiftRightZeroFill => Ok(BinOp::Ushr), + ast::BinaryOperator::In => Ok(BinOp::In), + ast::BinaryOperator::Instanceof => Ok(BinOp::InstanceOf), + } +} + +fn lower_assign_op(op: ast::AssignmentOperator) -> AssignOp { + match op { + ast::AssignmentOperator::Assign => AssignOp::Assign, + ast::AssignmentOperator::Addition => AssignOp::AddAssign, + ast::AssignmentOperator::Subtraction => AssignOp::SubAssign, + ast::AssignmentOperator::Multiplication => AssignOp::MulAssign, + ast::AssignmentOperator::Division => AssignOp::DivAssign, + ast::AssignmentOperator::Remainder => AssignOp::RemAssign, + ast::AssignmentOperator::Exponential => AssignOp::PowAssign, + ast::AssignmentOperator::BitwiseAnd => AssignOp::BitAndAssign, + ast::AssignmentOperator::BitwiseOR => AssignOp::BitOrAssign, + ast::AssignmentOperator::BitwiseXOR => AssignOp::BitXorAssign, + ast::AssignmentOperator::ShiftLeft => AssignOp::ShlAssign, + ast::AssignmentOperator::ShiftRight => AssignOp::ShrAssign, + ast::AssignmentOperator::ShiftRightZeroFill => AssignOp::UshrAssign, + ast::AssignmentOperator::LogicalNullish => AssignOp::NullishAssign, + ast::AssignmentOperator::LogicalAnd => AssignOp::AndAssign, + ast::AssignmentOperator::LogicalOr => AssignOp::OrAssign, + } +} diff --git a/crates/bashkit/src/zapcode_browser/sandbox.rs b/crates/bashkit/src/zapcode_browser/sandbox.rs new file mode 100644 index 000000000..4ce68c44d --- /dev/null +++ b/crates/bashkit/src/zapcode_browser/sandbox.rs @@ -0,0 +1,70 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResourceLimits { + pub memory_limit_bytes: usize, + pub time_limit_ms: u64, + pub max_stack_depth: usize, + pub max_allocations: usize, +} + +impl Default for ResourceLimits { + fn default() -> Self { + Self { + memory_limit_bytes: 32 * 1024 * 1024, // 32MB + time_limit_ms: 5000, // 5s + max_stack_depth: 512, + max_allocations: 100_000, + } + } +} + +/// Tracks resource usage during execution. +#[derive(Debug, Default)] +pub struct ResourceTracker { + pub allocations: usize, + pub current_stack_depth: usize, + pub peak_stack_depth: usize, + start_time: Option, +} + +impl ResourceTracker { + pub fn start(&mut self) { + self.start_time = Some(crate::time::Instant::now()); + } + + pub fn check_time(&self, limits: &ResourceLimits) -> crate::zapcode_browser::error::Result<()> { + if let Some(start) = self.start_time { + if start.elapsed().as_millis() as u64 > limits.time_limit_ms { + return Err(crate::zapcode_browser::ZapcodeError::TimeLimitExceeded); + } + } + Ok(()) + } + + pub fn check_stack(&self, limits: &ResourceLimits) -> crate::zapcode_browser::error::Result<()> { + if self.current_stack_depth > limits.max_stack_depth { + return Err(crate::zapcode_browser::ZapcodeError::StackOverflow(self.current_stack_depth)); + } + Ok(()) + } + + pub fn track_allocation(&mut self, limits: &ResourceLimits) -> crate::zapcode_browser::error::Result<()> { + self.allocations += 1; + if self.allocations > limits.max_allocations { + return Err(crate::zapcode_browser::ZapcodeError::AllocationLimitExceeded); + } + Ok(()) + } + + pub fn push_frame(&mut self) { + self.current_stack_depth += 1; + if self.current_stack_depth > self.peak_stack_depth { + self.peak_stack_depth = self.current_stack_depth; + } + } + + pub fn pop_frame(&mut self) { + self.current_stack_depth = self.current_stack_depth.saturating_sub(1); + } +} diff --git a/crates/bashkit/src/zapcode_browser/snapshot.rs b/crates/bashkit/src/zapcode_browser/snapshot.rs new file mode 100644 index 000000000..ff4398724 --- /dev/null +++ b/crates/bashkit/src/zapcode_browser/snapshot.rs @@ -0,0 +1,102 @@ +use std::collections::{HashMap, HashSet}; + +use serde::{Deserialize, Serialize}; + +use crate::zapcode_browser::compiler::CompiledProgram; +use crate::zapcode_browser::error::{Result, ZapcodeError}; +use crate::zapcode_browser::sandbox::ResourceLimits; +use crate::zapcode_browser::value::Value; +use crate::zapcode_browser::vm::{CallFrame, Continuation, TryInfo, Vm, VmState}; + +/// Internal serializable representation of VM state at a suspension point. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct VmSnapshot { + program: CompiledProgram, + stack: Vec, + frames: Vec, + /// User-defined globals only — builtins are re-registered on resume. + globals: Vec<(String, Value)>, + try_stack: Vec, + continuations: Vec, + stdout: String, + limits: ResourceLimits, + external_functions: Vec, +} + +/// A snapshot of VM state at a suspension point. +/// Can be serialized to bytes and resumed later in any process. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ZapcodeSnapshot { + data: Vec, +} + +impl ZapcodeSnapshot { + /// Capture the current VM state as a snapshot. + pub(crate) fn capture(vm: &Vm) -> Result { + // Filter out builtin globals — they'll be re-registered on resume. + let builtin_names: HashSet<&str> = Vm::BUILTIN_GLOBAL_NAMES.iter().copied().collect(); + let user_globals: Vec<(String, Value)> = vm + .globals + .iter() + .filter(|(k, _)| !builtin_names.contains(k.as_str())) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + + let snapshot = VmSnapshot { + program: vm.program.clone(), + stack: vm.stack.clone(), + frames: vm.frames.clone(), + globals: user_globals, + try_stack: vm.try_stack.clone(), + continuations: vm.continuations.clone(), + stdout: vm.stdout.clone(), + limits: vm.limits.clone(), + external_functions: vm.external_functions.iter().cloned().collect(), + }; + + let data = postcard::to_allocvec(&snapshot) + .map_err(|e| ZapcodeError::SnapshotError(format!("capture failed: {}", e)))?; + + Ok(Self { data }) + } + + /// Serialize the snapshot to bytes for storage / transport. + pub fn dump(&self) -> Result> { + postcard::to_allocvec(self) + .map_err(|e| ZapcodeError::SnapshotError(format!("dump failed: {}", e))) + } + + /// Deserialize a snapshot from bytes. + pub fn load(bytes: &[u8]) -> Result { + postcard::from_bytes(bytes) + .map_err(|e| ZapcodeError::SnapshotError(format!("load failed: {}", e))) + } + + /// Resume execution with a return value from the external function. + /// Returns a `VmState` which may be `Complete` or another `Suspended`. + pub fn resume(self, return_value: Value) -> Result { + let vm_snap: VmSnapshot = postcard::from_bytes(&self.data) + .map_err(|e| ZapcodeError::SnapshotError(format!("resume decode failed: {}", e)))?; + + let user_globals: HashMap = vm_snap.globals.into_iter().collect(); + let ext_set: HashSet = vm_snap.external_functions.into_iter().collect(); + + let mut vm = Vm::from_snapshot( + vm_snap.program, + vm_snap.stack, + vm_snap.frames, + user_globals, + vm_snap.try_stack, + vm_snap.continuations, + vm_snap.stdout, + vm_snap.limits, + ext_set, + ); + + // Push the return value onto the stack — this is the result the + // `CallExternal` instruction was waiting for. + vm.stack.push(return_value); + + vm.resume_execution() + } +} diff --git a/crates/bashkit/src/zapcode_browser/trace.rs b/crates/bashkit/src/zapcode_browser/trace.rs new file mode 100644 index 000000000..17cbd6285 --- /dev/null +++ b/crates/bashkit/src/zapcode_browser/trace.rs @@ -0,0 +1,179 @@ +//! Execution trace for debugging and observability. +//! +//! Captures a tree of spans covering parse → compile → execute → tool calls. +//! The trace is lightweight and always collected (sub-microsecond overhead). +//! +//! The `TraceSpan` shape is designed to map cleanly to OpenTelemetry spans +//! for future export to Jaeger, Langfuse, Datadog, etc. + +use crate::time::{Instant, SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +/// A single span in the execution trace. +/// +/// Shaped to be OTel-compatible: each span has a name, timestamps, +/// status, key-value attributes, and children. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TraceSpan { + /// Span name (e.g. "parse", "compile", "execute", "tool_call", "suspend"). + pub name: String, + /// When the span started (ms since Unix epoch). + pub start_time_ms: u64, + /// When the span ended (ms since Unix epoch). 0 if still open. + pub end_time_ms: u64, + /// Duration in microseconds. + pub duration_us: u64, + /// "ok" or "error". + pub status: TraceStatus, + /// Structured attributes. Keys use `zapcode.*` namespace. + pub attributes: Vec<(String, String)>, + /// Child spans. + pub children: Vec, +} + +/// Span status. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum TraceStatus { + Ok, + Error, +} + +/// Builder for constructing trace spans with proper timing. +pub(crate) struct SpanBuilder { + name: String, + start_wall: u64, + start_instant: Instant, + attributes: Vec<(String, String)>, + children: Vec, +} + +impl SpanBuilder { + pub fn new(name: &str) -> Self { + Self { + name: name.to_string(), + start_wall: now_ms(), + start_instant: Instant::now(), + attributes: Vec::new(), + children: Vec::new(), + } + } + + pub fn attr(mut self, key: &str, value: impl ToString) -> Self { + self.attributes.push((key.to_string(), value.to_string())); + self + } + + pub fn set_attr(&mut self, key: &str, value: impl ToString) { + self.attributes.push((key.to_string(), value.to_string())); + } + + pub fn add_child(&mut self, child: TraceSpan) { + self.children.push(child); + } + + pub fn finish(self, status: TraceStatus) -> TraceSpan { + let elapsed = self.start_instant.elapsed(); + TraceSpan { + name: self.name, + start_time_ms: self.start_wall, + end_time_ms: self.start_wall + elapsed.as_millis() as u64, + duration_us: elapsed.as_micros() as u64, + status, + attributes: self.attributes, + children: self.children, + } + } + + pub fn finish_ok(self) -> TraceSpan { + self.finish(TraceStatus::Ok) + } + + pub fn finish_error(self, error: &str) -> TraceSpan { + self.attr("zapcode.error", error).finish(TraceStatus::Error) + } +} + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +/// Execution trace covering a full run (parse + compile + execute). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExecutionTrace { + pub root: TraceSpan, +} + +impl ExecutionTrace { + /// Pretty-print the trace as a tree. + pub fn print(&self) { + print_span(&self.root, 0, true); + } + + /// Format the trace as a tree string. + pub fn to_string_pretty(&self) -> String { + let mut buf = String::new(); + format_span(&self.root, 0, true, &mut buf); + buf + } +} + +fn format_duration(us: u64) -> String { + if us < 1000 { + format!("{}µs", us) + } else if us < 1_000_000 { + format!("{:.1}ms", us as f64 / 1000.0) + } else { + format!("{:.2}s", us as f64 / 1_000_000.0) + } +} + +fn format_span(span: &TraceSpan, depth: usize, is_last: bool, buf: &mut String) { + let icon = match span.status { + TraceStatus::Ok => "✓", + TraceStatus::Error => "✗", + }; + let duration = format_duration(span.duration_us); + + // Build prefix + let prefix = if depth == 0 { + String::new() + } else { + let connector = if is_last { "└─ " } else { "├─ " }; + let indent = "│ ".repeat(depth - 1); + format!("{}{}", indent, connector) + }; + + buf.push_str(&format!("{}{} {} ({})", prefix, icon, span.name, duration)); + + // Show key attributes inline + for (k, v) in &span.attributes { + if k == "zapcode.error" { + buf.push_str(&format!(" error=\"{}\"", v)); + } else if k == "zapcode.tool.name" { + buf.push_str(&format!(" {}", v)); + } else if k == "zapcode.tool.args" { + buf.push_str(&format!("({})", v)); + } else if k == "zapcode.tool.result" { + let display = if v.len() > 60 { &v[..57] } else { v }; + buf.push_str(&format!(" → {}", display)); + if v.len() > 60 { + buf.push_str("..."); + } + } + } + buf.push('\n'); + + for (i, child) in span.children.iter().enumerate() { + format_span(child, depth + 1, i == span.children.len() - 1, buf); + } +} + +fn print_span(span: &TraceSpan, depth: usize, is_last: bool) { + let mut buf = String::new(); + format_span(span, depth, is_last, &mut buf); + print!("{}", buf); +} diff --git a/crates/bashkit/src/zapcode_browser/upstream-sha256.json b/crates/bashkit/src/zapcode_browser/upstream-sha256.json new file mode 100644 index 000000000..1f816ec65 --- /dev/null +++ b/crates/bashkit/src/zapcode_browser/upstream-sha256.json @@ -0,0 +1,14 @@ +{ + "value.rs": "28bd134e1d12911ade9d6de21eea234d52f3cea8a2ef65f7088e24f84582cd89", + "sandbox.rs": "35208f61dc46022dc22b158095773df9f2f77ff0ac3e182ec6bcb141c3a1ce33", + "trace.rs": "c5725abf64f9181a3d37d4aa44c9d155df5c187c2b8d9079963e8002e614080b", + "error.rs": "96ef9f52f262443bc5d9ef2fa26b6e94780669843da10c043a9b4646142604b4", + "snapshot.rs": "8181e60148d23dd6ec0801de530b20614b4ee151188cfb4b549068a81f32940f", + "lib.rs": "2c7cf21ffc37c8998232898a052b96b7ea53b77e0dfc78b02d600c503e106d0d", + "parser/ir.rs": "7f9ef74cd01fef9c8f571423e47a9427e3b5b750b5ef5e316de953e4bc1fad57", + "parser/mod.rs": "9c5432f040676e76745de2490ec7e709d54291b2adaa2b97257d2d6478e036fd", + "vm/mod.rs": "9819cecd056f841e6fa85e79a240e540ce01f90623f4c386600cb9d1b3258e9b", + "vm/builtins.rs": "b7a6726cf15a21721cbf25f9722f7b88b882ad4dde65724faf3702e7c9be0aae", + "compiler/instruction.rs": "c8cf4b09bce92ab4c06e0afbea13a7d877050ec6bd1ee60c6c9446f961c79c54", + "compiler/mod.rs": "c82a7e192b2740616524a59dc91d85ea2e8e48e51c1964d744f7015748ee50fb" +} diff --git a/crates/bashkit/src/zapcode_browser/value.rs b/crates/bashkit/src/zapcode_browser/value.rs new file mode 100644 index 000000000..5991fecc4 --- /dev/null +++ b/crates/bashkit/src/zapcode_browser/value.rs @@ -0,0 +1,164 @@ +use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::sync::Arc; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Value { + Undefined, + Null, + Bool(bool), + Int(i64), + Float(f64), + String(Arc), + Array(Vec), + Object(IndexMap, Value>), + Function(Closure), + /// A generator object — calling function* creates one of these. + /// Generators are stateful and cannot be serialized mid-yield. + #[serde(skip)] + Generator(GeneratorObject), + /// Internal: a bound method on a built-in object (e.g., console.log, Math.floor). + /// Not visible to user code — used to dispatch builtin calls. + #[serde(skip)] + BuiltinMethod { + object_name: Arc, + method_name: Arc, + }, +} + +/// Identifies a function in the compiled program. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct FunctionId(pub usize); + +/// A closure captures the enclosing scope's variables. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Closure { + pub func_id: FunctionId, + pub captured: Vec<(String, Value)>, +} + +/// The state of a generator object. +#[derive(Debug, Clone)] +pub struct GeneratorObject { + /// Unique ID for this generator instance (used as key in VM generator registry). + pub id: u64, + /// The function this generator was created from. + pub func_id: FunctionId, + /// Captured closure variables. + pub captured: Vec<(String, Value)>, + /// Suspended execution state. None = not yet started. + pub suspended: Option, + /// Whether the generator has completed. + pub done: bool, +} + +/// Saved execution state of a suspended generator. +#[derive(Debug, Clone)] +pub struct SuspendedFrame { + pub ip: usize, + pub locals: Vec, + pub stack: Vec, +} + +impl Value { + pub fn type_name(&self) -> &'static str { + match self { + Value::Undefined => "undefined", + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Int(_) | Value::Float(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "object", + Value::Object(_) => "object", + Value::Function(_) | Value::BuiltinMethod { .. } => "function", + Value::Generator(_) => "object", + } + } + + pub fn is_truthy(&self) -> bool { + match self { + Value::Undefined | Value::Null => false, + Value::Bool(b) => *b, + Value::Int(n) => *n != 0, + Value::Float(n) => *n != 0.0 && !n.is_nan(), + Value::String(s) => !s.is_empty(), + Value::Array(_) + | Value::Object(_) + | Value::Function(_) + | Value::BuiltinMethod { .. } + | Value::Generator(_) => true, + } + } + + pub fn to_number(&self) -> f64 { + match self { + Value::Undefined => f64::NAN, + Value::Null => 0.0, + Value::Bool(true) => 1.0, + Value::Bool(false) => 0.0, + Value::Int(n) => *n as f64, + Value::Float(n) => *n, + Value::String(s) => s.parse::().unwrap_or(f64::NAN), + _ => f64::NAN, + } + } + + pub fn to_js_string(&self) -> String { + match self { + Value::Undefined => "undefined".to_string(), + Value::Null => "null".to_string(), + Value::Bool(b) => b.to_string(), + Value::Int(n) => n.to_string(), + Value::Float(n) => { + if n.is_infinite() { + if *n > 0.0 { + "Infinity".to_string() + } else { + "-Infinity".to_string() + } + } else if n.is_nan() { + "NaN".to_string() + } else { + // Remove trailing ".0" for whole numbers + n.to_string() + } + } + Value::String(s) => s.to_string(), + Value::Array(arr) => { + let items: Vec = arr.iter().map(|v| v.to_js_string()).collect(); + items.join(",") + } + Value::Object(_) => "[object Object]".to_string(), + Value::Function(_) | Value::BuiltinMethod { .. } => "function".to_string(), + Value::Generator(_) => "[object Generator]".to_string(), + } + } + + /// Strict equality (===) + pub fn strict_eq(&self, other: &Value) -> bool { + match (self, other) { + (Value::Undefined, Value::Undefined) | (Value::Null, Value::Null) => true, + (Value::Bool(a), Value::Bool(b)) => a == b, + (Value::Int(a), Value::Int(b)) => a == b, + (Value::Float(a), Value::Float(b)) => a == b, + (Value::Int(a), Value::Float(b)) => (*a as f64) == *b, + (Value::Float(a), Value::Int(b)) => *a == (*b as f64), + (Value::String(a), Value::String(b)) => a == b, + // Reference equality for arrays/objects + _ => false, + } + } +} + +impl fmt::Display for Value { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.to_js_string()) + } +} + +impl PartialEq for Value { + fn eq(&self, other: &Self) -> bool { + self.strict_eq(other) + } +} diff --git a/crates/bashkit/src/zapcode_browser/vm/builtins.rs b/crates/bashkit/src/zapcode_browser/vm/builtins.rs new file mode 100644 index 000000000..3a081316f --- /dev/null +++ b/crates/bashkit/src/zapcode_browser/vm/builtins.rs @@ -0,0 +1,898 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use indexmap::IndexMap; + +use crate::zapcode_browser::error::{Result, ZapcodeError}; +use crate::zapcode_browser::value::Value; + +/// Register built-in global objects and functions. +pub fn register_globals(globals: &mut HashMap) { + // Register known globals as empty objects — method calls are intercepted by the VM + globals.insert("console".to_string(), Value::Object(IndexMap::new())); + globals.insert("JSON".to_string(), Value::Object(IndexMap::new())); + globals.insert("Object".to_string(), Value::Object(IndexMap::new())); + globals.insert("Array".to_string(), Value::Object(IndexMap::new())); + globals.insert("Promise".to_string(), Value::Object(IndexMap::new())); + + // Math gets its constants as real properties + let mut math = IndexMap::new(); + math.insert(Arc::from("PI"), Value::Float(std::f64::consts::PI)); + math.insert(Arc::from("E"), Value::Float(std::f64::consts::E)); + math.insert(Arc::from("LN2"), Value::Float(std::f64::consts::LN_2)); + math.insert(Arc::from("LN10"), Value::Float(std::f64::consts::LN_10)); + math.insert(Arc::from("LOG2E"), Value::Float(std::f64::consts::LOG2_E)); + math.insert(Arc::from("LOG10E"), Value::Float(std::f64::consts::LOG10_E)); + math.insert(Arc::from("SQRT2"), Value::Float(std::f64::consts::SQRT_2)); + math.insert( + Arc::from("SQRT1_2"), + Value::Float(1.0 / std::f64::consts::SQRT_2), + ); + globals.insert("Math".to_string(), Value::Object(math)); +} + +/// Execute a built-in method call. Returns Some(value) if handled, None if not a builtin. +pub fn call_builtin( + object: &Value, + method: &str, + args: &[Value], + _stdout: &mut String, +) -> Result> { + match object { + Value::String(s) => call_string_method(s, method, args), + Value::Array(arr) => call_array_method(arr, method, args), + _ => Ok(None), + } +} + +/// Execute a global builtin function/method like console.log, Math.floor, JSON.parse. +pub fn call_global_method( + global_name: &str, + method: &str, + args: &[Value], + stdout: &mut String, +) -> Result> { + match global_name { + "console" => call_console_method(method, args, stdout), + "Math" => call_math_method(method, args), + "JSON" => call_json_method(method, args), + "Object" => call_object_method(method, args), + "Array" => call_array_static_method(method, args), + "Promise" => call_promise_method(method, args), + _ => Ok(None), + } +} + +// ── Console ────────────────────────────────────────────────────────── + +fn call_console_method(method: &str, args: &[Value], stdout: &mut String) -> Result> { + match method { + "log" | "info" | "warn" | "error" | "debug" => { + let output: Vec = args.iter().map(|v| v.to_js_string()).collect(); + let line = output.join(" "); + stdout.push_str(&line); + stdout.push('\n'); + Ok(Some(Value::Undefined)) + } + _ => Ok(None), + } +} + +// ── Math ───────────────────────────────────────────────────────────── + +fn call_math_method(method: &str, args: &[Value]) -> Result> { + let result = match method { + "abs" => { + let n = arg_num(args, 0); + Value::Float(n.abs()) + } + "floor" => { + let n = arg_num(args, 0); + Value::Float(n.floor()) + } + "ceil" => { + let n = arg_num(args, 0); + Value::Float(n.ceil()) + } + "round" => { + let n = arg_num(args, 0); + Value::Float(n.round()) + } + "trunc" => { + let n = arg_num(args, 0); + Value::Float(n.trunc()) + } + "sqrt" => { + let n = arg_num(args, 0); + Value::Float(n.sqrt()) + } + "cbrt" => { + let n = arg_num(args, 0); + Value::Float(n.cbrt()) + } + "pow" => { + let base = arg_num(args, 0); + let exp = arg_num(args, 1); + Value::Float(base.powf(exp)) + } + "log" => { + let n = arg_num(args, 0); + Value::Float(n.ln()) + } + "log2" => { + let n = arg_num(args, 0); + Value::Float(n.log2()) + } + "log10" => { + let n = arg_num(args, 0); + Value::Float(n.log10()) + } + "exp" => { + let n = arg_num(args, 0); + Value::Float(n.exp()) + } + "sin" => { + let n = arg_num(args, 0); + Value::Float(n.sin()) + } + "cos" => { + let n = arg_num(args, 0); + Value::Float(n.cos()) + } + "tan" => { + let n = arg_num(args, 0); + Value::Float(n.tan()) + } + "asin" => { + let n = arg_num(args, 0); + Value::Float(n.asin()) + } + "acos" => { + let n = arg_num(args, 0); + Value::Float(n.acos()) + } + "atan" => { + let n = arg_num(args, 0); + Value::Float(n.atan()) + } + "atan2" => { + let y = arg_num(args, 0); + let x = arg_num(args, 1); + Value::Float(y.atan2(x)) + } + "max" => { + if args.is_empty() { + Value::Float(f64::NEG_INFINITY) + } else { + let mut max = arg_num(args, 0); + for arg in &args[1..] { + let n = arg.to_number(); + if n > max { + max = n; + } + } + Value::Float(max) + } + } + "min" => { + if args.is_empty() { + Value::Float(f64::INFINITY) + } else { + let mut min = arg_num(args, 0); + for arg in &args[1..] { + let n = arg.to_number(); + if n < min { + min = n; + } + } + Value::Float(min) + } + } + "sign" => { + let n = arg_num(args, 0); + if n > 0.0 { + Value::Float(1.0) + } else if n < 0.0 { + Value::Float(-1.0) + } else { + Value::Float(0.0) + } + } + "random" => { + // Deterministic for sandbox reproducibility — use a simple LCG + // In production this should be configurable + Value::Float(0.5) // TODO: proper PRNG + } + "PI" => Value::Float(std::f64::consts::PI), + "E" => Value::Float(std::f64::consts::E), + _ => return Ok(None), + }; + Ok(Some(result)) +} + +// ── JSON ───────────────────────────────────────────────────────────── + +fn call_json_method(method: &str, args: &[Value]) -> Result> { + match method { + "stringify" => { + let val = args.first().unwrap_or(&Value::Undefined); + let json = value_to_json(val); + Ok(Some(Value::String(Arc::from(json.as_str())))) + } + "parse" => { + let s = match args.first() { + Some(Value::String(s)) => s.to_string(), + _ => { + return Err(ZapcodeError::TypeError( + "JSON.parse requires a string argument".to_string(), + )) + } + }; + let val = json_to_value(&s)?; + Ok(Some(val)) + } + _ => Ok(None), + } +} + +fn value_to_json(val: &Value) -> String { + match val { + Value::Undefined => "undefined".to_string(), + Value::Null => "null".to_string(), + Value::Bool(b) => b.to_string(), + Value::Int(n) => n.to_string(), + Value::Float(n) => { + if n.is_nan() || n.is_infinite() { + "null".to_string() + } else { + n.to_string() + } + } + Value::String(s) => format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")), + Value::Array(arr) => { + let items: Vec = arr.iter().map(value_to_json).collect(); + format!("[{}]", items.join(",")) + } + Value::Object(map) => { + let pairs: Vec = map + .iter() + .map(|(k, v)| format!("\"{}\":{}", k, value_to_json(v))) + .collect(); + format!("{{{}}}", pairs.join(",")) + } + Value::Function(_) | Value::BuiltinMethod { .. } | Value::Generator(_) => { + "undefined".to_string() + } + } +} + +/// Maximum nesting depth for JSON parsing to prevent stack overflow. +const JSON_MAX_DEPTH: usize = 64; + +fn json_to_value(s: &str) -> Result { + json_to_value_depth(s, 0) +} + +fn json_to_value_depth(s: &str, depth: usize) -> Result { + if depth > JSON_MAX_DEPTH { + return Err(ZapcodeError::RuntimeError( + "JSON nesting depth exceeded (max 64)".to_string(), + )); + } + let s = s.trim(); + if s == "null" { + return Ok(Value::Null); + } + if s == "true" { + return Ok(Value::Bool(true)); + } + if s == "false" { + return Ok(Value::Bool(false)); + } + if s.starts_with('"') && s.ends_with('"') && s.len() >= 2 { + let inner = &s[1..s.len() - 1]; + let unescaped = inner + .replace("\\\"", "\"") + .replace("\\\\", "\\") + .replace("\\n", "\n") + .replace("\\t", "\t"); + return Ok(Value::String(Arc::from(unescaped.as_str()))); + } + if let Ok(n) = s.parse::() { + return Ok(Value::Int(n)); + } + if let Ok(n) = s.parse::() { + return Ok(Value::Float(n)); + } + if s.starts_with('[') { + return parse_json_array(s, depth); + } + if s.starts_with('{') { + return parse_json_object(s, depth); + } + Err(ZapcodeError::RuntimeError(format!("Invalid JSON: {}", s))) +} + +fn parse_json_array(s: &str, depth: usize) -> Result { + let inner = &s[1..s.len() - 1].trim(); + if inner.is_empty() { + return Ok(Value::Array(Vec::new())); + } + let mut items = Vec::new(); + for part in split_json_top_level(inner) { + items.push(json_to_value_depth(part.trim(), depth + 1)?); + } + Ok(Value::Array(items)) +} + +fn parse_json_object(s: &str, depth: usize) -> Result { + let inner = &s[1..s.len() - 1].trim(); + if inner.is_empty() { + return Ok(Value::Object(IndexMap::new())); + } + let mut map = IndexMap::new(); + for part in split_json_top_level(inner) { + let part = part.trim(); + if let Some(colon_pos) = find_json_colon(part) { + let key = part[..colon_pos].trim(); + let val = part[colon_pos + 1..].trim(); + let key = if key.starts_with('"') && key.ends_with('"') { + &key[1..key.len() - 1] + } else { + key + }; + map.insert(Arc::from(key), json_to_value_depth(val, depth + 1)?); + } + } + Ok(Value::Object(map)) +} + +/// Count consecutive backslashes preceding position `i` in `bytes`. +/// A quote is escaped only if preceded by an odd number of backslashes. +fn count_preceding_backslashes(bytes: &[u8], i: usize) -> usize { + let mut count = 0; + let mut pos = i; + while pos > 0 { + pos -= 1; + if bytes[pos] == b'\\' { + count += 1; + } else { + break; + } + } + count +} + +/// Returns true if the quote at position `i` is NOT escaped. +fn is_unescaped_quote(bytes: &[u8], i: usize) -> bool { + count_preceding_backslashes(bytes, i).is_multiple_of(2) +} + +fn split_json_top_level(s: &str) -> Vec<&str> { + let mut parts = Vec::new(); + let mut depth = 0; + let mut in_string = false; + let mut start = 0; + let bytes = s.as_bytes(); + + for i in 0..bytes.len() { + match bytes[i] { + b'"' if !in_string => in_string = true, + b'"' if in_string && is_unescaped_quote(bytes, i) => in_string = false, + b'[' | b'{' if !in_string => depth += 1, + b']' | b'}' if !in_string => depth -= 1, + b',' if !in_string && depth == 0 => { + parts.push(&s[start..i]); + start = i + 1; + } + _ => {} + } + } + if start < s.len() { + parts.push(&s[start..]); + } + parts +} + +fn find_json_colon(s: &str) -> Option { + let mut in_string = false; + let bytes = s.as_bytes(); + for i in 0..bytes.len() { + match bytes[i] { + b'"' if !in_string => in_string = true, + b'"' if in_string && is_unescaped_quote(bytes, i) => in_string = false, + b':' if !in_string => return Some(i), + _ => {} + } + } + None +} + +// ── String methods ─────────────────────────────────────────────────── + +fn call_string_method(s: &Arc, method: &str, args: &[Value]) -> Result> { + let result = match method { + "length" => Value::Int(s.len() as i64), + "charAt" => { + let idx = arg_int(args, 0) as usize; + match s.chars().nth(idx) { + Some(c) => Value::String(Arc::from(c.to_string().as_str())), + None => Value::String(Arc::from("")), + } + } + "charCodeAt" => { + let idx = arg_int(args, 0) as usize; + match s.chars().nth(idx) { + Some(c) => Value::Int(c as i64), + None => Value::Float(f64::NAN), + } + } + "indexOf" => { + let search = arg_str(args, 0); + match s.find(&*search) { + Some(pos) => Value::Int(pos as i64), + None => Value::Int(-1), + } + } + "lastIndexOf" => { + let search = arg_str(args, 0); + match s.rfind(&*search) { + Some(pos) => Value::Int(pos as i64), + None => Value::Int(-1), + } + } + "includes" => { + let search = arg_str(args, 0); + Value::Bool(s.contains(&*search)) + } + "startsWith" => { + let search = arg_str(args, 0); + Value::Bool(s.starts_with(&*search)) + } + "endsWith" => { + let search = arg_str(args, 0); + Value::Bool(s.ends_with(&*search)) + } + "slice" => { + let len = s.len() as i64; + let start = normalize_index(arg_int(args, 0), len); + let end = if args.len() > 1 { + normalize_index(arg_int(args, 1), len) + } else { + len as usize + }; + if start >= end { + Value::String(Arc::from("")) + } else { + Value::String(Arc::from(&s[start..end.min(s.len())])) + } + } + "substring" => { + let len = s.len(); + let start = (arg_int(args, 0).max(0) as usize).min(len); + let end = if args.len() > 1 { + (arg_int(args, 1).max(0) as usize).min(len) + } else { + len + }; + let (start, end) = if start > end { + (end, start) + } else { + (start, end) + }; + Value::String(Arc::from(&s[start..end])) + } + "toUpperCase" => Value::String(Arc::from(s.to_uppercase().as_str())), + "toLowerCase" => Value::String(Arc::from(s.to_lowercase().as_str())), + "trim" => Value::String(Arc::from(s.trim())), + "trimStart" | "trimLeft" => Value::String(Arc::from(s.trim_start())), + "trimEnd" | "trimRight" => Value::String(Arc::from(s.trim_end())), + "repeat" => { + let count = arg_int(args, 0).max(0) as usize; + let result_len = s.len().saturating_mul(count); + if result_len > 10_000_000 { + return Err(ZapcodeError::AllocationLimitExceeded); + } + Value::String(Arc::from(s.repeat(count).as_str())) + } + "padStart" => { + let target_len = arg_int(args, 0).max(0) as usize; + let pad = if args.len() > 1 { + arg_str(args, 1) + } else { + " ".to_string() + }; + let current_len = s.len(); + if current_len >= target_len { + Value::String(s.clone()) + } else { + let pad_len = target_len - current_len; + let padding: String = pad.chars().cycle().take(pad_len).collect(); + Value::String(Arc::from(format!("{}{}", padding, s).as_str())) + } + } + "padEnd" => { + let target_len = arg_int(args, 0).max(0) as usize; + let pad = if args.len() > 1 { + arg_str(args, 1) + } else { + " ".to_string() + }; + let current_len = s.len(); + if current_len >= target_len { + Value::String(s.clone()) + } else { + let pad_len = target_len - current_len; + let padding: String = pad.chars().cycle().take(pad_len).collect(); + Value::String(Arc::from(format!("{}{}", s, padding).as_str())) + } + } + "split" => { + let separator = arg_str(args, 0); + let parts: Vec = if separator.is_empty() { + s.chars() + .map(|c| Value::String(Arc::from(c.to_string().as_str()))) + .collect() + } else { + s.split(&*separator) + .map(|p| Value::String(Arc::from(p))) + .collect() + }; + Value::Array(parts) + } + "replace" => { + let search = arg_str(args, 0); + let replacement = arg_str(args, 1); + Value::String(Arc::from(s.replacen(&*search, &replacement, 1).as_str())) + } + "replaceAll" => { + let search = arg_str(args, 0); + let replacement = arg_str(args, 1); + Value::String(Arc::from(s.replace(&*search, &replacement).as_str())) + } + "concat" => { + let mut result = s.to_string(); + for arg in args { + result.push_str(&arg.to_js_string()); + } + Value::String(Arc::from(result.as_str())) + } + "at" => { + let idx = arg_int(args, 0); + let len = s.len() as i64; + let normalized = if idx < 0 { + (len + idx).max(0) as usize + } else { + idx as usize + }; + match s.chars().nth(normalized) { + Some(c) => Value::String(Arc::from(c.to_string().as_str())), + None => Value::Undefined, + } + } + _ => return Ok(None), + }; + Ok(Some(result)) +} + +// ── Array methods ──────────────────────────────────────────────────── + +fn call_array_method(arr: &[Value], method: &str, args: &[Value]) -> Result> { + let result = match method { + "length" => Value::Int(arr.len() as i64), + "indexOf" => { + let search = args.first().unwrap_or(&Value::Undefined); + let pos = arr.iter().position(|v| v.strict_eq(search)); + Value::Int(pos.map(|p| p as i64).unwrap_or(-1)) + } + "lastIndexOf" => { + let search = args.first().unwrap_or(&Value::Undefined); + let pos = arr.iter().rposition(|v| v.strict_eq(search)); + Value::Int(pos.map(|p| p as i64).unwrap_or(-1)) + } + "includes" => { + let search = args.first().unwrap_or(&Value::Undefined); + Value::Bool(arr.iter().any(|v| v.strict_eq(search))) + } + "join" => { + let sep = if args.is_empty() { + ",".to_string() + } else { + arg_str(args, 0) + }; + let joined: Vec = arr.iter().map(|v| v.to_js_string()).collect(); + Value::String(Arc::from(joined.join(&sep).as_str())) + } + "slice" => { + let len = arr.len() as i64; + let start = normalize_index(arg_int(args, 0), len); + let end = if args.len() > 1 { + normalize_index(arg_int(args, 1), len) + } else { + len as usize + }; + if start >= end { + Value::Array(Vec::new()) + } else { + Value::Array(arr[start..end.min(arr.len())].to_vec()) + } + } + "concat" => { + let mut result = arr.to_vec(); + for arg in args { + match arg { + Value::Array(other) => result.extend_from_slice(other), + other => result.push(other.clone()), + } + } + Value::Array(result) + } + "reverse" => { + let mut result = arr.to_vec(); + result.reverse(); + Value::Array(result) + } + "flat" => { + let mut result = Vec::new(); + for item in arr { + match item { + Value::Array(inner) => result.extend_from_slice(inner), + other => result.push(other.clone()), + } + } + Value::Array(result) + } + "at" => { + let idx = arg_int(args, 0); + let len = arr.len() as i64; + let normalized = if idx < 0 { + (len + idx).max(0) as usize + } else { + idx as usize + }; + arr.get(normalized).cloned().unwrap_or(Value::Undefined) + } + "fill" => { + let fill_val = args.first().unwrap_or(&Value::Undefined); + let len = arr.len(); + let start = if args.len() > 1 { + normalize_index(arg_int(args, 1), len as i64) + } else { + 0 + }; + let end = if args.len() > 2 { + normalize_index(arg_int(args, 2), len as i64) + } else { + len + }; + let mut result = arr.to_vec(); + for item in result.iter_mut().take(end.min(len)).skip(start) { + *item = fill_val.clone(); + } + Value::Array(result) + } + "push" => { + let new_len = (arr.len() + args.len()) as i64; + Value::Int(new_len) + } + "pop" => arr.last().cloned().unwrap_or(Value::Undefined), + "shift" => arr.first().cloned().unwrap_or(Value::Undefined), + "unshift" => { + let new_len = (arr.len() + args.len()) as i64; + Value::Int(new_len) + } + "splice" => { + let len = arr.len() as i64; + let raw_start = if args.is_empty() { 0 } else { arg_int(args, 0) }; + let start = if raw_start < 0 { + (len + raw_start).max(0) as usize + } else { + (raw_start as usize).min(arr.len()) + }; + let delete_count = if args.len() > 1 { + (arg_int(args, 1).max(0) as usize).min(arr.len() - start) + } else { + arr.len() - start + }; + let deleted: Vec = arr[start..start + delete_count].to_vec(); + Value::Array(deleted) + } + "every" | "some" | "map" | "filter" | "reduce" | "forEach" | "find" | "findIndex" + | "sort" | "flatMap" => { + // These require function callbacks — handled in VM dispatch + return Ok(None); + } + _ => return Ok(None), + }; + Ok(Some(result)) +} + +// ── Object static methods ──────────────────────────────────────────── + +fn call_object_method(method: &str, args: &[Value]) -> Result> { + match method { + "keys" => { + let obj = args.first().unwrap_or(&Value::Undefined); + match obj { + Value::Object(map) => { + let keys: Vec = map.keys().map(|k| Value::String(k.clone())).collect(); + Ok(Some(Value::Array(keys))) + } + _ => Ok(Some(Value::Array(Vec::new()))), + } + } + "values" => { + let obj = args.first().unwrap_or(&Value::Undefined); + match obj { + Value::Object(map) => { + let values: Vec = map.values().cloned().collect(); + Ok(Some(Value::Array(values))) + } + _ => Ok(Some(Value::Array(Vec::new()))), + } + } + "entries" => { + let obj = args.first().unwrap_or(&Value::Undefined); + match obj { + Value::Object(map) => { + let entries: Vec = map + .iter() + .map(|(k, v)| Value::Array(vec![Value::String(k.clone()), v.clone()])) + .collect(); + Ok(Some(Value::Array(entries))) + } + _ => Ok(Some(Value::Array(Vec::new()))), + } + } + "assign" => { + let mut target = match args.first() { + Some(Value::Object(map)) => map.clone(), + _ => IndexMap::new(), + }; + for src in args.iter().skip(1) { + if let Value::Object(map) = src { + for (k, v) in map { + target.insert(k.clone(), v.clone()); + } + } + } + Ok(Some(Value::Object(target))) + } + "freeze" | "seal" => { + // No-op in sandbox — return object as-is + Ok(args.first().cloned()) + } + _ => Ok(None), + } +} + +fn call_array_static_method(method: &str, args: &[Value]) -> Result> { + match method { + "isArray" => { + let val = args.first().unwrap_or(&Value::Undefined); + Ok(Some(Value::Bool(matches!(val, Value::Array(_))))) + } + "from" => { + let val = args.first().unwrap_or(&Value::Undefined); + match val { + Value::Array(arr) => Ok(Some(Value::Array(arr.clone()))), + Value::String(s) => { + let chars: Vec = s + .chars() + .map(|c| Value::String(Arc::from(c.to_string().as_str()))) + .collect(); + Ok(Some(Value::Array(chars))) + } + _ => Ok(Some(Value::Array(Vec::new()))), + } + } + "of" => Ok(Some(Value::Array(args.to_vec()))), + _ => Ok(None), + } +} + +// ── Promise ────────────────────────────────────────────────────────── + +fn call_promise_method(method: &str, args: &[Value]) -> Result> { + match method { + "resolve" => { + let val = args.first().cloned().unwrap_or(Value::Undefined); + // If the value is already a promise, return it as-is + if is_promise(&val) { + return Ok(Some(val)); + } + let mut obj = IndexMap::new(); + obj.insert(Arc::from("__promise__"), Value::Bool(true)); + obj.insert(Arc::from("status"), Value::String(Arc::from("resolved"))); + obj.insert(Arc::from("value"), val); + Ok(Some(Value::Object(obj))) + } + "reject" => { + let reason = args.first().cloned().unwrap_or(Value::Undefined); + let mut obj = IndexMap::new(); + obj.insert(Arc::from("__promise__"), Value::Bool(true)); + obj.insert(Arc::from("status"), Value::String(Arc::from("rejected"))); + obj.insert(Arc::from("reason"), reason); + Ok(Some(Value::Object(obj))) + } + "all" => { + // Basic Promise.all: takes an array of resolved promises and returns + // a resolved promise with an array of their values. + let arr = match args.first() { + Some(Value::Array(arr)) => arr.clone(), + _ => Vec::new(), + }; + let mut results = Vec::with_capacity(arr.len()); + for item in &arr { + if is_promise(item) { + if let Value::Object(map) = item { + if let Some(Value::String(status)) = map.get("status") { + if status.as_ref() == "rejected" { + // Promise.all rejects with the first rejection reason + return Ok(Some(item.clone())); + } + } + results.push(map.get("value").cloned().unwrap_or(Value::Undefined)); + } + } else { + results.push(item.clone()); + } + } + let mut obj = IndexMap::new(); + obj.insert(Arc::from("__promise__"), Value::Bool(true)); + obj.insert(Arc::from("status"), Value::String(Arc::from("resolved"))); + obj.insert(Arc::from("value"), Value::Array(results)); + Ok(Some(Value::Object(obj))) + } + _ => Ok(None), + } +} + +/// Check if a value is a promise object (has __promise__: true). +pub fn is_promise(val: &Value) -> bool { + if let Value::Object(map) = val { + matches!(map.get("__promise__"), Some(Value::Bool(true))) + } else { + false + } +} + +/// Create a resolved promise wrapping the given value. +pub fn make_resolved_promise(val: Value) -> Value { + // If the value is already a promise, return it as-is (thenable unwrapping) + if is_promise(&val) { + return val; + } + let mut obj = IndexMap::new(); + obj.insert(Arc::from("__promise__"), Value::Bool(true)); + obj.insert(Arc::from("status"), Value::String(Arc::from("resolved"))); + obj.insert(Arc::from("value"), val); + Value::Object(obj) +} + +// ── Helpers ────────────────────────────────────────────────────────── + +fn arg_num(args: &[Value], idx: usize) -> f64 { + args.get(idx).map(|v| v.to_number()).unwrap_or(f64::NAN) +} + +fn arg_int(args: &[Value], idx: usize) -> i64 { + args.get(idx) + .map(|v| match v { + Value::Int(n) => *n, + other => other.to_number() as i64, + }) + .unwrap_or(0) +} + +fn arg_str(args: &[Value], idx: usize) -> String { + args.get(idx).map(|v| v.to_js_string()).unwrap_or_default() +} + +fn normalize_index(idx: i64, len: i64) -> usize { + if idx < 0 { + (len + idx).max(0) as usize + } else { + idx as usize + } +} diff --git a/crates/bashkit/src/zapcode_browser/vm/mod.rs b/crates/bashkit/src/zapcode_browser/vm/mod.rs new file mode 100644 index 000000000..a51deb9dc --- /dev/null +++ b/crates/bashkit/src/zapcode_browser/vm/mod.rs @@ -0,0 +1,2648 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use indexmap::IndexMap; + +use crate::zapcode_browser::compiler::instruction::{Constant, Instruction}; +use crate::zapcode_browser::compiler::CompiledProgram; +use crate::zapcode_browser::error::{Result, ZapcodeError}; +use crate::zapcode_browser::sandbox::{ResourceLimits, ResourceTracker}; +use crate::zapcode_browser::snapshot::ZapcodeSnapshot; +use crate::zapcode_browser::trace::{ExecutionTrace, SpanBuilder, TraceStatus}; +use crate::zapcode_browser::value::{Closure, FunctionId, GeneratorObject, SuspendedFrame, Value}; + +mod builtins; + +/// The result of VM execution. +#[derive(Debug)] +pub enum VmState { + Complete(Value), + Suspended { + function_name: String, + args: Vec, + snapshot: ZapcodeSnapshot, + }, +} + +/// Tracks where a method receiver originated so that mutations to `this` +/// inside the method can be written back to the source variable. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub(crate) enum ReceiverSource { + /// The receiver was loaded from a global variable with the given name. + Global(String), + /// The receiver was loaded from a local variable at the given slot index + /// in the frame at the given depth (index into `self.frames`). + Local { frame_index: usize, slot: usize }, +} + +/// A call frame in the VM stack. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub(crate) struct CallFrame { + pub(crate) func_index: Option, + pub(crate) ip: usize, + pub(crate) locals: Vec, + pub(crate) stack_base: usize, + /// The `this` value for method/constructor calls. + pub(crate) this_value: Option, + /// Where the method receiver came from, so we can write back mutations. + pub(crate) receiver_source: Option, +} + +/// A continuation for array callback methods that may suspend (e.g., `.map()` with async callbacks). +/// Instead of running callbacks in a Rust for-loop (which can't be suspended), the continuation +/// tracks progress so the main `execute()` loop can drive iteration one callback at a time. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub(crate) enum Continuation { + /// Collecting `.map()` results element-by-element. + ArrayMap { + callback: Value, + source: Vec, + results: Vec, + next_index: usize, + /// Frame depth of the caller — the continuation fires when + /// we return to this depth AND the callback's frame has been popped. + caller_frame_depth: usize, + /// The frame index of the currently-executing callback. Only when + /// this specific frame is popped does the continuation advance. + callback_frame_index: usize, + }, + /// Collecting `.forEach()` calls element-by-element. + ArrayForEach { + callback: Value, + source: Vec, + next_index: usize, + caller_frame_depth: usize, + callback_frame_index: usize, + }, +} + +/// The Zapcode VM. +pub struct Vm { + pub(crate) program: CompiledProgram, + pub(crate) stack: Vec, + pub(crate) frames: Vec, + pub(crate) globals: HashMap, + pub(crate) stdout: String, + pub(crate) limits: ResourceLimits, + pub(crate) tracker: ResourceTracker, + pub(crate) external_functions: HashSet, + pub(crate) try_stack: Vec, + /// Active continuations for array callback methods that may suspend. + pub(crate) continuations: Vec, + /// The last object a property was accessed on — used for method dispatch. + last_receiver: Option, + /// Where the last receiver came from — used to write back `this` mutations. + last_receiver_source: Option, + /// The name of the last global loaded — used to identify known globals. + last_global_name: Option, + /// Tracks the source of the most recent Load instruction for receiver tracking. + last_load_source: Option, + /// Counter for assigning unique generator IDs. + next_generator_id: u64, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub(crate) struct TryInfo { + pub(crate) catch_ip: usize, + pub(crate) frame_depth: usize, + pub(crate) stack_depth: usize, +} + +impl Vm { + fn new( + program: CompiledProgram, + limits: ResourceLimits, + external_functions: HashSet, + ) -> Self { + let mut globals = HashMap::new(); + + // Register built-in globals + builtins::register_globals(&mut globals); + + Self { + program, + stack: Vec::new(), + frames: Vec::new(), + globals, + stdout: String::new(), + limits, + tracker: ResourceTracker::default(), + external_functions, + try_stack: Vec::new(), + continuations: Vec::new(), + last_receiver: None, + last_receiver_source: None, + last_global_name: None, + last_load_source: None, + next_generator_id: 0, + } + } + + /// Names of all builtin globals registered by `register_globals`. + pub(crate) const BUILTIN_GLOBAL_NAMES: &'static [&'static str] = + &["console", "JSON", "Object", "Array", "Math", "Promise"]; + + /// Restore a VM from snapshot state and continue execution. + /// Builtins are re-registered after restoring user globals. + /// The return_value is pushed onto the stack (result of the external call). + #[allow(clippy::too_many_arguments)] + pub(crate) fn from_snapshot( + program: CompiledProgram, + stack: Vec, + frames: Vec, + user_globals: HashMap, + try_stack: Vec, + continuations: Vec, + stdout: String, + limits: ResourceLimits, + external_functions: HashSet, + ) -> Self { + let mut globals = HashMap::new(); + // Re-register builtins first + builtins::register_globals(&mut globals); + // Then overlay user globals (user globals take precedence if names collide) + for (k, v) in user_globals { + globals.insert(k, v); + } + + Self { + program, + stack, + frames, + globals, + stdout, + limits, + tracker: ResourceTracker::default(), + external_functions, + try_stack, + continuations, + last_receiver: None, + last_receiver_source: None, + last_global_name: None, + last_load_source: None, + next_generator_id: 0, + } + } + + /// Resume execution after a snapshot restore. The return value from + /// the external function should already be pushed onto the stack. + pub(crate) fn resume_execution(&mut self) -> Result { + self.tracker.start(); + self.execute() + } + + fn push(&mut self, value: Value) -> Result<()> { + self.tracker.track_allocation(&self.limits)?; + self.stack.push(value); + Ok(()) + } + + fn pop(&mut self) -> Result { + self.stack + .pop() + .ok_or_else(|| ZapcodeError::RuntimeError("stack underflow".to_string())) + } + + fn peek(&self) -> Result<&Value> { + self.stack + .last() + .ok_or_else(|| ZapcodeError::RuntimeError("stack underflow".to_string())) + } + + fn current_frame(&self) -> &CallFrame { + // Frames are always non-empty during execution (run() pushes the initial frame). + // This is an internal invariant, not reachable by guest code. + self.frames.last().expect("internal error: no active frame") + } + + fn current_frame_mut(&mut self) -> &mut CallFrame { + self.frames + .last_mut() + .expect("internal error: no active frame") + } + + #[allow(dead_code)] + fn instructions(&self) -> &[Instruction] { + match self.current_frame().func_index { + Some(idx) => &self.program.functions[idx].instructions, + None => &self.program.instructions, + } + } + + /// Build the locals vec by binding `args` to the function's declared `params`. + /// Handles positional, rest, and default-value patterns. + fn bind_params(params: &[ParamPattern], args: &[Value], local_count: usize) -> Vec { + let mut locals = Vec::with_capacity(local_count); + for (i, param) in params.iter().enumerate() { + match param { + ParamPattern::Ident(_) => { + locals.push(args.get(i).cloned().unwrap_or(Value::Undefined)); + } + ParamPattern::Rest(_) => { + let rest: Vec = args[i..].to_vec(); + locals.push(Value::Array(rest)); + } + ParamPattern::DefaultValue { .. } => { + let val = args.get(i).cloned().unwrap_or(Value::Undefined); + // Keep Undefined so the compiler-emitted default init can fire + locals.push(val); + } + _ => { + locals.push(args.get(i).cloned().unwrap_or(Value::Undefined)); + } + } + } + locals + } + + /// Common setup for calling a closure: inject captures, bind params, push frame. + fn push_call_frame( + &mut self, + closure: &Closure, + args: &[Value], + this_value: Option, + ) -> Result<()> { + self.tracker.push_frame(); + self.tracker.check_stack(&self.limits)?; + + // Inject captured variables as globals + for (name, val) in &closure.captured { + if !self.globals.contains_key(name) { + self.globals.insert(name.clone(), val.clone()); + } + } + + let func = &self.program.functions[closure.func_id.0]; + let locals = Self::bind_params(&func.params, args, func.local_count); + + // If this is a method call (has this_value from a receiver), transfer + // the receiver source so we can write back mutations on return. + let receiver_source = if this_value.is_some() { + self.last_receiver_source.take() + } else { + self.last_receiver_source = None; + None + }; + + self.frames.push(CallFrame { + func_index: Some(closure.func_id.0), + ip: 0, + locals, + stack_base: self.stack.len(), + this_value, + receiver_source, + }); + Ok(()) + } + + fn run(&mut self) -> Result { + self.tracker.start(); + + // Set up top-level frame + self.frames.push(CallFrame { + func_index: None, + ip: 0, + locals: Vec::new(), + stack_base: 0, + this_value: None, + receiver_source: None, + }); + + self.execute() + } + + fn execute(&mut self) -> Result { + loop { + // Resource checks + self.tracker.check_time(&self.limits)?; + + let frame = self.frames.last().unwrap(); + let instructions = match frame.func_index { + Some(idx) => &self.program.functions[idx].instructions, + None => &self.program.instructions, + }; + + if frame.ip >= instructions.len() { + // End of function/program + if self.frames.len() <= 1 { + // Top-level: return last value on stack or undefined + let result = if self.stack.is_empty() { + Value::Undefined + } else { + self.stack.pop().unwrap_or(Value::Undefined) + }; + return Ok(VmState::Complete(result)); + } else { + // Return from function + let frame = self.frames.pop().unwrap(); + self.tracker.pop_frame(); + // If this was a constructor, return `this` + if let Some(this_val) = frame.this_value { + self.stack.truncate(frame.stack_base); + self.push(this_val)?; + } else { + self.push(Value::Undefined)?; + } + // Check if a continuation callback just completed + self.process_continuation()?; + continue; + } + } + + let instr = instructions[frame.ip].clone(); + let result = self.dispatch(instr); + + match result { + Ok(Some(state)) => return Ok(state), + Ok(None) => { + // After dispatch, check if a continuation callback returned + // (via Return instruction or ip overflow) + if self.process_continuation()? { + continue; + } + } + Err(err) => { + // Try to catch the error + if let Some(try_info) = self.try_stack.pop() { + // Unwind to catch block + while self.frames.len() > try_info.frame_depth { + self.frames.pop(); + self.tracker.pop_frame(); + } + self.stack.truncate(try_info.stack_depth); + + // Push error value + let error_val = Value::String(Arc::from(err.to_string())); + self.push(error_val)?; + + // Jump to catch + self.current_frame_mut().ip = try_info.catch_ip; + } else { + return Err(err); + } + } + } + } + } + + /// Process the top continuation if the current frame depth indicates a callback + /// has returned. Returns `true` if a continuation was processed (caller should + /// `continue` the execute loop). + fn process_continuation(&mut self) -> Result { + let cont = match self.continuations.last() { + Some(c) => c, + None => return Ok(false), + }; + + // Check if the callback's specific frame has been popped — only then + // has the callback returned. This avoids false triggers when inner + // helper functions return to the same depth. + let (callback_frame_index, caller_frame_depth) = match cont { + Continuation::ArrayMap { + callback_frame_index, + caller_frame_depth, + .. + } => (*callback_frame_index, *caller_frame_depth), + Continuation::ArrayForEach { + callback_frame_index, + caller_frame_depth, + .. + } => (*callback_frame_index, *caller_frame_depth), + }; + + // The callback frame is still active — not done yet + if self.frames.len() > callback_frame_index { + return Ok(false); + } + + // Guard against stale continuations on stack unwinds — we must be + // back at the original caller's frame depth. + if self.frames.len() != caller_frame_depth { + return Ok(false); + } + + // The callback just returned — collect its result from the stack. + // The compiler always emits PushUndefined+Return for implicit returns, + // so an empty stack here indicates a VM bug. + let callback_result = self.pop()?; + + // Unwrap internal promise values: async callbacks return + // {__promise__: true, status: "resolved", value: X} or {status: "rejected", ...}. + // Only unwrap objects with the __promise__ marker to avoid mangling user objects. + let callback_result = if let Value::Object(ref map) = callback_result { + if !matches!(map.get("__promise__"), Some(Value::Bool(true))) { + // Not an internal promise — leave untouched + callback_result + } else { + match map.get("status") { + Some(Value::String(s)) if s.as_ref() == "resolved" => { + map.get("value").cloned().unwrap_or(Value::Undefined) + } + Some(Value::String(s)) if s.as_ref() == "rejected" => { + let reason = map.get("reason").cloned().unwrap_or(Value::Undefined); + // Clean up the continuation before returning error + self.continuations.pop(); + return Err(ZapcodeError::RuntimeError(format!( + "Unhandled promise rejection: {}", + reason.to_js_string() + ))); + } + _ => callback_result, + } + } + } else { + callback_result + }; + + // Pop the continuation, take ownership to avoid cloning results + let cont = self.continuations.pop().unwrap(); + + match cont { + Continuation::ArrayMap { + callback, + source, + mut results, + next_index, + caller_frame_depth, + .. + } => { + results.push(callback_result); + let next = next_index + 1; + + if next < source.len() { + // Set up next callback call + let item = source[next].clone(); + let closure = match &callback { + Value::Function(c) => c.clone(), + _ => unreachable!("callback validated at start"), + }; + self.push_call_frame(&closure, &[item, Value::Int(next as i64)], None)?; + let new_frame_index = self.frames.len() - 1; + // Push updated continuation back + self.continuations.push(Continuation::ArrayMap { + callback, + source, + results, + next_index: next, + caller_frame_depth, + callback_frame_index: new_frame_index, + }); + Ok(true) + } else { + // All done — push final array, no clone needed + self.push(Value::Array(results))?; + Ok(true) + } + } + Continuation::ArrayForEach { + callback, + source, + next_index, + caller_frame_depth, + .. + } => { + let next = next_index + 1; + + if next < source.len() { + let item = source[next].clone(); + let closure = match &callback { + Value::Function(c) => c.clone(), + _ => unreachable!("callback validated at start"), + }; + self.push_call_frame(&closure, &[item, Value::Int(next as i64)], None)?; + let new_frame_index = self.frames.len() - 1; + self.continuations.push(Continuation::ArrayForEach { + callback, + source, + next_index: next, + caller_frame_depth, + callback_frame_index: new_frame_index, + }); + Ok(true) + } else { + self.push(Value::Undefined)?; + Ok(true) + } + } + } + } + + /// Call a function value with the given arguments and run it to completion. + /// Returns the function's return value. + fn call_function_internal(&mut self, callee: &Value, args: Vec) -> Result { + let closure = match callee { + Value::Function(c) => c.clone(), + other => { + return Err(ZapcodeError::TypeError(format!( + "{} is not a function", + other.to_js_string() + ))); + } + }; + + let target_frame_depth = self.frames.len(); + self.push_call_frame(&closure, &args, None)?; + + // Run until the new frame returns + loop { + self.tracker.check_time(&self.limits)?; + + let frame = self.frames.last().unwrap(); + let instructions = match frame.func_index { + Some(idx) => &self.program.functions[idx].instructions, + None => &self.program.instructions, + }; + + if frame.ip >= instructions.len() { + // End of function without explicit return + if self.frames.len() > target_frame_depth + 1 { + // Inner function ended, pop and continue + self.frames.pop(); + self.tracker.pop_frame(); + self.push(Value::Undefined)?; + continue; + } else { + // Our target function ended + self.frames.pop(); + self.tracker.pop_frame(); + return Ok(Value::Undefined); + } + } + + let instr = instructions[frame.ip].clone(); + let result = self.dispatch(instr); + + match result { + Ok(Some(VmState::Complete(val))) => { + // A return happened that completed the top-level program. + // This shouldn't happen inside a callback but handle gracefully. + return Ok(val); + } + Ok(Some(VmState::Suspended { .. })) => { + return Err(ZapcodeError::RuntimeError( + "cannot suspend inside array callback".to_string(), + )); + } + Ok(None) => { + // Check if the frame was popped by a Return instruction + if self.frames.len() == target_frame_depth { + // The function returned; return value is on the stack + return Ok(self.pop().unwrap_or(Value::Undefined)); + } + } + Err(err) => { + // Try to catch the error within the callback + if let Some(try_info) = self.try_stack.pop() { + while self.frames.len() > try_info.frame_depth { + self.frames.pop(); + self.tracker.pop_frame(); + } + self.stack.truncate(try_info.stack_depth); + let error_val = Value::String(Arc::from(err.to_string())); + self.push(error_val)?; + self.current_frame_mut().ip = try_info.catch_ip; + } else { + return Err(err); + } + } + } + } + } + + /// Call a callback for each array element. Passes (item, index) — the full + /// array reference (3rd JS argument) is only built lazily if the callback + /// actually uses 3+ params, avoiding O(n²) cloning. + fn call_element_callback( + &mut self, + callback: &Value, + item: &Value, + index: usize, + ) -> Result { + self.call_function_internal(callback, vec![item.clone(), Value::Int(index as i64)]) + } + + /// Check if a callback value is an async function that might suspend. + fn is_async_callback(&self, callback: &Value) -> bool { + if let Value::Function(closure) = callback { + if let Some(func) = self.program.functions.get(closure.func_id.0) { + return func.is_async; + } + } + false + } + + /// Start a continuation-based `.map()` call: push the continuation and set up + /// the first callback invocation. Returns `None` to signal that the main + /// `execute()` loop should drive the iteration. + fn start_continuation_map( + &mut self, + callback: Value, + arr: Vec, + ) -> Result> { + if arr.is_empty() { + return Ok(Some(Value::Array(Vec::new()))); + } + + // Validate callback type BEFORE pushing continuation + let closure = match &callback { + Value::Function(c) => c.clone(), + _ => { + return Err(ZapcodeError::TypeError( + "map callback is not a function".to_string(), + )) + } + }; + + let caller_frame_depth = self.frames.len(); + let first_item = arr[0].clone(); + + self.push_call_frame(&closure, &[first_item, Value::Int(0)], None)?; + let callback_frame_index = self.frames.len() - 1; + + self.continuations.push(Continuation::ArrayMap { + callback, + source: arr, + results: Vec::new(), + next_index: 0, + caller_frame_depth, + callback_frame_index, + }); + + Ok(None) // Signal: continuation in progress + } + + /// Start a continuation-based `.forEach()` call. + fn start_continuation_foreach( + &mut self, + callback: Value, + arr: Vec, + ) -> Result> { + if arr.is_empty() { + return Ok(Some(Value::Undefined)); + } + + // Validate callback type BEFORE pushing continuation + let closure = match &callback { + Value::Function(c) => c.clone(), + _ => { + return Err(ZapcodeError::TypeError( + "forEach callback is not a function".to_string(), + )) + } + }; + + let caller_frame_depth = self.frames.len(); + let first_item = arr[0].clone(); + + self.push_call_frame(&closure, &[first_item, Value::Int(0)], None)?; + let callback_frame_index = self.frames.len() - 1; + + self.continuations.push(Continuation::ArrayForEach { + callback, + source: arr, + next_index: 0, + caller_frame_depth, + callback_frame_index, + }); + + Ok(None) + } + + /// Execute an array callback method (map, filter, reduce, forEach, etc.) + /// Returns `Ok(Some(value))` if the method completed synchronously, or + /// `Ok(None)` if a continuation was started (async callback). + fn execute_array_callback_method( + &mut self, + arr: Vec, + method: &str, + all_args: Vec, + ) -> Result> { + let callback = all_args.first().cloned().unwrap_or(Value::Undefined); + + match method { + "map" => { + // Use continuation-based execution for async callbacks + if self.is_async_callback(&callback) { + return self.start_continuation_map(callback, arr); + } + let mut result = Vec::with_capacity(arr.len()); + for (i, item) in arr.iter().enumerate() { + result.push(self.call_element_callback(&callback, item, i)?); + } + Ok(Some(Value::Array(result))) + } + "filter" | "find" | "findIndex" | "every" | "some" | "reduce" | "sort" | "flatMap" => { + // Async callbacks are not supported for these methods + if self.is_async_callback(&callback) { + return Err(ZapcodeError::RuntimeError(format!( + ".{}() does not support async callbacks — use .map() or a for-of loop instead", + method + ))); + } + match method { + "filter" => { + let mut result = Vec::new(); + for (i, item) in arr.iter().enumerate() { + if self.call_element_callback(&callback, item, i)?.is_truthy() { + result.push(item.clone()); + } + } + Ok(Some(Value::Array(result))) + } + "find" => { + for (i, item) in arr.iter().enumerate() { + if self.call_element_callback(&callback, item, i)?.is_truthy() { + return Ok(Some(item.clone())); + } + } + Ok(Some(Value::Undefined)) + } + "findIndex" => { + for (i, item) in arr.iter().enumerate() { + if self.call_element_callback(&callback, item, i)?.is_truthy() { + return Ok(Some(Value::Int(i as i64))); + } + } + Ok(Some(Value::Int(-1))) + } + "every" => { + for (i, item) in arr.iter().enumerate() { + if !self.call_element_callback(&callback, item, i)?.is_truthy() { + return Ok(Some(Value::Bool(false))); + } + } + Ok(Some(Value::Bool(true))) + } + "some" => { + for (i, item) in arr.iter().enumerate() { + if self.call_element_callback(&callback, item, i)?.is_truthy() { + return Ok(Some(Value::Bool(true))); + } + } + Ok(Some(Value::Bool(false))) + } + "reduce" => { + let mut acc = match all_args.get(1).cloned() { + Some(init) => Some(init), + None if !arr.is_empty() => Some(arr[0].clone()), + None => { + return Err(ZapcodeError::TypeError( + "Reduce of empty array with no initial value".to_string(), + )); + } + }; + let start = if all_args.get(1).is_some() { 0 } else { 1 }; + for (i, item) in arr.iter().enumerate().skip(start) { + acc = Some(self.call_function_internal( + &callback, + vec![acc.unwrap(), item.clone(), Value::Int(i as i64)], + )?); + } + Ok(Some(acc.unwrap_or(Value::Undefined))) + } + "sort" => { + let mut result = arr; + if matches!(callback, Value::Function(_)) { + let len = result.len(); + for i in 1..len { + let mut j = i; + while j > 0 { + let cmp = self + .call_function_internal( + &callback, + vec![result[j - 1].clone(), result[j].clone()], + )? + .to_number(); + if cmp > 0.0 { + result.swap(j - 1, j); + j -= 1; + } else { + break; + } + } + } + } else { + result.sort_by_key(|a| a.to_js_string()); + } + Ok(Some(Value::Array(result))) + } + "flatMap" => { + let mut result = Vec::new(); + for (i, item) in arr.iter().enumerate() { + match self.call_element_callback(&callback, item, i)? { + Value::Array(inner) => result.extend(inner), + other => result.push(other), + } + } + Ok(Some(Value::Array(result))) + } + _ => unreachable!(), + } + } + "forEach" => { + // Use continuation-based execution for async callbacks + if self.is_async_callback(&callback) { + return self.start_continuation_foreach(callback, arr); + } + for (i, item) in arr.iter().enumerate() { + self.call_element_callback(&callback, item, i)?; + } + Ok(Some(Value::Undefined)) + } + _ => Err(ZapcodeError::TypeError(format!( + "Unknown array callback method: {}", + method + ))), + } + } + + /// Execute .then(), .catch(), or .finally() on a resolved/rejected promise. + /// Synchronously invokes the callback and returns a new promise wrapping the result. + fn execute_promise_method( + &mut self, + promise: Value, + method: &str, + args: Vec, + ) -> Result> { + let (status, value, reason) = if let Value::Object(ref map) = promise { + let status = match map.get("status") { + Some(Value::String(s)) => s.to_string(), + _ => "pending".to_string(), + }; + let value = map.get("value").cloned().unwrap_or(Value::Undefined); + let reason = map.get("reason").cloned().unwrap_or(Value::Undefined); + (status, value, reason) + } else { + return Ok(None); + }; + + let on_fulfilled = args.first().cloned().unwrap_or(Value::Undefined); + let on_rejected = args.get(1).cloned().unwrap_or(Value::Undefined); + + match method { + "then" => { + if status == "resolved" { + if matches!(on_fulfilled, Value::Function(_)) { + let result = self.call_function_internal(&on_fulfilled, vec![value])?; + Ok(Some(builtins::make_resolved_promise(result))) + } else { + // No callback — pass through the promise + Ok(Some(promise)) + } + } else if status == "rejected" { + if matches!(on_rejected, Value::Function(_)) { + let result = self.call_function_internal(&on_rejected, vec![reason])?; + Ok(Some(builtins::make_resolved_promise(result))) + } else { + // No onRejected — pass through the rejection + Ok(Some(promise)) + } + } else { + Ok(Some(promise)) + } + } + "catch" => { + if status == "rejected" { + let handler = args.first().cloned().unwrap_or(Value::Undefined); + if matches!(handler, Value::Function(_)) { + let result = self.call_function_internal(&handler, vec![reason])?; + Ok(Some(builtins::make_resolved_promise(result))) + } else { + Ok(Some(promise)) + } + } else { + // Resolved — pass through + Ok(Some(promise)) + } + } + "finally" => { + let handler = args.first().cloned().unwrap_or(Value::Undefined); + if matches!(handler, Value::Function(_)) { + // finally callback receives no arguments + self.call_function_internal(&handler, vec![])?; + } + // finally always passes through the original promise + Ok(Some(promise)) + } + _ => Ok(None), + } + } + + fn alloc_generator_id(&mut self) -> u64 { + let id = self.next_generator_id; + self.next_generator_id += 1; + id + } + + fn generator_next(&mut self, mut gen_obj: GeneratorObject, arg: Value) -> Result { + if gen_obj.done { + return Ok(self.make_iterator_result(Value::Undefined, true)); + } + for (name, val) in &gen_obj.captured { + if !self.globals.contains_key(name) { + self.globals.insert(name.clone(), val.clone()); + } + } + let func_idx = gen_obj.func_id.0; + match gen_obj.suspended.take() { + None => { + let func = &self.program.functions[func_idx]; + self.tracker.push_frame(); + let mut locals = Vec::with_capacity(func.local_count); + for param in func.params.iter() { + match param { + ParamPattern::Ident(name) => { + let val = gen_obj + .captured + .iter() + .find(|(n, _)| n == name) + .map(|(_, v)| v.clone()) + .unwrap_or(Value::Undefined); + locals.push(val); + } + ParamPattern::Rest(name) => { + let val = gen_obj + .captured + .iter() + .find(|(n, _)| n == name) + .map(|(_, v)| v.clone()) + .unwrap_or(Value::Array(Vec::new())); + locals.push(val); + } + _ => { + locals.push(Value::Undefined); + } + } + } + let stack_base = self.stack.len(); + self.frames.push(CallFrame { + func_index: Some(func_idx), + ip: 0, + locals, + stack_base, + this_value: None, + receiver_source: None, + }); + self.run_generator_until_yield_or_return(gen_obj) + } + Some(suspended) => { + self.tracker.push_frame(); + let stack_base = self.stack.len(); + for val in &suspended.stack { + self.push(val.clone())?; + } + self.push(arg)?; + self.frames.push(CallFrame { + func_index: Some(func_idx), + ip: suspended.ip, + locals: suspended.locals, + stack_base, + this_value: None, + receiver_source: None, + }); + self.run_generator_until_yield_or_return(gen_obj) + } + } + } + + /// Store generator state back into the globals registry. + /// For done generators, the key is removed to prevent unbounded growth. + fn store_generator(&mut self, gen_obj: GeneratorObject) { + let gen_key = format!("__gen_{}", gen_obj.id); + if gen_obj.done { + self.globals.remove(&gen_key); + } else { + self.globals.insert(gen_key, Value::Generator(gen_obj)); + } + } + + /// Mark a generator as done, store it, and return the final iterator result. + fn finish_generator(&mut self, mut gen_obj: GeneratorObject, value: Value) -> Value { + gen_obj.done = true; + gen_obj.suspended = None; + self.store_generator(gen_obj); + self.make_iterator_result(value, true) + } + + fn run_generator_until_yield_or_return( + &mut self, + mut gen_obj: GeneratorObject, + ) -> Result { + let target_frame_depth = self.frames.len() - 1; + loop { + self.tracker.check_time(&self.limits)?; + let frame = self.frames.last().unwrap(); + let instructions = match frame.func_index { + Some(idx) => &self.program.functions[idx].instructions, + None => &self.program.instructions, + }; + if frame.ip >= instructions.len() { + if self.frames.len() > target_frame_depth + 1 { + let frame = self.frames.pop().unwrap(); + self.tracker.pop_frame(); + if let Some(this_val) = frame.this_value { + self.stack.truncate(frame.stack_base); + self.push(this_val)?; + } else { + self.push(Value::Undefined)?; + } + continue; + } + let frame = self.frames.pop().unwrap(); + self.tracker.pop_frame(); + self.stack.truncate(frame.stack_base); + let result = self.finish_generator(gen_obj, Value::Undefined); + return Ok(result); + } + let instr = instructions[frame.ip].clone(); + if matches!(instr, Instruction::Yield) { + self.current_frame_mut().ip += 1; + let yielded_value = self.pop()?; + let frame = self.frames.pop().unwrap(); + self.tracker.pop_frame(); + let frame_stack: Vec = self.stack.drain(frame.stack_base..).collect(); + gen_obj.suspended = Some(SuspendedFrame { + ip: frame.ip, + locals: frame.locals, + stack: frame_stack, + }); + gen_obj.done = false; + self.store_generator(gen_obj); + return Ok(self.make_iterator_result(yielded_value, false)); + } + if matches!(instr, Instruction::Return) { + self.current_frame_mut().ip += 1; + let return_val = self.pop().unwrap_or(Value::Undefined); + if self.frames.len() > target_frame_depth + 1 { + let frame = self.frames.pop().unwrap(); + self.tracker.pop_frame(); + self.stack.truncate(frame.stack_base); + self.push(return_val)?; + continue; + } + let frame = self.frames.pop().unwrap(); + self.tracker.pop_frame(); + self.stack.truncate(frame.stack_base); + let result = self.finish_generator(gen_obj, return_val); + return Ok(result); + } + let result = self.dispatch(instr); + match result { + Ok(Some(VmState::Complete(val))) => return Ok(val), + Ok(Some(VmState::Suspended { .. })) => { + return Err(ZapcodeError::RuntimeError( + "cannot suspend inside a generator".to_string(), + )); + } + Ok(None) => { + if self.frames.len() == target_frame_depth { + let return_val = self.pop().unwrap_or(Value::Undefined); + let result = self.finish_generator(gen_obj, return_val); + return Ok(result); + } + } + Err(err) => { + if let Some(try_info) = self.try_stack.pop() { + while self.frames.len() > try_info.frame_depth { + self.frames.pop(); + self.tracker.pop_frame(); + } + self.stack.truncate(try_info.stack_depth); + let error_val = Value::String(Arc::from(err.to_string())); + self.push(error_val)?; + self.current_frame_mut().ip = try_info.catch_ip; + } else { + return Err(err); + } + } + } + } + } + + fn make_iterator_result(&self, value: Value, done: bool) -> Value { + let mut obj = IndexMap::new(); + obj.insert(Arc::from("value"), value); + obj.insert(Arc::from("done"), Value::Bool(done)); + Value::Object(obj) + } + + fn dispatch(&mut self, instr: Instruction) -> Result> { + self.current_frame_mut().ip += 1; + + match instr { + Instruction::Push(constant) => { + let value = match constant { + Constant::Undefined => Value::Undefined, + Constant::Null => Value::Null, + Constant::Bool(b) => Value::Bool(b), + Constant::Int(n) => Value::Int(n), + Constant::Float(n) => Value::Float(n), + Constant::String(s) => Value::String(Arc::from(s.as_str())), + }; + self.push(value)?; + } + Instruction::Pop => { + self.pop()?; + } + Instruction::Dup => { + let val = self.peek()?.clone(); + self.push(val)?; + } + Instruction::LoadLocal(idx) => { + let frame_index = self.frames.len() - 1; + let frame = self.current_frame(); + let val = frame.locals.get(idx).cloned().unwrap_or(Value::Undefined); + self.last_load_source = Some(ReceiverSource::Local { + frame_index, + slot: idx, + }); + self.push(val)?; + } + Instruction::StoreLocal(idx) => { + let val = self.pop()?; + let frame = self.current_frame_mut(); + while frame.locals.len() <= idx { + frame.locals.push(Value::Undefined); + } + frame.locals[idx] = val; + } + Instruction::LoadGlobal(name) => { + let val = self.globals.get(&name).cloned().unwrap_or(Value::Undefined); + self.last_global_name = Some(name.clone()); + // Only track receiver source for user-defined globals — builtins + // (console, Math, JSON, etc.) contain non-serializable BuiltinMethod + // values that would break snapshot serialization if written back. + if Self::BUILTIN_GLOBAL_NAMES.contains(&name.as_str()) { + self.last_load_source = None; + } else { + self.last_load_source = Some(ReceiverSource::Global(name)); + } + self.push(val)?; + } + Instruction::StoreGlobal(name) => { + let val = self.pop()?; + self.globals.insert(name, val); + } + Instruction::DeclareLocal(_) => { + let frame = self.current_frame_mut(); + frame.locals.push(Value::Undefined); + } + + // Arithmetic + Instruction::Add => { + let right = self.pop()?; + let left = self.pop()?; + let result = match (&left, &right) { + (Value::Int(a), Value::Int(b)) => match a.checked_add(*b) { + Some(r) => Value::Int(r), + None => Value::Float(*a as f64 + *b as f64), + }, + (Value::Float(a), Value::Float(b)) => Value::Float(a + b), + (Value::Int(a), Value::Float(b)) => Value::Float(*a as f64 + b), + (Value::Float(a), Value::Int(b)) => Value::Float(a + *b as f64), + (Value::String(a), _) => { + let rhs = right.to_js_string(); + let new_len = a.len().saturating_add(rhs.len()); + if new_len > 10_000_000 { + return Err(ZapcodeError::AllocationLimitExceeded); + } + let mut s = a.to_string(); + s.push_str(&rhs); + Value::String(Arc::from(s.as_str())) + } + (_, Value::String(b)) => { + let lhs = left.to_js_string(); + let new_len = lhs.len().saturating_add(b.len()); + if new_len > 10_000_000 { + return Err(ZapcodeError::AllocationLimitExceeded); + } + let mut s = lhs; + s.push_str(b); + Value::String(Arc::from(s.as_str())) + } + _ => Value::Float(left.to_number() + right.to_number()), + }; + self.push(result)?; + } + Instruction::Sub => { + let right = self.pop()?; + let left = self.pop()?; + let result = match (&left, &right) { + (Value::Int(a), Value::Int(b)) => match a.checked_sub(*b) { + Some(r) => Value::Int(r), + None => Value::Float(*a as f64 - *b as f64), + }, + _ => Value::Float(left.to_number() - right.to_number()), + }; + self.push(result)?; + } + Instruction::Mul => { + let right = self.pop()?; + let left = self.pop()?; + let result = match (&left, &right) { + (Value::Int(a), Value::Int(b)) => match a.checked_mul(*b) { + Some(r) => Value::Int(r), + None => Value::Float(*a as f64 * *b as f64), + }, + _ => Value::Float(left.to_number() * right.to_number()), + }; + self.push(result)?; + } + Instruction::Div => { + let right = self.pop()?; + let left = self.pop()?; + let result = Value::Float(left.to_number() / right.to_number()); + self.push(result)?; + } + Instruction::Rem => { + let right = self.pop()?; + let left = self.pop()?; + let result = match (&left, &right) { + (Value::Int(a), Value::Int(b)) if *b != 0 => Value::Int(a % b), + _ => Value::Float(left.to_number() % right.to_number()), + }; + self.push(result)?; + } + Instruction::Pow => { + let right = self.pop()?; + let left = self.pop()?; + let result = Value::Float(left.to_number().powf(right.to_number())); + self.push(result)?; + } + Instruction::Neg => { + let val = self.pop()?; + let result = match val { + Value::Int(n) => Value::Int(-n), + _ => Value::Float(-val.to_number()), + }; + self.push(result)?; + } + Instruction::BitNot => { + let val = self.pop()?; + let n = val.to_number() as i32; + self.push(Value::Int(!n as i64))?; + } + Instruction::BitAnd => { + let right = self.pop()?; + let left = self.pop()?; + let result = (left.to_number() as i32) & (right.to_number() as i32); + self.push(Value::Int(result as i64))?; + } + Instruction::BitOr => { + let right = self.pop()?; + let left = self.pop()?; + let result = (left.to_number() as i32) | (right.to_number() as i32); + self.push(Value::Int(result as i64))?; + } + Instruction::BitXor => { + let right = self.pop()?; + let left = self.pop()?; + let result = (left.to_number() as i32) ^ (right.to_number() as i32); + self.push(Value::Int(result as i64))?; + } + Instruction::Shl => { + let right = self.pop()?; + let left = self.pop()?; + let shift = (right.to_number() as u32) & 0x1f; + let result = (left.to_number() as i32) << shift; + self.push(Value::Int(result as i64))?; + } + Instruction::Shr => { + let right = self.pop()?; + let left = self.pop()?; + let shift = (right.to_number() as u32) & 0x1f; + let result = (left.to_number() as i32) >> shift; + self.push(Value::Int(result as i64))?; + } + Instruction::Ushr => { + let right = self.pop()?; + let left = self.pop()?; + let shift = (right.to_number() as u32) & 0x1f; + let result = (left.to_number() as u32) >> shift; + self.push(Value::Int(result as i64))?; + } + + // Comparison + Instruction::Eq | Instruction::StrictEq => { + let right = self.pop()?; + let left = self.pop()?; + self.push(Value::Bool(left.strict_eq(&right)))?; + } + Instruction::Neq | Instruction::StrictNeq => { + let right = self.pop()?; + let left = self.pop()?; + self.push(Value::Bool(!left.strict_eq(&right)))?; + } + Instruction::Lt => { + let right = self.pop()?; + let left = self.pop()?; + self.push(Value::Bool(left.to_number() < right.to_number()))?; + } + Instruction::Lte => { + let right = self.pop()?; + let left = self.pop()?; + self.push(Value::Bool(left.to_number() <= right.to_number()))?; + } + Instruction::Gt => { + let right = self.pop()?; + let left = self.pop()?; + self.push(Value::Bool(left.to_number() > right.to_number()))?; + } + Instruction::Gte => { + let right = self.pop()?; + let left = self.pop()?; + self.push(Value::Bool(left.to_number() >= right.to_number()))?; + } + + // Logical + Instruction::Not => { + let val = self.pop()?; + self.push(Value::Bool(!val.is_truthy()))?; + } + + // Objects & Arrays + Instruction::CreateArray(count) => { + self.tracker.track_allocation(&self.limits)?; + let mut arr = Vec::with_capacity(count); + for _ in 0..count { + arr.push(self.pop()?); + } + arr.reverse(); + self.push(Value::Array(arr))?; + } + Instruction::CreateObject(count) => { + self.tracker.track_allocation(&self.limits)?; + let mut obj = IndexMap::new(); + // Pop key-value pairs (or spread values) + let mut entries = Vec::new(); + for _ in 0..count { + let val = self.pop()?; + let key = self.pop()?; + entries.push((key, val)); + } + entries.reverse(); + for (key, val) in entries { + match key { + Value::String(k) => { + obj.insert(k, val); + } + _ => { + let k: Arc = Arc::from(key.to_js_string().as_str()); + obj.insert(k, val); + } + } + } + self.push(Value::Object(obj))?; + } + Instruction::GetProperty(name) => { + let obj = self.pop()?; + let result = self.get_property(&obj, &name)?; + // Store receiver for method calls + if matches!(result, Value::BuiltinMethod { .. } | Value::Function(_)) { + self.last_receiver = Some(obj); + self.last_receiver_source = self.last_load_source.take(); + } else { + self.last_receiver_source = None; + } + self.push(result)?; + } + Instruction::SetProperty(name) => { + // Stack: [value_to_store, object] with object on top + // (compile_store pushes object after the value) + let obj_val = self.pop()?; + let value = self.pop()?; + match obj_val { + Value::Object(mut obj) => { + obj.insert(Arc::from(name.as_str()), value); + // Push modified object back so compile_store can store it + self.push(Value::Object(obj))?; + } + _ => { + return Err(ZapcodeError::TypeError(format!( + "cannot set property '{}' on {}", + name, + obj_val.type_name() + ))); + } + } + } + Instruction::GetIndex => { + let index = self.pop()?; + let obj = self.pop()?; + let result = match (&obj, &index) { + (Value::Array(arr), Value::Int(i)) => { + arr.get(*i as usize).cloned().unwrap_or(Value::Undefined) + } + (Value::Array(arr), Value::Float(f)) => { + arr.get(*f as usize).cloned().unwrap_or(Value::Undefined) + } + (Value::Object(map), Value::String(key)) => { + map.get(key.as_ref()).cloned().unwrap_or(Value::Undefined) + } + (Value::Object(map), _) => { + let key: Arc = Arc::from(index.to_js_string().as_str()); + map.get(key.as_ref()).cloned().unwrap_or(Value::Undefined) + } + (Value::String(s), Value::Int(i)) => s + .chars() + .nth(*i as usize) + .map(|c| Value::String(Arc::from(c.to_string().as_str()))) + .unwrap_or(Value::Undefined), + _ => Value::Undefined, + }; + self.push(result)?; + } + Instruction::SetIndex => { + let index = self.pop()?; + let mut obj = self.pop()?; + let value = self.pop()?; + match &mut obj { + Value::Array(arr) => { + let idx = match &index { + Value::Int(i) if *i >= 0 => *i as usize, + Value::Float(f) if *f >= 0.0 && *f == (*f as usize as f64) => { + *f as usize + } + _ => { + // Negative or non-numeric index: treat as no-op (like JS) + self.push(obj)?; + return Ok(None); + } + }; + // Cap maximum sparse array growth to prevent memory exhaustion + if idx > arr.len() + 1024 { + return Err(ZapcodeError::RuntimeError(format!( + "array index {} too far beyond length {}", + idx, + arr.len() + ))); + } + while arr.len() <= idx { + arr.push(Value::Undefined); + } + arr[idx] = value; + } + Value::Object(map) => { + let key: Arc = Arc::from(index.to_js_string().as_str()); + map.insert(key, value); + } + _ => {} + } + // Push modified object back so compile_store can store it to the variable + self.push(obj)?; + } + Instruction::Spread => { + // Handled contextually in CreateArray/CreateObject + } + Instruction::In => { + let right = self.pop()?; + let left = self.pop()?; + let result = match &right { + Value::Object(map) => { + let key = left.to_js_string(); + map.contains_key(key.as_str()) + } + Value::Array(arr) => { + if let Value::Int(i) = left { + (i as usize) < arr.len() + } else { + false + } + } + _ => false, + }; + self.push(Value::Bool(result))?; + } + Instruction::InstanceOf => { + let right = self.pop()?; + let left = self.pop()?; + // Check if left's __class__ matches right's __class_name__ + let result = match (&left, &right) { + (Value::Object(instance), Value::Object(class_obj)) => { + if let (Some(inst_class), Some(class_name)) = + (instance.get("__class__"), class_obj.get("__class_name__")) + { + inst_class == class_name + } else { + false + } + } + _ => false, + }; + self.push(Value::Bool(result))?; + } + + // Functions + Instruction::CreateClosure(func_idx) => { + // Capture current scope for closure + let mut captured = Vec::new(); + // Capture all locals from all active frames using local_names + for frame in &self.frames { + let local_names = if let Some(fidx) = frame.func_index { + &self.program.functions[fidx].local_names + } else { + &self.program.local_names + }; + for (i, val) in frame.locals.iter().enumerate() { + if let Some(name) = local_names.get(i) { + captured.push((name.clone(), val.clone())); + } + } + } + // Also capture all globals that are user-defined (not builtins) + let builtins = ["console", "Math", "JSON", "Object", "Array"]; + for (name, val) in &self.globals { + if !builtins.contains(&name.as_str()) { + captured.push((name.clone(), val.clone())); + } + } + let closure = Closure { + func_id: FunctionId(func_idx), + captured, + }; + self.push(Value::Function(closure))?; + } + Instruction::Call(arg_count) => { + let mut args = Vec::with_capacity(arg_count); + for _ in 0..arg_count { + args.push(self.pop()?); + } + args.reverse(); + + let callee = self.pop()?; + match callee { + Value::Function(closure) => { + let func_idx = closure.func_id.0; + let is_generator = self.program.functions[func_idx].is_generator; + + // Generator function: create a Generator object instead of running + if is_generator { + let params = self.program.functions[func_idx].params.clone(); + let gen_id = self.alloc_generator_id(); + // Capture args as named params so generator_next can restore them + let mut captured = closure.captured.clone(); + for (i, param) in params.iter().enumerate() { + match param { + ParamPattern::Ident(name) => { + captured.push(( + name.clone(), + args.get(i).cloned().unwrap_or(Value::Undefined), + )); + } + ParamPattern::Rest(name) => { + let rest: Vec = args[i..].to_vec(); + captured.push((name.clone(), Value::Array(rest))); + } + _ => {} + } + } + let gen_obj = GeneratorObject { + id: gen_id, + func_id: closure.func_id, + captured, + suspended: None, + done: false, + }; + // Store in globals registry so we can look it up by ID later + self.globals.insert( + format!("__gen_{}", gen_id), + Value::Generator(gen_obj.clone()), + ); + self.push(Value::Generator(gen_obj))?; + self.last_receiver = None; + self.last_receiver_source = None; + } else { + let this_value = self.last_receiver.take(); + self.push_call_frame(&closure, &args, this_value)?; + } + } + Value::BuiltinMethod { + object_name, + method_name, + } => { + let receiver = self.last_receiver.take(); + let result = match object_name.as_ref() { + "__array__" => { + if let Some(Value::Array(arr)) = &receiver { + // Check if this is a callback method first + match method_name.as_ref() { + "map" | "filter" | "forEach" | "find" | "findIndex" + | "every" | "some" | "reduce" | "sort" | "flatMap" => { + match self.execute_array_callback_method( + arr.clone(), + &method_name, + args, + )? { + Some(val) => Some(val), + None => { + // Continuation started — the main execute() + // loop will drive the callbacks. Don't push + // a result; just return Ok(None). + return Ok(None); + } + } + } + _ => builtins::call_builtin( + &Value::Array(arr.clone()), + &method_name, + &args, + &mut self.stdout, + )?, + } + } else { + None + } + } + "__string__" => { + if let Some(Value::String(s)) = &receiver { + builtins::call_builtin( + &Value::String(s.clone()), + &method_name, + &args, + &mut self.stdout, + )? + } else { + None + } + } + "__generator__" => { + if let Some(Value::Generator(gen_obj)) = receiver { + match method_name.as_ref() { + "next" => { + let arg = + args.into_iter().next().unwrap_or(Value::Undefined); + // Get the latest generator state from registry. + // If the key is missing, the generator has finished + // and was cleaned up — return done immediately. + let gen_key = format!("__gen_{}", gen_obj.id); + if let Some(Value::Generator(g)) = + self.globals.remove(&gen_key) + { + let result = self.generator_next(g, arg)?; + Some(result) + } else { + Some( + self.make_iterator_result( + Value::Undefined, + true, + ), + ) + } + } + "return" => { + let val = + args.into_iter().next().unwrap_or(Value::Undefined); + let gen_key = format!("__gen_{}", gen_obj.id); + if let Some(Value::Generator(g)) = + self.globals.remove(&gen_key) + { + let result = self.finish_generator(g, val); + Some(result) + } else { + Some(self.make_iterator_result(val, true)) + } + } + _ => None, + } + } else { + None + } + } + "__promise__" => { + if let Some(promise) = receiver { + self.execute_promise_method(promise, &method_name, args)? + } else { + None + } + } + global_name => builtins::call_global_method( + global_name, + &method_name, + &args, + &mut self.stdout, + )?, + }; + match result { + Some(val) => self.push(val)?, + None => { + return Err(ZapcodeError::TypeError(format!( + "{}.{} is not a function", + object_name, method_name + ))); + } + } + } + _ => { + return Err(ZapcodeError::TypeError(format!( + "{} is not a function", + callee.to_js_string() + ))); + } + } + } + Instruction::Return => { + let return_val = self.pop().unwrap_or(Value::Undefined); + + if self.frames.len() <= 1 { + return Ok(Some(VmState::Complete(return_val))); + } + + let frame = self.frames.pop().unwrap(); + self.tracker.pop_frame(); + + // If this was a constructor frame (has this_value), return the + // updated `this` instead of the explicit return value (unless + // the constructor explicitly returns an object). + let actual_return = if let Some(ref this_val) = frame.this_value { + // Also propagate this back to parent frame (for super() calls) + if let Some(parent) = self.frames.last_mut() { + if parent.this_value.is_some() { + parent.this_value = Some(this_val.clone()); + } + } + // Write back the mutated `this` to the original variable + // that the method receiver came from. This ensures that + // value-type semantics work correctly for method calls + // that mutate `this` properties (e.g., this.count += 1). + if let Some(ref source) = frame.receiver_source { + match source { + ReceiverSource::Global(name) => { + self.globals.insert(name.clone(), this_val.clone()); + } + ReceiverSource::Local { frame_index, slot } => { + if let Some(target_frame) = self.frames.get_mut(*frame_index) { + while target_frame.locals.len() <= *slot { + target_frame.locals.push(Value::Undefined); + } + target_frame.locals[*slot] = this_val.clone(); + } + } + } + } + if matches!(return_val, Value::Undefined) { + this_val.clone() + } else { + return_val + } + } else { + return_val + }; + + self.stack.truncate(frame.stack_base); + self.push(actual_return)?; + } + Instruction::CallExternal(name, arg_count) => { + if !self.external_functions.contains(&name) { + return Err(ZapcodeError::UnknownExternalFunction(name)); + } + let mut args = Vec::with_capacity(arg_count); + for _ in 0..arg_count { + args.push(self.pop()?); + } + args.reverse(); + // Suspend execution + let snapshot = ZapcodeSnapshot::capture(self)?; + return Ok(Some(VmState::Suspended { + function_name: name, + args, + snapshot, + })); + } + + // Control flow + Instruction::Jump(target) => { + self.current_frame_mut().ip = target; + } + Instruction::JumpIfFalse(target) => { + let val = self.pop()?; + if !val.is_truthy() { + self.current_frame_mut().ip = target; + } + } + Instruction::JumpIfTrue(target) => { + let val = self.pop()?; + if val.is_truthy() { + self.current_frame_mut().ip = target; + } + } + Instruction::JumpIfNullish(target) => { + let val = self.peek()?; + if matches!(val, Value::Null | Value::Undefined) { + self.current_frame_mut().ip = target; + } + } + + // Loops + Instruction::SetupLoop => {} + Instruction::Break | Instruction::Continue => { + // These should have been compiled to jumps + } + + // Iterators + Instruction::GetIterator => { + let val = self.pop()?; + match val { + Value::Array(arr) => { + // Push an iterator object: [array, index] + let iter_obj = Value::Array(vec![Value::Array(arr), Value::Int(0)]); + self.push(iter_obj)?; + } + Value::String(s) => { + let chars: Vec = s + .chars() + .map(|c| Value::String(Arc::from(c.to_string().as_str()))) + .collect(); + let iter_obj = Value::Array(vec![Value::Array(chars), Value::Int(0)]); + self.push(iter_obj)?; + } + Value::Generator(gen_obj) => { + let iter_obj = Value::Array(vec![ + Value::String(Arc::from("__gen__")), + Value::Int(gen_obj.id as i64), + Value::Bool(false), + ]); + self.push(iter_obj)?; + } + _ => { + return Err(ZapcodeError::TypeError(format!( + "{} is not iterable", + val.type_name() + ))); + } + } + } + Instruction::IteratorNext => { + let iter = self.pop()?; + // Check for generator iterator (3-element sentinel) + if let Value::Array(ref items) = iter { + if items.len() == 3 { + if let Value::String(ref s) = items[0] { + if s.as_ref() == "__gen__" { + let gen_id = match &items[1] { + Value::Int(id) => *id as u64, + _ => { + return Err(ZapcodeError::RuntimeError( + "bad gen iter".into(), + )) + } + }; + let gen_key = format!("__gen_{}", gen_id); + let gen_obj = if let Some(Value::Generator(g)) = + self.globals.remove(&gen_key) + { + g + } else { + self.push(Value::Array(vec![ + Value::String(Arc::from("__gen__")), + Value::Int(gen_id as i64), + Value::Bool(true), + ]))?; + self.push(Value::Undefined)?; + return Ok(None); + }; + let result = self.generator_next(gen_obj, Value::Undefined)?; + if let Value::Object(ref obj) = result { + let done = obj + .get("done") + .is_some_and(|v| matches!(v, Value::Bool(true))); + let value = + obj.get("value").cloned().unwrap_or(Value::Undefined); + self.push(Value::Array(vec![ + Value::String(Arc::from("__gen__")), + Value::Int(gen_id as i64), + Value::Bool(done), + ]))?; + self.push(value)?; + } else { + self.push(iter)?; + self.push(Value::Undefined)?; + } + return Ok(None); + } + } + } + } + match iter { + Value::Array(ref items) if items.len() == 2 => { + let arr = match &items[0] { + Value::Array(a) => a, + _ => return Err(ZapcodeError::RuntimeError("invalid iterator".into())), + }; + let idx = match &items[1] { + Value::Int(i) => *i as usize, + _ => return Err(ZapcodeError::RuntimeError("invalid iterator".into())), + }; + if idx < arr.len() { + let value = arr[idx].clone(); + // Update iterator + let new_iter = + Value::Array(vec![items[0].clone(), Value::Int((idx + 1) as i64)]); + // Push updated iterator back, then the value + self.push(new_iter)?; + self.push(value)?; + } else { + // Done — increment index past the end so IteratorDone sees idx > len + let new_iter = + Value::Array(vec![items[0].clone(), Value::Int((idx + 1) as i64)]); + self.push(new_iter)?; + self.push(Value::Undefined)?; + } + } + _ => { + return Err(ZapcodeError::RuntimeError("invalid iterator state".into())); + } + } + } + Instruction::IteratorDone => { + let value = self.pop()?; + let iter = self.peek()?; + // Check for generator iterator first + if let Value::Array(items) = iter { + if items.len() == 3 { + if let Value::String(ref s) = items[0] { + if s.as_ref() == "__gen__" { + let done = matches!(&items[2], Value::Bool(true)); + if !done { + self.push(value)?; + } + self.push(Value::Bool(done))?; + return Ok(None); + } + } + } + } + let iter = self.peek()?; + match iter { + Value::Array(items) if items.len() == 2 => { + let arr = match &items[0] { + Value::Array(a) => a, + _ => { + self.push(value)?; + self.push(Value::Bool(true))?; + return Ok(None); + } + }; + let idx = match &items[1] { + Value::Int(i) => *i as usize, + _ => { + self.push(value)?; + self.push(Value::Bool(true))?; + return Ok(None); + } + }; + let done = idx > arr.len(); + if !done { + // Push value back for the binding + self.push(value)?; + } + self.push(Value::Bool(done))?; + } + _ => { + self.push(value)?; + self.push(Value::Bool(true))?; + } + } + } + + // Error handling + Instruction::SetupTry(catch_ip, _) => { + self.try_stack.push(TryInfo { + catch_ip, + frame_depth: self.frames.len(), + stack_depth: self.stack.len(), + }); + } + Instruction::Throw => { + let val = self.pop()?; + let msg = val.to_js_string(); + return Err(ZapcodeError::RuntimeError(msg)); + } + Instruction::EndTry => { + self.try_stack.pop(); + } + + // Typeof + Instruction::TypeOf => { + let val = self.pop()?; + let type_str = val.type_name(); + self.push(Value::String(Arc::from(type_str)))?; + } + + // Void + Instruction::Void => { + self.pop()?; + self.push(Value::Undefined)?; + } + + // Update + Instruction::Increment => { + let val = self.pop()?; + let result = match val { + Value::Int(n) => Value::Int(n + 1), + _ => Value::Float(val.to_number() + 1.0), + }; + self.push(result)?; + } + Instruction::Decrement => { + let val = self.pop()?; + let result = match val { + Value::Int(n) => Value::Int(n - 1), + _ => Value::Float(val.to_number() - 1.0), + }; + self.push(result)?; + } + + // Template literals + Instruction::ConcatStrings(count) => { + let mut parts = Vec::with_capacity(count); + for _ in 0..count { + parts.push(self.pop()?); + } + parts.reverse(); + let result: String = parts.iter().map(|v| v.to_js_string()).collect(); + self.push(Value::String(Arc::from(result.as_str())))?; + } + + // Destructuring + Instruction::DestructureObject(keys) => { + let obj = self.pop()?; + for key in keys { + let val = self.get_property(&obj, &key)?; + self.push(val)?; + } + } + Instruction::DestructureArray(count) => { + let arr = self.pop()?; + match arr { + Value::Array(items) => { + for i in 0..count { + self.push(items.get(i).cloned().unwrap_or(Value::Undefined))?; + } + } + _ => { + for _ in 0..count { + self.push(Value::Undefined)?; + } + } + } + } + + Instruction::Nop => {} + + // Generators + Instruction::CreateGenerator(_func_idx) => { + // Generator creation is handled at Call time via is_generator check. + } + Instruction::Yield => { + // Yield is handled in run_generator_until_yield_or_return. + // Reaching here means yield outside a generator function. + return Err(ZapcodeError::RuntimeError( + "yield can only be used inside a generator function".to_string(), + )); + } + + Instruction::Await => { + // Check if the value on the stack is a Promise object. + // If resolved, unwrap its value. If rejected, throw its reason. + // If it's a regular (non-promise) value, leave it as-is. + let val = self.pop()?; + if builtins::is_promise(&val) { + if let Value::Object(map) = &val { + let status = map.get("status").cloned().unwrap_or(Value::Undefined); + match status { + Value::String(s) if s.as_ref() == "resolved" => { + let inner = map.get("value").cloned().unwrap_or(Value::Undefined); + self.push(inner)?; + } + Value::String(s) if s.as_ref() == "rejected" => { + let reason = map.get("reason").cloned().unwrap_or(Value::Undefined); + return Err(ZapcodeError::RuntimeError(format!( + "Unhandled promise rejection: {}", + reason.to_js_string() + ))); + } + _ => { + // Unknown status — pass through + self.push(val)?; + } + } + } else { + self.push(val)?; + } + } else { + // Not a promise — pass through (await on non-promise returns the value) + self.push(val)?; + } + } + + // Classes + Instruction::CreateClass { + name, + n_methods, + n_statics, + has_super, + } => { + // Stack layout (top to bottom): + // constructor closure (or undefined) + // n_methods * (closure, method_name_string) pairs + // n_statics * (closure, method_name_string) pairs + // [optional super class if has_super] + + let constructor = self.pop()?; + + // Pop instance methods + let mut prototype = IndexMap::new(); + for _ in 0..n_methods { + let method_closure = self.pop()?; + let method_name = self.pop()?; + if let Value::String(mn) = method_name { + prototype.insert(mn, method_closure); + } + } + + // Pop static methods + let mut statics = IndexMap::new(); + for _ in 0..n_statics { + let method_closure = self.pop()?; + let method_name = self.pop()?; + if let Value::String(mn) = method_name { + statics.insert(mn, method_closure); + } + } + + // Pop super class if present + let super_class = if has_super { Some(self.pop()?) } else { None }; + + // If super class, copy its prototype methods to ours (inheritance) + if let Some(Value::Object(ref sc)) = super_class { + if let Some(Value::Object(super_proto)) = sc.get("__prototype__").cloned() { + // Super prototype methods go first, then our own (which override) + let mut merged = super_proto; + for (k, v) in prototype { + merged.insert(k, v); + } + prototype = merged; + } + } + + // Build the class object + let mut class_obj = IndexMap::new(); + class_obj.insert( + Arc::from("__class_name__"), + Value::String(Arc::from(name.as_str())), + ); + class_obj.insert(Arc::from("__constructor__"), constructor); + class_obj.insert(Arc::from("__prototype__"), Value::Object(prototype)); + + // Store super class reference for super() calls + if let Some(sc) = super_class { + class_obj.insert(Arc::from("__super__"), sc); + } + + // Add static methods directly on the class object + for (k, v) in statics { + class_obj.insert(k, v); + } + + self.push(Value::Object(class_obj))?; + } + + Instruction::Construct(arg_count) => { + let mut args = Vec::with_capacity(arg_count); + for _ in 0..arg_count { + args.push(self.pop()?); + } + args.reverse(); + + let callee = self.pop()?; + + match &callee { + Value::Object(class_obj) if class_obj.contains_key("__class_name__") => { + // Create a new instance object + let mut instance = IndexMap::new(); + + // Copy prototype methods onto the instance + if let Some(Value::Object(proto)) = class_obj.get("__prototype__") { + for (k, v) in proto { + instance.insert(k.clone(), v.clone()); + } + } + + // Store class reference for instanceof + if let Some(class_name) = class_obj.get("__class_name__") { + instance.insert(Arc::from("__class__"), class_name.clone()); + } + + let instance_val = Value::Object(instance); + + // Call the constructor with `this` bound to the instance + if let Some(ctor) = class_obj.get("__constructor__") { + if let Value::Function(closure) = ctor { + // Clear receiver source — constructors should not + // write back to a receiver variable. + self.last_receiver_source = None; + self.push_call_frame(closure, &args, Some(instance_val))?; + self.last_receiver = None; + } else { + // No valid constructor, just return the instance + self.push(instance_val)?; + } + } else { + // No constructor, just return the instance + self.push(instance_val)?; + } + } + Value::Function(closure) => { + // `new` on a plain function — just call it + self.push_call_frame(closure, &args, None)?; + self.last_receiver = None; + } + _ => { + return Err(ZapcodeError::TypeError(format!( + "{} is not a constructor", + callee.to_js_string() + ))); + } + } + } + + Instruction::LoadThis => { + // Walk frames from top to find the nearest `this` value + let this_val = self + .frames + .iter() + .rev() + .find_map(|f| f.this_value.clone()) + .unwrap_or(Value::Undefined); + self.push(this_val)?; + } + Instruction::StoreThis => { + let val = self.pop()?; + // Update this_value in the nearest frame that has one + for frame in self.frames.iter_mut().rev() { + if frame.this_value.is_some() { + frame.this_value = Some(val); + break; + } + } + } + Instruction::CallSuper(arg_count) => { + let mut args = Vec::with_capacity(arg_count); + for _ in 0..arg_count { + args.push(self.pop()?); + } + args.reverse(); + + // Get current `this` value (the instance being constructed) + let this_val = self + .frames + .iter() + .rev() + .find_map(|f| f.this_value.clone()) + .unwrap_or(Value::Undefined); + + // Find the super class constructor from the class that's being constructed. + // We need to look it up from the globals — the class with __super__ key. + // The super class info is stored on the class object. + // We'll look through globals for the class that has __super__. + let mut super_ctor = None; + for val in self.globals.values() { + if let Value::Object(obj) = val { + if let Some(Value::Object(super_class)) = obj.get("__super__") { + if let Some(ctor) = super_class.get("__constructor__") { + super_ctor = Some(ctor.clone()); + break; + } + } + } + } + + if let Some(Value::Function(closure)) = super_ctor { + self.last_receiver_source = None; + self.push_call_frame(&closure, &args, Some(this_val))?; + self.last_receiver = None; + } else { + // No super constructor found — push undefined + self.push(Value::Undefined)?; + } + } + } + + Ok(None) + } + + fn get_property(&self, obj: &Value, name: &str) -> Result { + // Property access on null/undefined throws TypeError (like JS) + if matches!(obj, Value::Null | Value::Undefined) { + return Err(ZapcodeError::TypeError(format!( + "Cannot read properties of {} (reading '{}')", + obj.to_js_string(), + name + ))); + } + match obj { + Value::Object(map) => { + // Check if property exists as a real value on the object + if let Some(val) = map.get(name) { + if !matches!(val, Value::Undefined) { + return Ok(val.clone()); + } + } + // Check if this is a promise instance — expose .then/.catch/.finally + if builtins::is_promise(obj) && is_promise_method(name) { + return Ok(Value::BuiltinMethod { + object_name: Arc::from("__promise__"), + method_name: Arc::from(name), + }); + } + // Check if this is a known global object — return builtin method handle + if let Some(global_name) = &self.last_global_name { + let known_globals = ["console", "Math", "JSON", "Object", "Array", "Promise"]; + if known_globals.contains(&global_name.as_str()) { + return Ok(Value::BuiltinMethod { + object_name: Arc::from(global_name.as_str()), + method_name: Arc::from(name), + }); + } + } + Ok(Value::Undefined) + } + Value::Array(arr) => match name { + "length" => Ok(Value::Int(arr.len() as i64)), + _ if is_array_method(name) => Ok(Value::BuiltinMethod { + object_name: Arc::from("__array__"), + method_name: Arc::from(name), + }), + _ => { + if let Ok(idx) = name.parse::() { + Ok(arr.get(idx).cloned().unwrap_or(Value::Undefined)) + } else { + Ok(Value::Undefined) + } + } + }, + Value::String(s) => match name { + "length" => Ok(Value::Int(s.chars().count() as i64)), + _ if is_string_method(name) => Ok(Value::BuiltinMethod { + object_name: Arc::from("__string__"), + method_name: Arc::from(name), + }), + _ => Ok(Value::Undefined), + }, + Value::Generator(_) => match name { + "next" | "return" | "throw" => Ok(Value::BuiltinMethod { + object_name: Arc::from("__generator__"), + method_name: Arc::from(name), + }), + _ => Ok(Value::Undefined), + }, + _ => Ok(Value::Undefined), + } + } +} + +// Re-export for the ParamPattern type used in function calls +use crate::zapcode_browser::parser::ir::ParamPattern; + +fn is_array_method(name: &str) -> bool { + matches!( + name, + "push" + | "pop" + | "shift" + | "unshift" + | "splice" + | "slice" + | "concat" + | "join" + | "reverse" + | "sort" + | "indexOf" + | "lastIndexOf" + | "includes" + | "find" + | "findIndex" + | "map" + | "filter" + | "reduce" + | "forEach" + | "every" + | "some" + | "flat" + | "flatMap" + | "fill" + | "at" + | "entries" + | "keys" + | "values" + ) +} + +fn is_string_method(name: &str) -> bool { + matches!( + name, + "charAt" + | "charCodeAt" + | "indexOf" + | "lastIndexOf" + | "includes" + | "startsWith" + | "endsWith" + | "slice" + | "substring" + | "substr" + | "toUpperCase" + | "toLowerCase" + | "trim" + | "trimStart" + | "trimEnd" + | "trimLeft" + | "trimRight" + | "padStart" + | "padEnd" + | "repeat" + | "replace" + | "replaceAll" + | "split" + | "concat" + | "at" + | "match" + | "search" + | "normalize" + ) +} + +fn is_promise_method(name: &str) -> bool { + matches!(name, "then" | "catch" | "finally") +} + +/// Main entry point: compile and run TypeScript code. +pub struct ZapcodeRun { + source: String, + #[allow(dead_code)] + inputs: Vec, + external_functions: Vec, + limits: ResourceLimits, +} + +impl ZapcodeRun { + pub fn new( + source: String, + inputs: Vec, + external_functions: Vec, + limits: ResourceLimits, + ) -> Result { + Ok(Self { + source, + inputs, + external_functions, + limits, + }) + } + + pub fn run(&self, input_values: Vec<(String, Value)>) -> Result { + let mut root_span = SpanBuilder::new("zapcode.run"); + + // Parse + let parse_span = SpanBuilder::new("parse"); + let program = match crate::zapcode_browser::parser::parse(&self.source) { + Ok(p) => { + root_span.add_child(parse_span.finish_ok()); + p + } + Err(e) => { + root_span.add_child(parse_span.finish_error(&e.to_string())); + let _trace = ExecutionTrace { + root: root_span.finish(TraceStatus::Error), + }; + return Err(e); + } + }; + + // Compile + let compile_span = SpanBuilder::new("compile"); + let ext_set: HashSet = self.external_functions.iter().cloned().collect(); + let compiled = match crate::zapcode_browser::compiler::compile_with_externals(&program, ext_set.clone()) { + Ok(c) => { + root_span.add_child(compile_span.finish_ok()); + c + } + Err(e) => { + root_span.add_child(compile_span.finish_error(&e.to_string())); + let _trace = ExecutionTrace { + root: root_span.finish(TraceStatus::Error), + }; + return Err(e); + } + }; + + // Execute + let execute_span = SpanBuilder::new("execute"); + let mut vm = Vm::new(compiled, self.limits.clone(), ext_set); + + for (name, value) in input_values { + vm.globals.insert(name, value); + } + + let state = match vm.run() { + Ok(s) => { + let status = match &s { + VmState::Complete(_) => TraceStatus::Ok, + VmState::Suspended { + function_name, + args, + .. + } => { + let mut span = execute_span; + span.set_attr("zapcode.suspended_on", function_name); + span.set_attr("zapcode.args_count", args.len()); + root_span.add_child(span.finish(TraceStatus::Ok)); + let trace = ExecutionTrace { + root: root_span.finish_ok(), + }; + return Ok(RunResult { + state: s, + stdout: vm.stdout, + trace, + }); + } + }; + root_span.add_child(execute_span.finish(status)); + s + } + Err(e) => { + root_span.add_child(execute_span.finish_error(&e.to_string())); + let _trace = ExecutionTrace { + root: root_span.finish(TraceStatus::Error), + }; + return Err(e); + } + }; + + let trace = ExecutionTrace { + root: root_span.finish_ok(), + }; + + Ok(RunResult { + state, + stdout: vm.stdout, + trace, + }) + } + + /// Start execution. Like `run()`, but returns the raw `VmState` directly + /// instead of wrapping it in a `RunResult`. This is the primary entry point + /// for code that needs to handle suspension / snapshot / resume. + pub fn start(&self, input_values: Vec<(String, Value)>) -> Result { + let result = self.run(input_values)?; + Ok(result.state) + } + + pub fn run_simple(&self) -> Result { + let result = self.run(Vec::new())?; + match result.state { + VmState::Complete(v) => Ok(v), + VmState::Suspended { function_name, .. } => Err(ZapcodeError::RuntimeError(format!( + "execution suspended on external function '{}' — use run() instead", + function_name + ))), + } + } +} + +/// Result of running a Zapcode program. +pub struct RunResult { + pub state: VmState, + pub stdout: String, + /// Execution trace covering parse → compile → execute. + pub trace: ExecutionTrace, +} + +/// Quick helper to evaluate a TypeScript expression. +pub fn eval_ts(source: &str) -> Result { + let runner = ZapcodeRun::new( + source.to_string(), + Vec::new(), + Vec::new(), + ResourceLimits::default(), + )?; + runner.run_simple() +} + +/// Evaluate TypeScript and return both the value and stdout output. +pub fn eval_ts_with_output(source: &str) -> Result<(Value, String)> { + let runner = ZapcodeRun::new( + source.to_string(), + Vec::new(), + Vec::new(), + ResourceLimits::default(), + )?; + let result = runner.run(Vec::new())?; + match result.state { + VmState::Complete(v) => Ok((v, result.stdout)), + VmState::Suspended { function_name, .. } => Err(ZapcodeError::RuntimeError(format!( + "execution suspended on external function '{}'", + function_name + ))), + } +} diff --git a/docs/start-browser.md b/docs/start-browser.md index aec6e539a..8b15456e5 100644 --- a/docs/start-browser.md +++ b/docs/start-browser.md @@ -31,6 +31,44 @@ const result = bash.executeSync('echo "Hello, browser!" | tr a-z A-Z'); console.log(result.stdout); // HELLO, BROWSER! ``` +## Python and TypeScript host functions + +Use the optional runtime bundle when scripts need embedded Python or TypeScript: + +```js +import { initBashkit, Bash } from "@everruns/bashkit-wasm/runtimes"; +await initBashkit(); +const bash = new Bash({ + python: true, + externalFunctions: ["lookup"], + externalHandler: async (_name, args, kwargs, { signal }) => { + const response = await fetch(`/api/items/${encodeURIComponent(String(args[0]))}`, { signal }); + return response.json(); + }, + typescript: true, + typescriptExternalFunctions: ["double"], + typescriptExternalHandler: (_name, args) => Number(args[0]) * 2, +}); +await bash.execute(`python -c 'print(lookup(21))'`); +console.log((await bash.execute(`ts -c 'await double(21)'`)).stdout); // 42 +``` + +The default import keeps the slim binary. Both bundles use one event loop and +work in module workers without SharedArrayBuffer or COOP/COEP. Runtimes remain +disabled until enabled in constructor options. + +Handlers accept the same JSON subset and signatures as the +[native JS SDK](start-node.md#python-and-typescript-host-functions). Use async +`execute` for host calls. Its signal aborts on timeout, cancellation or completion; +pass it to host I/O. An optional `execute(commands, { signal, onOutput })` controls +cancellation and streaming. Cancellation is sticky until `clearCancel()`. +One execution may run per instance; concurrent or recursive execution rejects +with a busy error. Reset preserves handlers; persisted state contains no host code. +Terminal supports pure runtimes but rejects runtime callback names; use Bash for +host functions. Host code owns service authorization and I/O limits. + +Runtime host callbacks also work with live commands and the `stdout`/`stderr` byte sinks, explicit `capture` limits, and `AbortSignal` options described in [Streaming I/O](streaming.md). Text `onOutput` observers remain a separate execution mode; use byte sinks when combining output delivery with live execution options. + ## Try it live This is the exact package above, running in your browser, no server, nothing diff --git a/knowledge/log.md b/knowledge/log.md index 7fd461da1..76f007c2d 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -2,6 +2,10 @@ ## 2026-10-16 +* **Browser live runtime callbacks**: Integrated the runtime dispatcher with live byte sinks, bounded capture and command pipelines after the streaming API landed. Both execution paths retain request-local leases; tests cover async sink completion/deadlines, abort/reuse, reset and caller context. See [Browser Package](runtimes/browser-package.md). + +* **Browser runtime parity**: Added the optional `/runtimes` import with embedded Python/TypeScript and named host callbacks. Slim default imports keep their VM-free binary. Browser callbacks use revocable execution sessions and AbortSignal; Python host waits now use the portable timer, and a hash-checked browser-only ZapCode copy preserves deadlines with platform clocks. Added worker, Chromium/no-isolation, cancellation, VFS/persistence and npm-archive coverage. See [Browser Package](runtimes/browser-package.md) and [ZapCode Runtime](runtimes/zapcode-runtime.md). + * **C streaming callbacks**: Integrated C ABI runtime host functions with live output. Buffered and streaming entry points share admission guards, and output callbacks reject blocking reentry on any handle before a nested runtime can start. Combined tests cover immediate/cross-thread replies, capture and discard, caller affinity, and recursive streaming calls. See [C API](runtimes/c-api.md). * **C runtime parity**: Added optional Python/TypeScript runtime flags and an additive ABI v1 host dispatcher. Owned, revocable runtime calls accept immediate or cross-thread replies; blocking callback reentry fails with BUSY. Added parallel-instance, caller-thread, cancellation/deadline/reuse and real C header/library tests. Corrected parity evidence for existing C cancellation and mounts. Windows import libraries include every runtime symbol; header/Rust/definition inventory checks prevent missing exports. See [C API](runtimes/c-api.md). diff --git a/knowledge/runtimes/browser-package.md b/knowledge/runtimes/browser-package.md index 131ff7cc7..fc128d5a3 100644 --- a/knowledge/runtimes/browser-package.md +++ b/knowledge/runtimes/browser-package.md @@ -90,7 +90,7 @@ matches awaiting it, and `send`/`takeOutput`/`resize` stay callable while a command such as `sleep` is in flight. Absent (need sockets, threads, or a host FS the browser sandbox lacks): -`http_client` (`curl`/`wget`), `ssh`, `sqlite`, embedded `python`, `realfs` +`http_client` (`curl`/`wget`), `ssh`, `sqlite`, `realfs` mounts, and native `interop`. Reach the network from a custom builtin that calls the app's own `fetch` instead. @@ -103,6 +103,49 @@ are unaffected. Embedders who need named zones should build their own wasm target with the `tzdata` feature on. See [Dependency Policy](../operations/dependencies.md). +## Optional embedded runtimes + +The default import stays slim. `@everruns/bashkit-wasm/runtimes` loads a separate +single-threaded binary with Python/Monty and TypeScript/ZapCode. Both runtimes +remain explicit constructor opt-ins. Cargo features `python` and `typescript` +can also be selected independently. Build the slim bundle first, then use +`BASHKIT_WASM_VARIANT=runtimes BASHKIT_WASM_FEATURES=terminal,python,typescript` +with `scripts/build.sh`; CI and publication include both in one npm archive. + +Bash accepts the native JS SDK's function-name/dispatcher options. Dispatchers +receive JSON-subset values (depth 64, finite/safe JS numbers, string-key objects), +Python kwargs, and an execution-local AbortSignal. Host code grants service +access explicitly and owns its authorization and I/O limits. Per-execution +capabilities revoke pending dispatch on completion, timeout or cancellation; +JS sessions abort host I/O and never serialize callbacks into persisted state. +A per-execution promise mailbox receives calls in the original caller's async +context before launching them concurrently. This avoids context leakage through +wasm-bindgen's shared task wakeup queue, without importing Node APIs. A real WASM +regression verifies independent AsyncLocalStorage contexts when hosted in Node. + +One execution runs per browser instance. Concurrent/reentrant execution rejects +promptly; reset rejects active work without replacing its cancellation token. +Calls needing host functions use `execute`; synchronous host calls fail clearly. +Live filesystem handles access the VFS directly without the interpreter lock. +The interactive Terminal accepts pure runtimes but rejects external function +names: it does not expose Bash's execution-local callback session. + +Python external waits use the portable core timer, not Tokio's native timer. +Monty-types already uses web-time; its getrandom 0.3 backend needs an explicit +wasm_js selection. ZapCode 1.5.1 has unsupported std clocks, so browser-only +source is vendored with clock reads redirected through Bashkit's platform shim. +Native builds retain the registry crate and its public value types. See +[ZapCode Runtime](zapcode-runtime.md) for the recorded-source drift check. + +Verification includes real wasm under Node, CPU and pending-I/O deadlines, +cancellation/reuse, JSON failures, VFS/reset/checkout, independent worker +instances, npm archive contents and actual Chromium fetch/module workers served +without COOP/COEP. The existing slim package semantics and size gates still run. + +### Runtime callbacks with live I/O + +Buffered and live executions install the same runtime session through `ExecOptions`. Live byte sinks, bounded capture and commands retain the execution-local host dispatcher. AbortSignal cancellation revokes host work and clears its cancellation token for reuse; manual cancellation remains sticky. Combined runtime tests cover sink completion/deadlines, cancellation/reuse, reset-preserved commands and caller context. Legacy text observers reject mixed live options before execution. + ## Host-backed filesystem (`new Bash({ fs })`) Embedders can replace the in-memory VFS with their own store by passing `fs`. @@ -309,7 +352,7 @@ Decisions and constraints: native `getrandom` backend, so `--cfg getrandom_backend="wasm_js"` must not be set there. - **Feature surface matches the browser package**, and for the same reasons: - no sockets (`http_client`, `ssh`), no host FS (`realfs`), no `python`. + no sockets (`http_client`, `ssh`) and no host FS (`realfs`). `sqlite` additionally fails to build on WASI (tokio feature selection), so it stays out until that is resolved. - **Single-threaded**, same inline-execution consequences as the browser build. diff --git a/knowledge/runtimes/zapcode-runtime.md b/knowledge/runtimes/zapcode-runtime.md index 933d690fc..d759237c4 100644 --- a/knowledge/runtimes/zapcode-runtime.md +++ b/knowledge/runtimes/zapcode-runtime.md @@ -164,3 +164,23 @@ a hint to `help()` and `system_prompt()`: > ts/node/deno/bun: Embedded TypeScript (ZapCode). Supports ES2024 subset. > File I/O via readFile()/writeFile() async functions. No npm/import/require. > No HTTP/network. No eval(). + +### Browser platform clocks + +Only wasm32-unknown-unknown uses the source copy in +`crates/bashkit/src/zapcode_browser`. ZapCode 1.5.1 uses std Instant in its resource +tracker and Instant/SystemTime in tracing, which panic on this target. The copy +replaces those reads with Bashkit's clock shim; time limits remain enabled. +Native and WASI builds retain the registry crate and its public value types. + +Upstream is MIT, with its license retained. `upstream-sha256.json` records every +original source file. The only mechanical namespace change is `crate::` to +`crate::zapcode_browser::`, plus lib.rs to mod.rs. `scripts/check_zapcode_browser.py` +reverses those changes and the two clock substitutions, then checks hashes and +the complete file inventory and alignment with the native Cargo.lock version. Rustfmt and Clippy skip this upstream source like +the jaq-json vendor. To update, copy a reviewed crate release, regenerate hashes, +apply those changes, retain the license and rerun the real browser runtime suite. +Keep parser/postcard/indexmap dependency versions aligned with upstream. Remove +the copy once upstream provides browser-safe clocks; never disable deadlines. + +See [Browser Package](browser-package.md) for bundle and callback session rules. diff --git a/knowledge/security/threat-model.md b/knowledge/security/threat-model.md index 8669e20e0..ebd20efc6 100644 --- a/knowledge/security/threat-model.md +++ b/knowledge/security/threat-model.md @@ -1005,6 +1005,7 @@ Only exact domain matches are allowed (TM-NET-017). | TM-ISO-024 | `$?` leaks into VFS subprocess | Parent `last_exit_code` visible inside VFS script subprocess, causing false `set -e` failures | `execute_script_content()` sets `last_exit_code = 0`, clears `nounset_error`, and clears `traps` for the child | **MITIGATED** | | TM-ISO-025 | Wrapper rebuild silently drops or restores revoked capabilities | A binding's `reset()` or fresh-execution path rebuilds `Bash` without limits, policy files, callbacks, network policy, or other host configuration, or replays an explicitly revoked mount, changing the sandbox contract after the first call | The canonical capability manifest requires executable evidence for every supported surface cell. NAPI `SharedState` retains constructor `files`, and `build_bash_from_state` is the single rebuild path used by construction and reset. Host mutations applied *after* construction are recorded on the same path: NAPI `runtime_env`/`runtime_mounts` and the Python equivalents replay `set_env()` and runtime mounts on every rebuild. Runtime mount replay keys use the VFS-normalized mount point, so `unmount()` retracts the record even when mount and unmount callers use equivalent paths containing `.` or `..`; a rebuild cannot resurrect the removed mount. Script-set env is not recorded, so `reset()` still discards it. Regressions: `Bash: reset restores configured files`, `Bash: reset preserves setEnv values`, `Bash: reset preserves a runtime FileSystem mount`, `Bash: unmount retracts the replay, so reset does not resurrect it`, `Bash: unmount retracts an equivalent normalized mount path`, `test_reset_preserves_set_env`, `test_unmount_retracts_replay`, `test_unmount_retracts_equivalent_normalized_mount_path` | **MITIGATED** | | TM-ISO-026 | Shared ToolRegistry leaks tenant identity or traces across requests/runtimes | One immutable registry and callback set may serve concurrent tenants through shell, Python, and TypeScript; registry-global mutable request state would cross-contaminate authorization and telemetry | `ToolCallRequest` carries tenant + a fresh bounded trace through `ExecutionExtensions`; runtime bridges expose it through the core `RuntimeCallContext` task-local only for the current run; callbacks receive owned `ToolArgs` context; no tenant value is stored in the registry (`bashkit-scripted-tool/src/tool_registry.rs`) | **MITIGATED** | +| TM-ISO-029 | Browser WASM task wakeups cross caller async contexts | wasm-bindgen's shared wakeup queue resumes two instances in the first caller's context, so a host callback can observe another request's AsyncLocalStorage identity | Each execution registers a promise mailbox in its caller's context and launches its host functions there concurrently; native execution capabilities revoke dispatch and the JS AbortSignal ends host I/O. No Node-only imports. Regression: `WASM host callbacks keep independent caller async contexts` | **MITIGATED** | | TM-ISO-027 | Request-owned execution authority or retained facility handles survive completion | A retained runtime, transport, callback, budget, extension, or custom-builtin VFS handle emits late output, charges a reused request, or accesses facilities after completion/cancellation | One `ExecutionBudget` and one capability lease are created before initialization and closed/revoked on every exit. The budget gates work and async results; `ExecutionCapability`, scoped VFS wrappers, host-call brokers, and `ToolArgs` context gate retained facilities. Cleanup is bounded/idempotent and reports only sanitized counts. `BuiltinRegistry::insert_trusted` explicitly models the intentional unscoped host escape hatch. Shared lifecycle evidence is defined in [Request Execution Lifecycle](request-lifecycle.md); Rust and Python capability regressions cover late/cross-request/cancellation use and cleanup failure. | **MITIGATED** | | TM-ISO-028 | Process substitution data shared through the VFS | Tenants sharing one filesystem read or clobber each other's `<(cmd)` output, or plant `/dev/fd/63` for a victim to consume | `/dev/fd/N` (N >= 3) is a per-interpreter fd namespace (`ProcSubFs` wraps each shell's VFS view): open substitutions are served from memory, closed ones are "No such file or directory", and no such path reaches the shared filesystem. Regressions: `process_substitution_fd_tests` (`substitution_never_reaches_shared_filesystem`, `second_interpreter_does_not_see_first_ones_fd`, `concurrent_interpreters_keep_their_own_fd_63`), `fs::proc_sub_fds::tests` | **MITIGATED** | diff --git a/knowledge/status/capability-parity.md b/knowledge/status/capability-parity.md index 2651fcc30..7fed6d3ef 100644 --- a/knowledge/status/capability-parity.md +++ b/knowledge/status/capability-parity.md @@ -37,12 +37,12 @@ A dash means the feature is intentionally unsupported and has a recorded reason | Host-visible virtual filesystem state | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ | ✅ | | Filesystem mounts beyond seeded virtual files | ✅ | ✅ | — | ✅ | ✅ | ✅ | — | ✅ | | Snapshot and restore of interpreter state | ✅ | — | — | — | ✅ | ✅ | ✅ | — | -| Explicit embedded Python opt-in | ✅ | ✅ | — | ✅ | ✅ | ✅ | — | ✅ | -| Explicit embedded TypeScript opt-in | ✅ | ✅ | — | — | ✅ | ✅ | — | ✅ | +| Explicit embedded Python opt-in | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ | ✅ | +| Explicit embedded TypeScript opt-in | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ | | Explicit embedded SQLite opt-in | ✅ | ✅ | — | ✅ | ✅ | ✅ | — | — | -| In-process interactive terminal session (keystrokes in, screen text and per-command transcript out; vi, less) | ✅ | — | — | — | ✅ | ✅ | — | — | -| Named host callbacks invoked from embedded Python | ✅ | ✅ | — | — | ✅ | ✅ | — | ✅ | -| Named host callbacks invoked from embedded TypeScript | ✅ | ✅ | — | — | ✅ | ✅ | — | ✅ | +| In-process interactive terminal session (keystrokes in, screen text and per-command transcript out; vi, less) | ✅ | — | — | — | ✅ | ✅ | ✅ | — | +| Named host callbacks invoked from embedded Python | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ | +| Named host callbacks invoked from embedded TypeScript | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | ✅ | ## Intentional exclusions @@ -116,12 +116,7 @@ A dash means the feature is intentionally unsupported and has a recorded reason - `network_policy`: The slim browser build has no network builtin or host socket access. - `transport_hooks`: The slim browser build has no HTTP transport. - `mounts`: Browser WASM exposes one in-memory VFS and no host filesystem mounts. -- `runtime_python`: The slim browser build omits embedded language runtimes. -- `runtime_typescript`: The slim browser build omits embedded language runtimes. - `runtime_sqlite`: The slim browser build omits embedded language runtimes. -- `interactive_terminal`: Not exposed in the slim browser package yet. -- `host_functions_python`: The slim browser build currently omits embedded language runtimes. -- `host_functions_typescript`: The slim browser build currently omits embedded language runtimes. ### C ABI diff --git a/scripts/check_zapcode_browser.py b/scripts/check_zapcode_browser.py new file mode 100644 index 000000000..b3cc997f2 --- /dev/null +++ b/scripts/check_zapcode_browser.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +"""Keep the browser VM copy byte-close to the recorded ZapCode 1.5.1 source.""" +import hashlib +import json +import tomllib +from pathlib import Path + +root = Path(__file__).resolve().parents[1] / "crates/bashkit/src/zapcode_browser" +native_versions = {package["version"] for package in tomllib.loads((root.parents[3] / "Cargo.lock").read_text())["package"] if package["name"] == "zapcode-core"} +if native_versions != {"1.5.1"}: + raise SystemExit("update the browser source copy when updating native ZapCode") +expected = json.loads((root / "upstream-sha256.json").read_text()) +actual_paths = {str(p.relative_to(root)) for p in root.rglob("*.rs")} +if actual_paths != {"mod.rs" if p == "lib.rs" else p for p in expected}: + raise SystemExit("vendored file inventory changed") +for path, digest in expected.items(): + text = (root / ("mod.rs" if path == "lib.rs" else path)).read_text() + text = text.replace("crate::zapcode_browser::", "crate::") + if path in {"sandbox.rs", "trace.rs"}: + text = text.replace("crate::time::", "std::time::") + if hashlib.sha256(text.encode()).hexdigest() != digest: + raise SystemExit(f"unrecorded ZapCode change: {path}") +print("browser ZapCode matches 1.5.1 plus namespace and platform-clock changes") diff --git a/scripts/tests/test_wasm_package_gates.py b/scripts/tests/test_wasm_package_gates.py index b2c13d21f..55c2cbe5c 100644 --- a/scripts/tests/test_wasm_package_gates.py +++ b/scripts/tests/test_wasm_package_gates.py @@ -31,6 +31,7 @@ def test_source_archive_cannot_claim_enclosing_repository_identity(self): script = root / "crates/bashkit-wasm/scripts/build-info.mjs" script.parent.mkdir(parents=True) shutil.copy(ROOT / "crates/bashkit-wasm/scripts/build-info.mjs", script) + (script.parent.parent / "package.json").write_text('{"version":"1.2.3"}') pkg = root / "pkg" pkg.mkdir() (pkg / "package.json").write_text('{"version":"1.2.3"}')