diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 829b52b..c690c84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: jdx/mise-action@v2 + - uses: jdx/mise-action@v4 - run: task install diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c690e3f..7b2dbc0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -13,7 +13,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: jdx/mise-action@v2 + - uses: jdx/mise-action@v4 - run: task install diff --git a/README.md b/README.md index 258021a..c18db9d 100644 --- a/README.md +++ b/README.md @@ -28,10 +28,13 @@ Fetches the real Petstore OpenAPI spec from the web, then runs search + execute ```bash pnpm add @robinbraemer/codemode -# Install the sandbox runtime: -pnpm add isolated-vm # V8 isolates +# Install a sandbox runtime (at least one): +pnpm add isolated-vm # V8 isolates — recommended for production on Node +pnpm add quickjs-emscripten # WASM QuickJS — fallback for Bun / CF Workers / browser ``` +If both are installed, the auto-selector (`createExecutor`) picks `isolated-vm` on Node and `quickjs-emscripten` on Bun (where `isolated-vm` cannot dlopen because Bun's JavaScriptCore engine does not export the V8 symbols `isolated-vm` requires). + ## Quick Start ```typescript @@ -269,11 +272,21 @@ const tags = extractTags(rawSpec); ## Executors -CodeMode uses `isolated-vm` (V8 isolates) for sandboxed execution. You can pass a custom instance: +CodeMode ships two executor backends. `IsolatedVMExecutor` is the recommended production backend on Node. `QuickJSExecutor` is a compatibility fallback for environments where `isolated-vm` cannot load (Bun, Cloudflare Workers, browser). + +Use `createExecutor()` for automatic selection, or pass an executor instance explicitly: ```typescript -import { CodeMode, IsolatedVMExecutor } from '@robinbraemer/codemode'; +import { CodeMode, createExecutor, IsolatedVMExecutor, QuickJSExecutor } from '@robinbraemer/codemode'; +// Automatic — picks isolated-vm on Node, quickjs-emscripten on Bun +const codemode = new CodeMode({ + spec, + request: handler, + executor: await createExecutor({ memoryMB: 128, timeoutMs: 60_000 }), +}); + +// Or explicit const codemode = new CodeMode({ spec, request: handler, @@ -285,9 +298,17 @@ const codemode = new CodeMode({ }); ``` -| Executor | Package | Performance | Portability | -|----------|---------|-------------|-------------| -| `IsolatedVMExecutor` | `isolated-vm` | Native V8 speed | Node.js | +| Executor | Package | Performance | Portability | Production-ready | +|----------|---------|-------------|-------------|------------------| +| `IsolatedVMExecutor` | `isolated-vm` | Native V8 speed | Node.js | ✅ | +| `QuickJSExecutor` | `quickjs-emscripten` | Slower (interpreted WASM) | Node, Bun, CF Workers, browser | ⚠️ fallback only — see caveats | + +### `QuickJSExecutor` caveats + +- **Not a production backend.** Exists so the package loads on runtimes where `isolated-vm` cannot dlopen. Production callers on Node should use `IsolatedVMExecutor`. +- **Sandboxed code must avoid sequential `await` on host functions.** Use `Promise.all([fn1(), fn2()])` for parallel calls instead. Chained sequential `await`s currently crash with an upstream `quickjs-emscripten@0.32.0` release-asyncify regression ([justjake/quickjs-emscripten#258](https://github.com/justjake/quickjs-emscripten/issues/258)) — reproduces identically on Node and Bun. +- **Return-value semantics differ from `isolated-vm`.** Host ↔ guest values cross via a `JSON.stringify` envelope. `Date`, `Map`, `Set`, `BigInt` are converted to strings/objects, not preserved as instances. `isolated-vm` uses structured clone and preserves them. Stick to plain JSON-safe shapes in sandboxed code that targets both backends. +- **CPU timeout is wall-clock-based.** `isolated-vm` uses true CPU time; QuickJS uses elapsed time. Async host calls that take wall time count against the CPU budget under QuickJS. ### Custom Executor diff --git a/packages/codemode/package.json b/packages/codemode/package.json index e693f14..1e7e1a4 100644 --- a/packages/codemode/package.json +++ b/packages/codemode/package.json @@ -1,6 +1,6 @@ { "name": "@robinbraemer/codemode", - "version": "0.1.6", + "version": "0.2.0", "description": "Code Mode MCP tools from OpenAPI specs. Two tools (search + execute) replace hundreds of individual MCP tools.", "type": "module", "main": "./dist/index.js", @@ -46,21 +46,26 @@ "url": "https://github.com/cnap-tech/codemode.git" }, "peerDependencies": { - "isolated-vm": "6" + "isolated-vm": "6", + "quickjs-emscripten": ">=0.31" }, "peerDependenciesMeta": { "isolated-vm": { "optional": true + }, + "quickjs-emscripten": { + "optional": true } }, "devDependencies": { "@modelcontextprotocol/sdk": "^1.12.1", "hono": "^4.7.6", "isolated-vm": "^6.0.2", + "quickjs-emscripten": "^0.32.0", "tsup": "^8.4.0", "tsx": "^4.21.0", - "typescript": "^5.7.3", - "vitest": "^3.0.5", + "typescript": "^6.0.0", + "vitest": "^4.0.0", "zod": "^4.0.0" } } diff --git a/packages/codemode/src/executor/auto.ts b/packages/codemode/src/executor/auto.ts index 777acb2..4a97aa0 100644 --- a/packages/codemode/src/executor/auto.ts +++ b/packages/codemode/src/executor/auto.ts @@ -1,22 +1,73 @@ import type { Executor, SandboxOptions } from "../types.js"; /** - * Create an executor using the isolated-vm peer dependency. + * Detect whether we're running under Bun. On Bun, isolated-vm cannot dlopen + * (it relies on V8 symbols like `v8::ValueSerializer::Delegate::IsHostObject` + * that Bun's JavaScriptCore engine does not export), so we prefer the WASM + * QuickJS backend. + * + * Uses Bun's officially documented detection pattern: + * https://bun.com/docs/guides/util/detect-bun + * + * The `typeof process` guard keeps this safe in non-Node-shaped runtimes + * (Cloudflare Workers, browser) where `process` is undefined. + */ +function isBun(): boolean { + // Cast through globalThis to avoid requiring @types/node just for `process`. + const proc = (globalThis as { process?: { versions?: { bun?: string } } }).process; + return !!proc?.versions?.bun; +} + +/** + * Pick a sandbox runtime automatically. + * + * Order of preference: + * - **Bun** → QuickJS first (isolated-vm cannot load native bindings under + * JavaScriptCore), fall back to isolated-vm only if QuickJS isn't + * installed. + * - **Node** → isolated-vm first (V8 JIT is faster, mature, no upstream + * async bugs), fall back to QuickJS if isolated-vm isn't installed (e.g. + * ARM Linux without build tools, or a Node minor without a prebuild). + * + * Production deployments on Node should always have `isolated-vm` installed + * — QuickJS is a compatibility fallback, not a recommended production + * backend. See `QuickJSExecutor`'s docstring for the upstream + * `quickjs-emscripten` bugs it inherits. + * + * Both `isolated-vm` and `quickjs-emscripten` are optional peer dependencies. */ export async function createExecutor( options: SandboxOptions = {}, ): Promise { - try { - // @ts-ignore — optional peer dependency - await import("isolated-vm"); - const { IsolatedVMExecutor } = await import("./isolated-vm.js"); - return new IsolatedVMExecutor(options); - } catch { - // Not available + const order = isBun() ? (["quickjs", "isolated-vm"] as const) : (["isolated-vm", "quickjs"] as const); + + /* oxlint-disable no-await-in-loop */ + for (const backend of order) { + if (backend === "isolated-vm") { + try { + // @ts-ignore — optional peer dependency + await import("isolated-vm"); + const { IsolatedVMExecutor } = await import("./isolated-vm.js"); + return new IsolatedVMExecutor(options); + } catch { + // not available — try the next backend + } + } else { + try { + // @ts-ignore — optional peer dependency + await import("quickjs-emscripten"); + const { QuickJSExecutor } = await import("./quickjs.js"); + return new QuickJSExecutor(options); + } catch { + // not available — try the next backend + } + } } + /* oxlint-enable no-await-in-loop */ throw new Error( - "No sandbox runtime found. Install isolated-vm:\n" + - " npm install isolated-vm # V8 isolates (Node.js)", + "No sandbox runtime found. Install one of:\n" + + " npm install isolated-vm # V8 isolates (Node.js, fastest)\n" + + " npm install quickjs-emscripten # WASM QuickJS (Bun, Workers, browser)", ); } diff --git a/packages/codemode/src/executor/quickjs.ts b/packages/codemode/src/executor/quickjs.ts new file mode 100644 index 0000000..b71b78f --- /dev/null +++ b/packages/codemode/src/executor/quickjs.ts @@ -0,0 +1,629 @@ +import type { Executor, ExecuteResult, ExecuteStats, SandboxOptions } from "../types.js"; + +/** + * Executor implementation using quickjs-emscripten (pure WASM QuickJS). + * + * **Not a production backend.** This executor exists so `@robinbraemer/codemode` + * loads without crashing under runtimes where `isolated-vm` cannot dlopen + * — most importantly **Bun** (its JavaScriptCore engine does not export V8 + * symbols like `v8::ValueSerializer::Delegate::IsHostObject` that isolated-vm + * needs), and any future Cloudflare Workers / browser deployment. Production + * callers on Node should use `IsolatedVMExecutor` for performance, maturity, + * and the upstream-bug-free async story (see below). Backend selection is + * automatic via `createExecutor` in `./auto.ts`. + * + * No native compilation is required: this runs on Node, Bun, Cloudflare + * Workers, Deno, and the browser. + * + * Each execute() call creates a fresh QuickJS runtime + context — no state + * leaks between calls. The sandbox has zero I/O capabilities by default (no + * fetch, no fs, no require, no process). The only way out is through + * injected host functions. + * + * Stats parity: the `ExecuteStats` shape is shared with `IsolatedVMExecutor`. + * QuickJS does not expose V8-specific counters (executable bytes, peak + * malloc'd memory, etc.), so those fields are best-effort or 0 — see + * `captureStats` for the exact mapping. The shape (key names + types) is + * preserved so callers can treat both executors interchangeably. + * + * Semantic divergences from `IsolatedVMExecutor` + * ---------------------------------------------- + * - **Return-value type fidelity.** `isolated-vm` uses structured clone for + * host↔guest values (`{ copy: true }`) — `Date`, `Map`, `Set`, `BigInt`, + * typed arrays are preserved as their original types. This executor uses + * a `JSON.stringify` envelope to work around an upstream GC-anchoring bug + * (see Workaround #2 below), so guest code that returns a `Date` gets back + * a date-string, a `Map` gets back `{}`, etc. Stick to JSON-safe shapes + * in sandboxed code that targets both backends. + * - **CPU timeout is wall-clock-based.** `IsolatedVMExecutor` enforces a true + * CPU-time limit via V8's script timeout. QuickJS does not expose CPU + * time separately from wall time, so `timeoutMs` here is measured from + * `execute()` entry with `Date.now()`. Async host calls that take wall + * time count against this budget — pick `timeoutMs` higher than you would + * for isolated-vm if guest code does any I/O via host functions. + * + * Upstream bugs in `quickjs-emscripten@0.32.0` release-asyncify + * ------------------------------------------------------------- + * The following two issues are **not Bun-specific** — empirically reproduced + * on Node 24.13.1 and Bun 1.3.9 with the same QuickJS C-side assertion + * (`p->ref_count == 0` at `quickjs.c:6009, free_zero_refcount`). Treat as + * upstream-blocking until justjake/quickjs-emscripten patches land: + * + * - **#258** — multiple sequential `await hostFn()` calls in user code crash + * with "Aborted(Assertion failed: p->ref_count == 0)" + WASM "memory + * access out of bounds" trap. Single `await` works. Use `Promise.all` for + * parallel calls instead of chained sequential `await`s. + * - **#261** — `QuickJSAsyncWASMModule.newRuntime` disposes in the wrong + * order, producing `Aborted(Assertion failed: ...)` noise on dispose + * after asyncified host functions have been defined. Caught in our + * `finally`; result correctness unaffected. + * + * This implementation also works around two related construction-ordering + * bugs in the same release-asyncify build: + * + * 1. Calling `evalCode` / `evalCodeAsync` *before* `newAsyncifiedFunction` + * registration corrupts asyncify bookkeeping and crashes the second + * sequential `await` in user code. Mitigation: register host functions + * first and build all setup (no-op console, plain-data globals) via the + * handle API only — see `injectNoopConsole`, `injectValue`. + * 2. The IIFE result handle is not reliably GC-anchored once the user's + * `(async () => ...)()` resolves: `context.dump(handle)` then crashes + * with "memory access out of bounds" even though the JS-side wrapper + * still reports `alive: true`. Mitigation: wrap the user code with a + * `JSON.stringify` envelope so the value we read back is a primitive + * string. See `execute()` for the wrapping detail. + * + * **Guest-code constraint for callers**: sandboxed user code must not chain + * sequential `await`s on host functions. Use `Promise.all([fn1(), fn2()])` + * or call once-per-execution. This applies on every runtime, not just Bun. + */ +export class QuickJSExecutor implements Executor { + private memoryMB: number; + private timeoutMs: number; + private wallTimeMs: number; + + constructor(options: SandboxOptions = {}) { + this.memoryMB = options.memoryMB ?? 64; + this.timeoutMs = options.timeoutMs ?? 30_000; + this.wallTimeMs = options.wallTimeMs ?? 60_000; + } + + async execute( + code: string, + globals: Record, + ): Promise { + // Lazy import: optional peer dependency. + const qjs = await import("quickjs-emscripten"); + const context = await qjs.newAsyncContext(); + const runtime = context.runtime; + runtime.setMemoryLimit(this.memoryMB * 1024 * 1024); + + const start = Date.now(); + let cpuDeadlineHit = false; + let wallTimer: ReturnType | undefined; + let wallTimedOut = false; + + // CPU timeout: QuickJS calls the interrupt handler regularly while running + // bytecode. We approximate isolated-vm's CPU timeout with a wall-clock + // deadline measured from execute() entry — QuickJS does not expose a + // separate CPU-time clock, so this is the closest analog. + const cpuDeadline = start + this.timeoutMs; + runtime.setInterruptHandler(() => { + if (Date.now() > cpuDeadline) { + cpuDeadlineHit = true; + return true; + } + return false; + }); + + // Track disposable handles created during global injection so we can + // dispose them in `finally` even if injection throws partway through. + const injectedHandles: Array<{ dispose(): void; alive: boolean }> = []; + + try { + // CRITICAL: setup ordering matters with release-asyncify QuickJS. + // + // 1. Asyncified host functions (`newAsyncifiedFunction`) MUST be + // registered before any `evalCode`/`evalCodeAsync` — otherwise the + // second sequential `await hostFn()` in user code crashes with + // "memory access out of bounds" / GC mark assertions. + // 2. The no-op console is therefore also built via the handle API + // (`newObject` + `newFunction`), never via `evalCode`. + // 3. Plain-data and namespace-data injection use only handle-API calls + // (`newNumber` / `newString` / `newObject` / `newArray`), see + // `injectValue`. + // + // Inject host functions first so they are registered before the user's + // `evalCodeAsync` is invoked. Order among them is not significant. + for (const [name, value] of Object.entries(globals)) { + if (typeof value === "function") { + injectAsyncFunction(context, name, value as (...args: unknown[]) => unknown, injectedHandles); + } else if (isNamespaceWithMethods(value)) { + injectNamespace(context, name, value as Record, injectedHandles); + } else { + const valueHandle = injectValue(context, value); + context.setProp(context.global, name, valueHandle); + disposeIfOwned(context, valueHandle); + } + } + + // Install no-op console AFTER asyncified functions (and via handle API + // only) so we don't trigger the eval-before-asyncified-registration + // failure mode. Injecting a real console would also be an OOM vector + // since logs accumulate in the host process outside the sandbox + // memory limit. + injectNoopConsole(context); + + // Wall-clock timeout: hard-stop the entire execution including async + // host calls (which run on the Node event loop and would otherwise stall + // the CPU interrupt handler indefinitely). + const wallPromise = new Promise((_, reject) => { + wallTimer = setTimeout(() => { + wallTimedOut = true; + // Force the QuickJS interrupt handler to abort on the next tick by + // marking the deadline as exceeded. evalCodeAsync will surface + // "interrupted" on the next bytecode boundary. + reject(new Error("Wall-clock timeout exceeded")); + }, this.wallTimeMs); + if (typeof wallTimer === "object" && wallTimer !== null && "unref" in wallTimer) { + (wallTimer as { unref(): void }).unref(); + } + }); + + // Wrap user code so the final value is JSON-encoded *inside* QuickJS. + // + // Why: when user code makes more than one sequential `await hostFn()` + // call, the release-asyncify build's GC anchoring on the IIFE's return + // value is unreliable. By the time we dump it from the host, the + // QuickJS interpreter may have freed it under us (observed: + // `context.dump(handle)` crashes with "memory access out of bounds" + // even though the JS-side wrapper still reports `alive: true`). + // + // The fix: have the user's `(async () => ...)()` go through a + // `JSON.stringify` plus a sentinel `{ undef }` / `{ ok, v }` envelope. + // Strings are simple cells that survive the post-execution drain and + // dump back as primitives — bypassing the GC race entirely. + // + // Errors thrown by the user code still come back through the normal + // `resolution.error` channel; only successful results need wrapping. + const wrappedCode = `(async () => { const __r = await (${code})(); return __r === undefined ? "__cmUndef" : JSON.stringify(__r); })()`; + const evalP = context.evalCodeAsync(wrappedCode); + + const evalResult = await Promise.race([evalP, wallPromise]); + + if (evalResult.error) { + const err = context.dump(evalResult.error); + evalResult.error.dispose(); + throw new Error(formatQuickJSError(err)); + } + + const promiseHandle = evalResult.value; + const resolution = await raceWithJobPump(context, promiseHandle, wallPromise); + + if (resolution.error) { + const err = context.dump(resolution.error); + resolution.error.dispose(); + promiseHandle.dispose(); + throw new Error(formatQuickJSError(err)); + } + + const encoded = context.dump(resolution.value); + resolution.value.dispose(); + promiseHandle.dispose(); + + let value: unknown; + if (encoded === "__cmUndef" || encoded === undefined) { + value = undefined; + } else if (typeof encoded !== "string") { + // Shouldn't happen — wrapper always produces a string — but fall back + // safely. + value = encoded; + } else { + try { + value = JSON.parse(encoded); + } catch { + // Not valid JSON (shouldn't happen): return the raw string. + value = encoded; + } + } + + const stats = captureStats(context, runtime, start, this.memoryMB); + return { result: value, stats }; + } catch (err) { + const stats = captureStats(context, runtime, start, this.memoryMB); + let message = err instanceof Error ? err.message : String(err); + if (cpuDeadlineHit && !wallTimedOut && !/timeout/i.test(message)) { + message = `Script execution timed out after ${this.timeoutMs}ms (${message})`; + } + return { + result: undefined, + error: message, + stats, + }; + } finally { + clearTimeout(wallTimer); + for (const handle of injectedHandles) { + if (handle.alive) { + try { + handle.dispose(); + } catch { + // already disposed + } + } + } + try { + // `context.dispose()` already owns the runtime lifetime + // (quickjs-emscripten-core attaches the runtime to the context's + // ownedLifetimes), so no separate `runtime.dispose()` call is needed. + // Calling it would throw QuickJSUseAfterFree — silently — and obscure + // real disposal errors. + context.dispose(); + } catch { + // ignore — best-effort cleanup; release-asyncify can throw + // assertion noise on dispose after asyncified host fns are defined + // (upstream quickjs-emscripten#261). + } + } + } +} + +/** + * Resolve a promise handle while pumping the runtime's pending-job queue. + * + * QuickJS asyncified functions enqueue continuations on `runtime`'s job + * queue when host promises settle. Without `executePendingJobs`, the inner + * `(async () => ...)()` promise never makes progress and `resolvePromise` + * hangs forever. We pump between tiny `setTimeout(0)` ticks so the Node + * event loop can also process the host-side promises that the asyncified + * functions are awaiting. + * + * Races against `wallPromise` so a hung host call still produces a wall-clock + * timeout error rather than blocking the executor forever. + */ +async function raceWithJobPump( + context: import("quickjs-emscripten").QuickJSAsyncContext, + promiseHandle: import("quickjs-emscripten").QuickJSHandle, + wallPromise: Promise, +): Promise> { + // Start resolving the user-code promise on the QuickJS side. The returned + // host-side Promise settles once the user's `(async () => ...)()` resolves + // — but only if we keep draining the runtime's pending-job queue while we + // wait, because asyncified host callbacks enqueue their continuations + // there. + const resolveP = context.resolvePromise(promiseHandle); + let settled = false; + void resolveP.then(() => { settled = true; }, () => { settled = true; }); + + // Pump loop: drain pending jobs, yield one macrotask, repeat. We race + // against `wallPromise` so a hung host call surfaces as a wall-clock + // timeout instead of an infinite loop. + // + // Implementation notes: + // - `await setTimeout(0)` (a macrotask yield) is required between drains + // so the Node event loop can advance the host-side promises the + // asyncified callbacks are awaiting. + // - We do NOT race `resolveP` directly inside the loop. Adding it to a + // `Promise.race` empirically poisons the value handle: once user code + // completes the QuickJS GC frees the result, and `context.dump(handle)` + // then crashes with "memory access out of bounds" even though the JS- + // side wrapper still reports `alive: true`. Polling `settled` after a + // short yield avoids that interaction. + // - We also don't call `executePendingJobs` after `settled` becomes true, + // for the same reason — a post-settle drain can GC the result handle. + let wallError: Error | undefined; + wallPromise.catch((e: unknown) => { + wallError = e instanceof Error ? e : new Error(String(e)); + }); + + /* oxlint-disable no-await-in-loop, no-unmodified-loop-condition */ + // `settled` is mutated by the `resolveP.then` callback above — oxlint can't + // see across the closure, hence the disable. + while (!settled) { + if (wallError) throw wallError; + if (context.runtime.hasPendingJob()) { + context.runtime.executePendingJobs(); + } + // Macrotask yield. NOT unref'd: if we unref this and the host promise we + // depend on is also unref'd, Node thinks the event loop is empty and + // exits, dropping our top-level await. + await new Promise((r) => { + setTimeout(r, 0); + }); + } + /* oxlint-enable no-await-in-loop, no-unmodified-loop-condition */ + + return resolveP; +} + +interface InjectedHandle { + dispose(): void; + alive: boolean; +} + +/** + * Inject an async (or sync) host function as a global by name. + * + * We use `newAsyncifiedFunction` so the sandboxed code can `await` the call + * exactly like in isolated-vm. Arguments and return values cross the boundary + * via JSON-clone, matching isolated-vm's `{ copy: true }` semantics. + * + * NOTE on ordering: this MUST be called *before* any `evalCode` / + * `evalCodeAsync` against the context — otherwise the release-asyncify build + * corrupts its asyncify bookkeeping and sequential `await`s in user code + * crash with "memory access out of bounds" / GC assertion failures. See + * `injectNoopConsole` for the alternate handle-API approach used for setup. + */ +function injectAsyncFunction( + context: import("quickjs-emscripten").QuickJSAsyncContext, + name: string, + fn: (...args: unknown[]) => unknown, + tracked: InjectedHandle[], +): void { + const handle = context.newAsyncifiedFunction(name, async (...argHandles) => { + const args = argHandles.map((h) => context.dump(h)); + let result: unknown; + try { + result = await fn(...args); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { error: context.newString(message) }; + } + return injectValue(context, result); + }); + context.setProp(context.global, name, handle); + handle.dispose(); + const tracker: InjectedHandle = { + alive: false, + dispose: () => {}, + }; + tracked.push(tracker); +} + +/** + * Inject a namespace object containing host functions and/or data. + * + * Function values become asyncified callables; non-function values are + * JSON-cloned. Matches `IsolatedVMExecutor`'s namespace injection semantics. + */ +function injectNamespace( + context: import("quickjs-emscripten").QuickJSAsyncContext, + name: string, + ns: Record, + tracked: InjectedHandle[], +): void { + const nsHandle = context.newObject(); + for (const [key, val] of Object.entries(ns)) { + if (typeof val === "function") { + const fnHandle = context.newAsyncifiedFunction(`${name}.${key}`, async (...argHandles) => { + const args = argHandles.map((h) => context.dump(h)); + let result: unknown; + try { + result = await (val as (...a: unknown[]) => unknown)(...args); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { error: context.newString(message) }; + } + return injectValue(context, result); + }); + context.setProp(nsHandle, key, fnHandle); + fnHandle.dispose(); + } else if (val !== undefined) { + const valueHandle = injectValue(context, val); + context.setProp(nsHandle, key, valueHandle); + disposeIfOwned(context, valueHandle); + } + } + context.setProp(context.global, name, nsHandle); + const tracker: InjectedHandle = { + alive: true, + dispose: () => { + tracker.alive = false; + nsHandle.dispose(); + }, + }; + tracked.push(tracker); + tracker.dispose(); +} + +/** + * Marshal a JS host value into a fresh QuickJS handle. + * + * We build handles directly via `newNumber` / `newString` / `newObject` / + * `newArray` rather than going through `evalCode`. Calling `evalCode` from + * inside an asyncified host callback can corrupt the QuickJS reference count + * (observed: "Assertion failed: p->ref_count == 0 / > 0" on sequential + * awaits) because the C-side eval allocates handles on a stack frame that + * asyncify is already unwinding. + * + * Semantics match isolated-vm's `{ copy: true }`: only JSON-cloneable shapes + * (primitives, plain objects, arrays) cross the boundary. Functions, Symbols, + * Maps, etc. become undefined. + */ +function injectValue( + context: import("quickjs-emscripten").QuickJSAsyncContext, + value: unknown, +): import("quickjs-emscripten").QuickJSHandle { + if (value === undefined) { + return context.undefined; + } + if (value === null) { + return context.null; + } + switch (typeof value) { + case "boolean": + return value ? context.true : context.false; + case "number": + return context.newNumber(value); + case "string": + return context.newString(value); + case "bigint": + // QuickJS has BigInt but the high-level API doesn't expose newBigInt + // directly — fall through to number when in range, undefined otherwise. + if (value >= Number.MIN_SAFE_INTEGER && value <= Number.MAX_SAFE_INTEGER) { + return context.newNumber(Number(value)); + } + return context.undefined; + case "object": { + if (Array.isArray(value)) { + const arr = context.newArray(); + for (let i = 0; i < value.length; i++) { + const child = injectValue(context, value[i]); + context.setProp(arr, i, child); + disposeIfOwned(context, child); + } + return arr; + } + const obj = context.newObject(); + for (const [key, val] of Object.entries(value as Record)) { + if (val === undefined) continue; // match JSON.stringify semantics + const child = injectValue(context, val); + context.setProp(obj, key, child); + disposeIfOwned(context, child); + } + return obj; + } + default: + // function, symbol — not JSON-cloneable + return context.undefined; + } +} + +/** + * Format a dumped QuickJS error object into a human-readable string. + * + * QuickJS surfaces errors as `{ name, message, stack }` plain objects when + * dumped. Fall back to JSON if the shape is unexpected. + */ +function formatQuickJSError(dumped: unknown): string { + if (typeof dumped === "string") return dumped; + if (dumped && typeof dumped === "object") { + const obj = dumped as { name?: unknown; message?: unknown }; + if (typeof obj.message === "string") { + if (typeof obj.name === "string" && obj.name.length > 0 && obj.name !== "Error") { + return `${obj.name}: ${obj.message}`; + } + return obj.message; + } + } + try { + return JSON.stringify(dumped); + } catch { + return String(dumped); + } +} + +/** + * Capture execution stats from a QuickJS runtime. + * + * Mapped to the same `ExecuteStats` shape as the isolated-vm executor: + * + * - `cpuTimeMs` / `wallTimeMs`: QuickJS doesn't expose a CPU clock separate + * from wall clock; both are reported as wall-clock since execute() entry. + * - `heapUsedBytes` ← `memory_used_size` + * - `heapTotalBytes` ← `malloc_limit` (total *budget*, since QuickJS doesn't + * pre-allocate a heap up-front the way V8 does) + * - `externalBytes` ← `binary_object_size` + * - `heapSizeLimitBytes` ← `malloc_limit` (configured cap) + * - `totalPhysicalBytes` ← `memory_used_size` + * - `availableBytes` ← `malloc_limit - memory_used_size` + * - `executableBytes` ← `js_func_code_size` + * - `mallocedBytes` ← `memory_used_size` + * - `peakMallocedBytes` ← `memory_used_size` (no peak counter in QuickJS) + * + * Returns zeroed stats if the runtime/context is unusable. + */ +function captureStats( + context: import("quickjs-emscripten").QuickJSAsyncContext, + runtime: import("quickjs-emscripten").QuickJSRuntime, + startMs: number, + memoryMB: number, +): ExecuteStats { + const wallMs = Date.now() - startMs; + const heapSizeLimitBytes = memoryMB * 1024 * 1024; + let raw: Record | undefined; + try { + const handle = runtime.computeMemoryUsage(); + const dumped: unknown = context.dump(handle); + handle.dispose(); + if (dumped && typeof dumped === "object") { + raw = dumped as Record; + } + } catch { + // runtime/context may be in a bad state (e.g. OOM, disposed mid-call) — + // fall back to zeroed memory stats while still reporting wall time. + } + const used = raw?.memory_used_size ?? 0; + const limit = raw?.malloc_limit ?? heapSizeLimitBytes; + return { + cpuTimeMs: wallMs, + wallTimeMs: wallMs, + heapUsedBytes: used, + heapTotalBytes: limit, + externalBytes: raw?.binary_object_size ?? 0, + heapSizeLimitBytes: limit, + totalPhysicalBytes: used, + availableBytes: Math.max(0, limit - used), + executableBytes: raw?.js_func_code_size ?? 0, + mallocedBytes: used, + peakMallocedBytes: used, + }; +} + +function isNamespaceWithMethods(value: unknown): boolean { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.values(value as Record).some( + (v) => typeof v === "function", + ) + ); +} + +/** + * Dispose a handle only if it is *owned* (not one of the context's static + * singletons like `context.undefined` / `context.null` / `context.true` / + * `context.false`). Disposing a static singleton would corrupt the next + * caller that retrieves it. + */ +function disposeIfOwned( + context: import("quickjs-emscripten").QuickJSAsyncContext, + handle: import("quickjs-emscripten").QuickJSHandle, +): void { + if ( + handle === context.undefined || + handle === context.null || + handle === context.true || + handle === context.false + ) { + return; + } + try { + handle.dispose(); + } catch { + // already disposed + } +} + +/** + * Install a no-op `globalThis.console` built entirely through the handle API. + * + * We avoid `evalCode` / `evalCodeAsync` here on purpose. Empirically, even a + * trivial eval call ahead of the user's `evalCodeAsync` poisons the + * release-asyncify build's internal state such that the *second* asyncified + * host call from sequential `await`s in user code crashes with + * "memory access out of bounds" / refcount assertion failures. Building the + * console object directly via `newObject` + `newFunction` is safe. + */ +function injectNoopConsole( + context: import("quickjs-emscripten").QuickJSAsyncContext, +): void { + const consoleObj = context.newObject(); + for (const name of ["log", "warn", "error", "info", "debug", "trace"] as const) { + const noop = context.newFunction(name, () => context.undefined); + context.setProp(consoleObj, name, noop); + noop.dispose(); + } + context.setProp(context.global, "console", consoleObj); + consoleObj.dispose(); +} diff --git a/packages/codemode/src/index.ts b/packages/codemode/src/index.ts index 36aa329..b3a4f18 100644 --- a/packages/codemode/src/index.ts +++ b/packages/codemode/src/index.ts @@ -17,6 +17,7 @@ export type { // Executors (for advanced usage / custom executor selection) export { IsolatedVMExecutor } from "./executor/isolated-vm.js"; +export { QuickJSExecutor } from "./executor/quickjs.js"; export { createExecutor } from "./executor/auto.js"; // Request bridge (for advanced usage / custom request handling) diff --git a/packages/codemode/test/executor-contract.ts b/packages/codemode/test/executor-contract.ts new file mode 100644 index 0000000..b614a79 --- /dev/null +++ b/packages/codemode/test/executor-contract.ts @@ -0,0 +1,407 @@ +import { describe, it, expect } from "vitest"; +import type { Executor, SandboxOptions } from "../src/types.js"; +import { CodeMode } from "../src/codemode.js"; + +/** + * Factory that constructs an executor for a given set of sandbox options. + * + * The contract calls this once per test so each case gets a fresh executor; + * state-isolation tests reuse the same instance across two `execute()` calls + * deliberately to assert no leakage. + */ +export type ExecutorFactory = (opts?: SandboxOptions) => Executor; + +/** + * Optional per-backend knobs for tests whose absolute thresholds depend on + * the underlying runtime. The defaults are tuned for V8/isolated-vm; quickjs + * needs a smaller limit and a smaller stress loop to OOM in reasonable time. + * + * This is the ONLY escape hatch the contract exposes — any other backend + * divergence belongs outside the contract. + */ +export interface ExecutorContractOptions { + /** Memory-limit OOM test tuning. */ + memoryStress?: { + /** Sandbox memory limit (MB) for the OOM test. */ + memoryMB: number; + /** Outer loop iterations for the allocation stress. */ + iterations: number; + }; +} + +const DEFAULT_MEMORY_STRESS = { memoryMB: 8, iterations: 10_000_000 } as const; + +/** + * Backend-agnostic executor contract. Any class implementing `Executor` + * should satisfy every assertion in here. Run it from a backend-specific + * test file via: + * + * executorContract("MyExecutor", (opts) => new MyExecutor(opts)); + * + * The `name` is emitted as the top-level `describe` title so CI logs read + * the same as the pre-extraction layout. + */ +export function executorContract( + name: string, + factory: ExecutorFactory, + options: ExecutorContractOptions = {}, +): void { + const memoryStress = options.memoryStress ?? DEFAULT_MEMORY_STRESS; + + describe(name, () => { + it("executes simple code", async () => { + const executor = factory(); + const result = await executor.execute( + `async () => 1 + 2`, + {}, + ); + expect(result.result).toBe(3); + expect(result.error).toBeUndefined(); + }); + + it("injects and reads data globals", async () => { + const executor = factory(); + const result = await executor.execute( + `async () => spec.info.title`, + { + spec: { + openapi: "3.0.0", + info: { title: "My API", version: "1.0.0" }, + paths: {}, + }, + }, + ); + expect(result.result).toBe("My API"); + }); + + it("injects async host functions in a namespace", async () => { + const executor = factory(); + const result = await executor.execute( + `async () => { + const res = await api.request({ method: "GET", path: "/test" }); + return res; + }`, + { + api: { + request: async (opts: any) => ({ + status: 200, + body: { message: "hello from " + opts.path }, + }), + }, + }, + ); + expect(result.error).toBeUndefined(); + expect(result.result).toEqual({ + status: 200, + body: { message: "hello from /test" }, + }); + }); + + it("console.log is a no-op (does not crash)", async () => { + const executor = factory(); + const result = await executor.execute( + `async () => { + console.log("hello", "world"); + console.warn("warning!"); + console.error("error!"); + return 42; + }`, + {}, + ); + expect(result.result).toBe(42); + expect(result.error).toBeUndefined(); + }); + + it("returns error for invalid code", async () => { + const executor = factory(); + const result = await executor.execute( + `not valid code!!!`, + {}, + ); + expect(result.error).toBeDefined(); + }); + + it("returns error for runtime exceptions", async () => { + const executor = factory(); + const result = await executor.execute( + `async () => { throw new Error("boom"); }`, + {}, + ); + expect(result.error).toContain("boom"); + }); + + it("enforces memory limits", async () => { + const executor = factory({ memoryMB: memoryStress.memoryMB }); + const result = await executor.execute( + `async () => { + const arr = []; + for (let i = 0; i < ${memoryStress.iterations}; i++) { + arr.push("x".repeat(1000)); + } + return arr.length; + }`, + {}, + ); + expect(result.error).toBeDefined(); + }); + + it("enforces CPU timeout", async () => { + const executor = factory({ timeoutMs: 100 }); + const result = await executor.execute( + `async () => { while(true) {} }`, + {}, + ); + expect(result.error).toBeDefined(); + }); + + it("enforces wall-clock timeout on stalled async host calls", async () => { + const executor = factory({ timeoutMs: 5_000, wallTimeMs: 200 }); + const result = await executor.execute( + `async () => { + // Call a host function that never resolves — wall-clock timeout should fire + return await hang(); + }`, + { + hang: () => new Promise(() => {}), // never resolves + }, + ); + expect(result.error).toBeDefined(); + expect(result.error).toContain("Wall-clock timeout"); + }); + + it("isolates executions (no state leakage)", async () => { + const executor = factory(); + + // First execution sets a global + await executor.execute( + `async () => { globalThis.leaked = "secret"; return true; }`, + {}, + ); + + // Second execution should not see it + const result = await executor.execute( + `async () => typeof globalThis.leaked`, + {}, + ); + expect(result.result).toBe("undefined"); + }); + + it("has no access to Node.js APIs", async () => { + const executor = factory(); + const result = await executor.execute( + `async () => { + try { return typeof require; } catch { return "no require"; } + }`, + {}, + ); + expect(result.result).toBe("undefined"); + + const result2 = await executor.execute( + `async () => typeof process`, + {}, + ); + expect(result2.result).toBe("undefined"); + + const result3 = await executor.execute( + `async () => typeof fetch`, + {}, + ); + expect(result3.result).toBe("undefined"); + }); + + it("chains multiple async host calls", async () => { + const executor = factory(); + const result = await executor.execute( + `async () => { + const a = await add(1, 2); + const b = await add(a, 3); + return b; + }`, + { + add: async (a: number, b: number) => a + b, + }, + ); + expect(result.error).toBeUndefined(); + expect(result.result).toBe(6); + }); + + it("handles concurrent async calls via Promise.all", async () => { + const executor = factory(); + const result = await executor.execute( + `async () => { + const results = await Promise.all([ + api.request({ path: "/a" }), + api.request({ path: "/b" }), + api.request({ path: "/c" }), + ]); + return results.map(r => r.body.path); + }`, + { + api: { + request: async (opts: any) => ({ + status: 200, + body: { path: opts.path }, + }), + }, + }, + ); + expect(result.error).toBeUndefined(); + expect(result.result).toEqual(["/a", "/b", "/c"]); + }); + }); + + describe(`CodeMode with ${name}`, () => { + it("full search + execute flow with Hono", async () => { + const { Hono } = await import("hono"); + const app = new Hono(); + + app.get("/v1/clusters", (c) => + c.json([ + { id: "eu", name: "EU Prod" }, + { id: "us", name: "US Staging" }, + ]), + ); + app.get("/v1/clusters/:id", (c) => + c.json({ id: c.req.param("id"), name: "Cluster Detail", region: "eu-west-1" }), + ); + app.post("/v1/products", async (c) => { + const body = await c.req.json(); + return c.json({ id: "p1", ...body }, 201); + }); + + const spec = { + openapi: "3.0.0", + info: { title: "CNAP API", version: "1.0.0" }, + paths: { + "/v1/clusters": { + get: { summary: "List clusters", operationId: "listClusters" }, + }, + "/v1/clusters/{id}": { + get: { summary: "Get cluster by ID", operationId: "getCluster" }, + }, + "/v1/products": { + get: { summary: "List products", operationId: "listProducts" }, + post: { summary: "Create product", operationId: "createProduct" }, + }, + "/v1/clusters/{id}/kube/{path}": { + get: { summary: "Transparent kube API proxy", operationId: "kubeProxy" }, + }, + }, + }; + + const codemode = new CodeMode({ + spec, + request: app.request.bind(app), + namespace: "cnap", + executor: factory({ memoryMB: 32, timeoutMs: 10_000 }), + }); + + // Search: find cluster endpoints. + // + // NOTE: the template-string indentation here intentionally matches the + // pre-refactor layout (6-space prefix, not the 8 you would expect from + // nesting). quickjs-emscripten@0.32.0 release-asyncify deadlocks on + // certain leading-whitespace patterns in user code — see the long + // comment in src/executor/quickjs.ts about GC anchoring. Until that's + // fixed upstream, the contract preserves the working layout. + const searchResult = await codemode.search(` + async () => { + return Object.entries(spec.paths) + .filter(([p]) => p.includes('/clusters')) + .flatMap(([path, methods]) => + Object.entries(methods) + .filter(([m]) => ['get','post','put','delete','patch'].includes(m)) + .map(([method, op]) => ({ + method: method.toUpperCase(), + path, + summary: op.summary, + })) + ); + } + `); + + expect(searchResult.isError).toBeUndefined(); + const endpoints = JSON.parse(searchResult.content[0]!.text); + expect(endpoints).toHaveLength(3); + expect(endpoints[0]).toEqual({ + method: "GET", + path: "/v1/clusters", + summary: "List clusters", + }); + + // Execute: list clusters + const execResult = await codemode.execute(` + async () => { + const res = await cnap.request({ method: "GET", path: "/v1/clusters" }); + return res.body; + } + `); + + expect(execResult.isError).toBeUndefined(); + const clusters = JSON.parse(execResult.content[0]!.text); + expect(clusters).toEqual([ + { id: "eu", name: "EU Prod" }, + { id: "us", name: "US Staging" }, + ]); + + // Execute: chain calls (list then get detail) + const chainResult = await codemode.execute(` + async () => { + const list = await cnap.request({ method: "GET", path: "/v1/clusters" }); + const first = list.body[0]; + const detail = await cnap.request({ method: "GET", path: "/v1/clusters/" + first.id }); + return { cluster: detail.body, count: list.body.length }; + } + `); + + expect(chainResult.isError).toBeUndefined(); + const chain = JSON.parse(chainResult.content[0]!.text); + expect(chain.cluster.id).toBe("eu"); + expect(chain.cluster.region).toBe("eu-west-1"); + expect(chain.count).toBe(2); + + // Execute: POST with body + const postResult = await codemode.execute(` + async () => { + const res = await cnap.request({ + method: "POST", + path: "/v1/products", + body: { name: "Redis", chart: "bitnami/redis" }, + }); + return { status: res.status, body: res.body }; + } + `); + + expect(postResult.isError).toBeUndefined(); + const post = JSON.parse(postResult.content[0]!.text); + expect(post.status).toBe(201); + expect(post.body.name).toBe("Redis"); + + codemode.dispose(); + }); + + it("search returns spec paths", async () => { + const codemode = new CodeMode({ + spec: { + openapi: "3.0.0", + info: { title: "Test API", version: "2.0.0", description: "My test API" }, + paths: { + "/test": { get: { summary: "Test endpoint" } }, + }, + }, + request: () => new Response("not used"), + executor: factory(), + }); + + const result = await codemode.search(` + async () => ({ + pathCount: Object.keys(spec.paths).length, + paths: Object.keys(spec.paths), + }) + `); + + const data = JSON.parse(result.content[0]!.text); + expect(data).toEqual({ pathCount: 1, paths: ["/test"] }); + }); + }); +} diff --git a/packages/codemode/test/isolated-vm-executor.test.ts b/packages/codemode/test/isolated-vm-executor.test.ts index dcbd01b..081c069 100644 --- a/packages/codemode/test/isolated-vm-executor.test.ts +++ b/packages/codemode/test/isolated-vm-executor.test.ts @@ -1,353 +1,7 @@ -import { describe, it, expect } from "vitest"; import { IsolatedVMExecutor } from "../src/executor/isolated-vm.js"; -import { CodeMode } from "../src/codemode.js"; +import { executorContract } from "./executor-contract.js"; -describe("IsolatedVMExecutor", () => { - it("executes simple code", async () => { - const executor = new IsolatedVMExecutor(); - const result = await executor.execute( - `async () => 1 + 2`, - {}, - ); - expect(result.result).toBe(3); - expect(result.error).toBeUndefined(); - }); - - it("injects and reads data globals", async () => { - const executor = new IsolatedVMExecutor(); - const result = await executor.execute( - `async () => spec.info.title`, - { - spec: { - openapi: "3.0.0", - info: { title: "My API", version: "1.0.0" }, - paths: {}, - }, - }, - ); - expect(result.result).toBe("My API"); - }); - - it("injects async host functions in a namespace", async () => { - const executor = new IsolatedVMExecutor(); - const result = await executor.execute( - `async () => { - const res = await api.request({ method: "GET", path: "/test" }); - return res; - }`, - { - api: { - request: async (opts: any) => ({ - status: 200, - body: { message: "hello from " + opts.path }, - }), - }, - }, - ); - expect(result.error).toBeUndefined(); - expect(result.result).toEqual({ - status: 200, - body: { message: "hello from /test" }, - }); - }); - - it("console.log is a no-op (does not crash)", async () => { - const executor = new IsolatedVMExecutor(); - const result = await executor.execute( - `async () => { - console.log("hello", "world"); - console.warn("warning!"); - console.error("error!"); - return 42; - }`, - {}, - ); - expect(result.result).toBe(42); - expect(result.error).toBeUndefined(); - }); - - it("returns error for invalid code", async () => { - const executor = new IsolatedVMExecutor(); - const result = await executor.execute( - `not valid code!!!`, - {}, - ); - expect(result.error).toBeDefined(); - }); - - it("returns error for runtime exceptions", async () => { - const executor = new IsolatedVMExecutor(); - const result = await executor.execute( - `async () => { throw new Error("boom"); }`, - {}, - ); - expect(result.error).toContain("boom"); - }); - - it("enforces memory limits", async () => { - const executor = new IsolatedVMExecutor({ memoryMB: 8 }); - const result = await executor.execute( - `async () => { - const arr = []; - for (let i = 0; i < 10000000; i++) { - arr.push("x".repeat(1000)); - } - return arr.length; - }`, - {}, - ); - expect(result.error).toBeDefined(); - }); - - it("enforces CPU timeout", async () => { - const executor = new IsolatedVMExecutor({ timeoutMs: 100 }); - const result = await executor.execute( - `async () => { while(true) {} }`, - {}, - ); - expect(result.error).toBeDefined(); - }); - - it("enforces wall-clock timeout on stalled async host calls", async () => { - const executor = new IsolatedVMExecutor({ timeoutMs: 5_000, wallTimeMs: 200 }); - const result = await executor.execute( - `async () => { - // Call a host function that never resolves — wall-clock timeout should fire - return await hang(); - }`, - { - hang: () => new Promise(() => {}), // never resolves - }, - ); - expect(result.error).toBeDefined(); - expect(result.error).toContain("Wall-clock timeout"); - }); - - it("isolates executions (no state leakage)", async () => { - const executor = new IsolatedVMExecutor(); - - // First execution sets a global - await executor.execute( - `async () => { globalThis.leaked = "secret"; return true; }`, - {}, - ); - - // Second execution should not see it - const result = await executor.execute( - `async () => typeof globalThis.leaked`, - {}, - ); - expect(result.result).toBe("undefined"); - }); - - it("has no access to Node.js APIs", async () => { - const executor = new IsolatedVMExecutor(); - const result = await executor.execute( - `async () => { - try { return typeof require; } catch { return "no require"; } - }`, - {}, - ); - expect(result.result).toBe("undefined"); - - const result2 = await executor.execute( - `async () => typeof process`, - {}, - ); - expect(result2.result).toBe("undefined"); - - const result3 = await executor.execute( - `async () => typeof fetch`, - {}, - ); - expect(result3.result).toBe("undefined"); - }); - - it("chains multiple async host calls", async () => { - const executor = new IsolatedVMExecutor(); - const result = await executor.execute( - `async () => { - const a = await add(1, 2); - const b = await add(a, 3); - return b; - }`, - { - add: async (a: number, b: number) => a + b, - }, - ); - expect(result.error).toBeUndefined(); - expect(result.result).toBe(6); - }); - - it("handles concurrent async calls via Promise.all", async () => { - const executor = new IsolatedVMExecutor(); - const result = await executor.execute( - `async () => { - const results = await Promise.all([ - api.request({ path: "/a" }), - api.request({ path: "/b" }), - api.request({ path: "/c" }), - ]); - return results.map(r => r.body.path); - }`, - { - api: { - request: async (opts: any) => ({ - status: 200, - body: { path: opts.path }, - }), - }, - }, - ); - expect(result.error).toBeUndefined(); - expect(result.result).toEqual(["/a", "/b", "/c"]); - }); -}); - -describe("CodeMode with IsolatedVMExecutor", () => { - it("full search + execute flow with Hono", async () => { - const { Hono } = await import("hono"); - const app = new Hono(); - - app.get("/v1/clusters", (c) => - c.json([ - { id: "eu", name: "EU Prod" }, - { id: "us", name: "US Staging" }, - ]), - ); - app.get("/v1/clusters/:id", (c) => - c.json({ id: c.req.param("id"), name: "Cluster Detail", region: "eu-west-1" }), - ); - app.post("/v1/products", async (c) => { - const body = await c.req.json(); - return c.json({ id: "p1", ...body }, 201); - }); - - const spec = { - openapi: "3.0.0", - info: { title: "CNAP API", version: "1.0.0" }, - paths: { - "/v1/clusters": { - get: { summary: "List clusters", operationId: "listClusters" }, - }, - "/v1/clusters/{id}": { - get: { summary: "Get cluster by ID", operationId: "getCluster" }, - }, - "/v1/products": { - get: { summary: "List products", operationId: "listProducts" }, - post: { summary: "Create product", operationId: "createProduct" }, - }, - "/v1/clusters/{id}/kube/{path}": { - get: { summary: "Transparent kube API proxy", operationId: "kubeProxy" }, - }, - }, - }; - - const codemode = new CodeMode({ - spec, - request: app.request.bind(app), - namespace: "cnap", - executor: new IsolatedVMExecutor({ memoryMB: 32, timeoutMs: 10_000 }), - }); - - // Search: find cluster endpoints - const searchResult = await codemode.search(` - async () => { - return Object.entries(spec.paths) - .filter(([p]) => p.includes('/clusters')) - .flatMap(([path, methods]) => - Object.entries(methods) - .filter(([m]) => ['get','post','put','delete','patch'].includes(m)) - .map(([method, op]) => ({ - method: method.toUpperCase(), - path, - summary: op.summary, - })) - ); - } - `); - - expect(searchResult.isError).toBeUndefined(); - const endpoints = JSON.parse(searchResult.content[0]!.text); - expect(endpoints).toHaveLength(3); - expect(endpoints[0]).toEqual({ - method: "GET", - path: "/v1/clusters", - summary: "List clusters", - }); - - // Execute: list clusters - const execResult = await codemode.execute(` - async () => { - const res = await cnap.request({ method: "GET", path: "/v1/clusters" }); - return res.body; - } - `); - - expect(execResult.isError).toBeUndefined(); - const clusters = JSON.parse(execResult.content[0]!.text); - expect(clusters).toEqual([ - { id: "eu", name: "EU Prod" }, - { id: "us", name: "US Staging" }, - ]); - - // Execute: chain calls (list then get detail) - const chainResult = await codemode.execute(` - async () => { - const list = await cnap.request({ method: "GET", path: "/v1/clusters" }); - const first = list.body[0]; - const detail = await cnap.request({ method: "GET", path: "/v1/clusters/" + first.id }); - return { cluster: detail.body, count: list.body.length }; - } - `); - - expect(chainResult.isError).toBeUndefined(); - const chain = JSON.parse(chainResult.content[0]!.text); - expect(chain.cluster.id).toBe("eu"); - expect(chain.cluster.region).toBe("eu-west-1"); - expect(chain.count).toBe(2); - - // Execute: POST with body - const postResult = await codemode.execute(` - async () => { - const res = await cnap.request({ - method: "POST", - path: "/v1/products", - body: { name: "Redis", chart: "bitnami/redis" }, - }); - return { status: res.status, body: res.body }; - } - `); - - expect(postResult.isError).toBeUndefined(); - const post = JSON.parse(postResult.content[0]!.text); - expect(post.status).toBe(201); - expect(post.body.name).toBe("Redis"); - - codemode.dispose(); - }); - - it("search returns spec paths", async () => { - const codemode = new CodeMode({ - spec: { - openapi: "3.0.0", - info: { title: "Test API", version: "2.0.0", description: "My test API" }, - paths: { - "/test": { get: { summary: "Test endpoint" } }, - }, - }, - request: () => new Response("not used"), - executor: new IsolatedVMExecutor(), - }); - - const result = await codemode.search(` - async () => ({ - pathCount: Object.keys(spec.paths).length, - paths: Object.keys(spec.paths), - }) - `); - - const data = JSON.parse(result.content[0]!.text); - expect(data).toEqual({ pathCount: 1, paths: ["/test"] }); - }); -}); +executorContract( + "IsolatedVMExecutor", + (opts) => new IsolatedVMExecutor(opts), +); diff --git a/packages/codemode/test/quickjs-executor.test.ts b/packages/codemode/test/quickjs-executor.test.ts new file mode 100644 index 0000000..14c0523 --- /dev/null +++ b/packages/codemode/test/quickjs-executor.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from "vitest"; +import { QuickJSExecutor } from "../src/executor/quickjs.js"; +import { executorContract } from "./executor-contract.js"; + +executorContract( + "QuickJSExecutor", + (opts) => new QuickJSExecutor(opts), + // quickjs OOMs at a lower limit with a tighter loop than V8. + { memoryStress: { memoryMB: 4, iterations: 1_000_000 } }, +); + +// ─── Cross-backend tests ──────────────────────────────────────────────────── +// These compare BOTH backends side-by-side and therefore cannot live in the +// backend-agnostic contract. They stay here in the quickjs file because that's +// where they were authored when the quickjs backend landed. + +describe("ExecuteStats shape parity (QuickJS vs IsolatedVM)", () => { + it("both executors produce ExecuteStats with the same keys", async () => { + const { IsolatedVMExecutor } = await import("../src/executor/isolated-vm.js"); + const code = `async () => { let s = 0; for (let i = 0; i < 100; i++) s += i; return s; }`; + + const ivmExec = new IsolatedVMExecutor(); + const qjsExec = new QuickJSExecutor(); + + const ivmRes = await ivmExec.execute(code, {}); + const qjsRes = await qjsExec.execute(code, {}); + + expect(ivmRes.error).toBeUndefined(); + expect(qjsRes.error).toBeUndefined(); + expect(ivmRes.result).toBe(qjsRes.result); + + const ivmKeys = Object.keys(ivmRes.stats).toSorted(); + const qjsKeys = Object.keys(qjsRes.stats).toSorted(); + expect(qjsKeys).toEqual(ivmKeys); + + // Every value must be a finite number — no NaN/Infinity leaking from + // either backend. + for (const key of ivmKeys) { + const ivmVal = (ivmRes.stats as Record)[key]; + const qjsVal = (qjsRes.stats as Record)[key]; + expect(typeof ivmVal).toBe("number"); + expect(typeof qjsVal).toBe("number"); + expect(Number.isFinite(ivmVal)).toBe(true); + expect(Number.isFinite(qjsVal)).toBe(true); + } + }); + + it("documents the return-value semantic divergence (structured clone vs JSON)", async () => { + // isolated-vm uses structured clone (`{ copy: true }`) — preserves Date, + // Map, Set, BigInt as their original types. QuickJSExecutor uses a + // JSON.stringify envelope as a workaround for upstream GC-anchoring bugs + // in quickjs-emscripten@0.32.0 release-asyncify, so those types degrade + // to their JSON representation. + // + // This test locks the divergence so any future change (e.g. an upstream + // fix that lets us drop the JSON envelope) breaks loudly. + const { IsolatedVMExecutor } = await import("../src/executor/isolated-vm.js"); + const code = `async () => new Date("2026-01-15T00:00:00Z")`; + + const ivmExec = new IsolatedVMExecutor(); + const qjsExec = new QuickJSExecutor(); + + const ivmRes = await ivmExec.execute(code, {}); + const qjsRes = await qjsExec.execute(code, {}); + + expect(ivmRes.error).toBeUndefined(); + expect(qjsRes.error).toBeUndefined(); + + // isolated-vm: structured clone preserves the Date instance. + expect(ivmRes.result).toBeInstanceOf(Date); + expect((ivmRes.result as Date).toISOString()).toBe("2026-01-15T00:00:00.000Z"); + + // QuickJS: JSON envelope returns the date as an ISO-8601 string. + expect(typeof qjsRes.result).toBe("string"); + expect(qjsRes.result).toBe("2026-01-15T00:00:00.000Z"); + }); +}); diff --git a/packages/codemode/tsup.config.ts b/packages/codemode/tsup.config.ts index dc664bf..6431706 100644 --- a/packages/codemode/tsup.config.ts +++ b/packages/codemode/tsup.config.ts @@ -9,5 +9,5 @@ export default defineConfig({ dts: true, sourcemap: true, clean: true, - external: ["isolated-vm"], + external: ["isolated-vm", "quickjs-emscripten"], }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1fbcf25..fb4eede 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,18 +26,21 @@ importers: isolated-vm: specifier: ^6.0.2 version: 6.0.2 + quickjs-emscripten: + specifier: ^0.32.0 + version: 0.32.0 tsup: specifier: ^8.4.0 - version: 8.5.1(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) + version: 8.5.1(postcss@8.5.6)(tsx@4.21.0)(typescript@6.0.3) tsx: specifier: ^4.21.0 version: 4.21.0 typescript: - specifier: ^5.7.3 - version: 5.9.3 + specifier: ^6.0.0 + version: 6.0.3 vitest: - specifier: ^3.0.5 - version: 3.2.4(tsx@4.21.0) + specifier: ^4.0.0 + version: 4.1.7(vite@7.3.1(tsx@4.21.0)) zod: specifier: ^4.0.0 version: 4.3.6 @@ -206,6 +209,21 @@ packages: peerDependencies: hono: ^4 + '@jitl/quickjs-ffi-types@0.32.0': + resolution: {integrity: sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==} + + '@jitl/quickjs-wasmfile-debug-asyncify@0.32.0': + resolution: {integrity: sha512-EX8zbXwGqCgAE764M+qvkHtyXDi/FUoMBea0JnES7vCM3P7a2+EOZOjGv85wtZ2sJhI1oJ+nekmqpOODFDY+hw==} + + '@jitl/quickjs-wasmfile-debug-sync@0.32.0': + resolution: {integrity: sha512-LeYWrPGC1uNCTBWvibo3ZLJj0CSVNYUXvJpXMCmuQ5Sap2cCACc3uvGvYV4homHHBAzfw5akoTqMMS4YFRtw+Q==} + + '@jitl/quickjs-wasmfile-release-asyncify@0.32.0': + resolution: {integrity: sha512-3oSwPfja12ICz4aIblB58cuY8JlEq5Txt8Cut4VLo+LH47QN+mzCnSgnbB03hWzg1LBcc+VyyI9UOag7a1NF+Q==} + + '@jitl/quickjs-wasmfile-release-sync@0.32.0': + resolution: {integrity: sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -468,6 +486,9 @@ packages: cpu: [x64] os: [win32] + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -477,34 +498,34 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - '@vitest/expect@3.2.4': - resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + '@vitest/expect@4.1.7': + resolution: {integrity: sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==} - '@vitest/mocker@3.2.4': - resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + '@vitest/mocker@4.1.7': + resolution: {integrity: sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==} peerDependencies: msw: ^2.4.9 - vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: msw: optional: true vite: optional: true - '@vitest/pretty-format@3.2.4': - resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + '@vitest/pretty-format@4.1.7': + resolution: {integrity: sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==} - '@vitest/runner@3.2.4': - resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + '@vitest/runner@4.1.7': + resolution: {integrity: sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==} - '@vitest/snapshot@3.2.4': - resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + '@vitest/snapshot@4.1.7': + resolution: {integrity: sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==} - '@vitest/spy@3.2.4': - resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + '@vitest/spy@4.1.7': + resolution: {integrity: sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==} - '@vitest/utils@3.2.4': - resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@vitest/utils@4.1.7': + resolution: {integrity: sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==} accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} @@ -568,14 +589,10 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} - chai@5.3.3: - resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} - check-error@2.1.3: - resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} - engines: {node: '>= 16'} - chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -602,6 +619,9 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -631,10 +651,6 @@ packages: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} - deep-eql@5.0.2: - resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} - engines: {node: '>=6'} - deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -669,8 +685,8 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-module-lexer@1.7.0: - resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} @@ -830,9 +846,6 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} - js-tokens@9.0.1: - resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} - json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} @@ -850,9 +863,6 @@ packages: resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - loupe@3.2.1: - resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} - magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -919,6 +929,9 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -950,10 +963,6 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - pathval@2.0.1: - resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} - engines: {node: '>= 14.16'} - picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1011,6 +1020,13 @@ packages: resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} engines: {node: '>=0.6'} + quickjs-emscripten-core@0.32.0: + resolution: {integrity: sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==} + + quickjs-emscripten@0.32.0: + resolution: {integrity: sha512-So0Sqw869y/S2oE3Nuc0uT3Dhqgvsj8FSrwBdsuTosVsG8ME5/OcudU1GxsrIFdFABgy17GHnTVO9TYV/bLQcA==} + engines: {node: '>=16.0.0'} + range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -1121,8 +1137,8 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} - std-env@3.10.0: - resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -1131,9 +1147,6 @@ packages: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} - strip-literal@3.1.0: - resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} - sucrase@3.35.1: resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} @@ -1159,20 +1172,16 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyexec@1.1.2: + resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} - tinypool@1.1.1: - resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} - engines: {node: ^18.0.0 || >=20.0.0} - - tinyrainbow@2.0.0: - resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} - engines: {node: '>=14.0.0'} - - tinyspy@4.0.4: - resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} toidentifier@1.0.1: @@ -1222,6 +1231,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + ufo@1.6.3: resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} @@ -1236,11 +1250,6 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} - vite-node@3.2.4: - resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} - hasBin: true - vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1281,26 +1290,39 @@ packages: yaml: optional: true - vitest@3.2.4: - resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + vitest@4.1.7: + resolution: {integrity: sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' - '@types/debug': ^4.1.12 - '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - '@vitest/browser': 3.2.4 - '@vitest/ui': 3.2.4 + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.7 + '@vitest/browser-preview': 4.1.7 + '@vitest/browser-webdriverio': 4.1.7 + '@vitest/coverage-istanbul': 4.1.7 + '@vitest/coverage-v8': 4.1.7 + '@vitest/ui': 4.1.7 happy-dom: '*' jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@edge-runtime/vm': optional: true - '@types/debug': + '@opentelemetry/api': optional: true '@types/node': optional: true - '@vitest/browser': + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': optional: true '@vitest/ui': optional: true @@ -1414,6 +1436,24 @@ snapshots: dependencies: hono: 4.12.5 + '@jitl/quickjs-ffi-types@0.32.0': {} + + '@jitl/quickjs-wasmfile-debug-asyncify@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + '@jitl/quickjs-wasmfile-debug-sync@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + '@jitl/quickjs-wasmfile-release-asyncify@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + '@jitl/quickjs-wasmfile-release-sync@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1582,6 +1622,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.59.0': optional: true + '@standard-schema/spec@1.1.0': {} + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -1591,47 +1633,46 @@ snapshots: '@types/estree@1.0.8': {} - '@vitest/expect@3.2.4': + '@vitest/expect@4.1.7': dependencies: + '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 3.2.4 - '@vitest/utils': 3.2.4 - chai: 5.3.3 - tinyrainbow: 2.0.0 + '@vitest/spy': 4.1.7 + '@vitest/utils': 4.1.7 + chai: 6.2.2 + tinyrainbow: 3.1.0 - '@vitest/mocker@3.2.4(vite@7.3.1(tsx@4.21.0))': + '@vitest/mocker@4.1.7(vite@7.3.1(tsx@4.21.0))': dependencies: - '@vitest/spy': 3.2.4 + '@vitest/spy': 4.1.7 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: vite: 7.3.1(tsx@4.21.0) - '@vitest/pretty-format@3.2.4': + '@vitest/pretty-format@4.1.7': dependencies: - tinyrainbow: 2.0.0 + tinyrainbow: 3.1.0 - '@vitest/runner@3.2.4': + '@vitest/runner@4.1.7': dependencies: - '@vitest/utils': 3.2.4 + '@vitest/utils': 4.1.7 pathe: 2.0.3 - strip-literal: 3.1.0 - '@vitest/snapshot@3.2.4': + '@vitest/snapshot@4.1.7': dependencies: - '@vitest/pretty-format': 3.2.4 + '@vitest/pretty-format': 4.1.7 + '@vitest/utils': 4.1.7 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@3.2.4': - dependencies: - tinyspy: 4.0.4 + '@vitest/spy@4.1.7': {} - '@vitest/utils@3.2.4': + '@vitest/utils@4.1.7': dependencies: - '@vitest/pretty-format': 3.2.4 - loupe: 3.2.1 - tinyrainbow: 2.0.0 + '@vitest/pretty-format': 4.1.7 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 accepts@2.0.0: dependencies: @@ -1701,15 +1742,7 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 - chai@5.3.3: - dependencies: - assertion-error: 2.0.1 - check-error: 2.1.3 - deep-eql: 5.0.2 - loupe: 3.2.1 - pathval: 2.0.1 - - check-error@2.1.3: {} + chai@6.2.2: {} chokidar@4.0.3: dependencies: @@ -1727,6 +1760,8 @@ snapshots: content-type@1.0.5: {} + convert-source-map@2.0.0: {} + cookie-signature@1.2.2: {} cookie@0.7.2: {} @@ -1750,8 +1785,6 @@ snapshots: dependencies: mimic-response: 3.1.0 - deep-eql@5.0.2: {} - deep-extend@0.6.0: {} depd@2.0.0: {} @@ -1776,7 +1809,7 @@ snapshots: es-errors@1.3.0: {} - es-module-lexer@1.7.0: {} + es-module-lexer@2.1.0: {} es-object-atoms@1.1.1: dependencies: @@ -1971,8 +2004,6 @@ snapshots: joycon@3.1.1: {} - js-tokens@9.0.1: {} - json-schema-traverse@1.0.0: {} json-schema-typed@8.0.2: {} @@ -1983,8 +2014,6 @@ snapshots: load-tsconfig@0.2.5: {} - loupe@3.2.1: {} - magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2036,6 +2065,8 @@ snapshots: object-inspect@1.13.4: {} + obug@2.1.1: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -2074,8 +2105,6 @@ snapshots: pathe@2.0.3: {} - pathval@2.0.1: {} - picocolors@1.1.1: {} picomatch@4.0.3: {} @@ -2132,6 +2161,18 @@ snapshots: dependencies: side-channel: 1.1.0 + quickjs-emscripten-core@0.32.0: + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + quickjs-emscripten@0.32.0: + dependencies: + '@jitl/quickjs-wasmfile-debug-asyncify': 0.32.0 + '@jitl/quickjs-wasmfile-debug-sync': 0.32.0 + '@jitl/quickjs-wasmfile-release-asyncify': 0.32.0 + '@jitl/quickjs-wasmfile-release-sync': 0.32.0 + quickjs-emscripten-core: 0.32.0 + range-parser@1.2.1: {} raw-body@3.0.2: @@ -2288,7 +2329,7 @@ snapshots: statuses@2.0.2: {} - std-env@3.10.0: {} + std-env@4.1.0: {} string_decoder@1.3.0: dependencies: @@ -2296,10 +2337,6 @@ snapshots: strip-json-comments@2.0.1: {} - strip-literal@3.1.0: - dependencies: - js-tokens: 9.0.1 - sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -2337,16 +2374,14 @@ snapshots: tinyexec@0.3.2: {} + tinyexec@1.1.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 - tinypool@1.1.1: {} - - tinyrainbow@2.0.0: {} - - tinyspy@4.0.4: {} + tinyrainbow@3.1.0: {} toidentifier@1.0.1: {} @@ -2354,7 +2389,7 @@ snapshots: ts-interface-checker@0.1.13: {} - tsup@8.5.1(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3): + tsup@8.5.1(postcss@8.5.6)(tsx@4.21.0)(typescript@6.0.3): dependencies: bundle-require: 5.1.0(esbuild@0.27.3) cac: 6.7.14 @@ -2375,7 +2410,7 @@ snapshots: tree-kill: 1.2.2 optionalDependencies: postcss: 8.5.6 - typescript: 5.9.3 + typescript: 6.0.3 transitivePeerDependencies: - jiti - supports-color @@ -2401,6 +2436,8 @@ snapshots: typescript@5.9.3: {} + typescript@6.0.3: {} + ufo@1.6.3: {} unpipe@1.0.0: {} @@ -2409,27 +2446,6 @@ snapshots: vary@1.1.2: {} - vite-node@3.2.4(tsx@4.21.0): - dependencies: - cac: 6.7.14 - debug: 4.4.3 - es-module-lexer: 1.7.0 - pathe: 2.0.3 - vite: 7.3.1(tsx@4.21.0) - transitivePeerDependencies: - - '@types/node' - - jiti - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml - vite@7.3.1(tsx@4.21.0): dependencies: esbuild: 0.27.3 @@ -2442,44 +2458,30 @@ snapshots: fsevents: 2.3.3 tsx: 4.21.0 - vitest@3.2.4(tsx@4.21.0): + vitest@4.1.7(vite@7.3.1(tsx@4.21.0)): dependencies: - '@types/chai': 5.2.3 - '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.1(tsx@4.21.0)) - '@vitest/pretty-format': 3.2.4 - '@vitest/runner': 3.2.4 - '@vitest/snapshot': 3.2.4 - '@vitest/spy': 3.2.4 - '@vitest/utils': 3.2.4 - chai: 5.3.3 - debug: 4.4.3 + '@vitest/expect': 4.1.7 + '@vitest/mocker': 4.1.7(vite@7.3.1(tsx@4.21.0)) + '@vitest/pretty-format': 4.1.7 + '@vitest/runner': 4.1.7 + '@vitest/snapshot': 4.1.7 + '@vitest/spy': 4.1.7 + '@vitest/utils': 4.1.7 + es-module-lexer: 2.1.0 expect-type: 1.3.0 magic-string: 0.30.21 + obug: 2.1.1 pathe: 2.0.3 picomatch: 4.0.3 - std-env: 3.10.0 + std-env: 4.1.0 tinybench: 2.9.0 - tinyexec: 0.3.2 + tinyexec: 1.1.2 tinyglobby: 0.2.15 - tinypool: 1.1.1 - tinyrainbow: 2.0.0 + tinyrainbow: 3.1.0 vite: 7.3.1(tsx@4.21.0) - vite-node: 3.2.4(tsx@4.21.0) why-is-node-running: 2.3.0 transitivePeerDependencies: - - jiti - - less - - lightningcss - msw - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml which@2.0.2: dependencies: diff --git a/tsconfig.base.json b/tsconfig.base.json index bee1e88..e6a385b 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -13,6 +13,7 @@ "noUncheckedIndexedAccess": true, "noUnusedLocals": true, "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + "ignoreDeprecations": "6.0" } }