From 6bc757dedf09fa308a2951ec217c710d1386b9b4 Mon Sep 17 00:00:00 2001 From: Silvio Date: Thu, 13 Aug 2026 08:42:01 +0300 Subject: [PATCH 1/6] feat(ai-isolate-quickjs): adding support for wasmLocation option for QuickJsWASM driver and documentation update --- .changeset/quickjs-wasm-location.md | 5 ++ docs/code-mode/code-mode-isolates.md | 16 ++++- docs/config.json | 2 +- packages/ai-isolate-quickjs/README.md | 16 ++++- .../ai-isolate-quickjs/src/isolate-driver.ts | 29 +++++++- .../tests/wasm-location.test.ts | 70 +++++++++++++++++++ 6 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 .changeset/quickjs-wasm-location.md create mode 100644 packages/ai-isolate-quickjs/tests/wasm-location.test.ts diff --git a/.changeset/quickjs-wasm-location.md b/.changeset/quickjs-wasm-location.md new file mode 100644 index 0000000000..3a398e7969 --- /dev/null +++ b/.changeset/quickjs-wasm-location.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-isolate-quickjs': minor +--- + +Add a `wasmLocation` driver option for loading the QuickJS WASM binary from a custom URL or path, such as a public directory or CDN. \ No newline at end of file diff --git a/docs/code-mode/code-mode-isolates.md b/docs/code-mode/code-mode-isolates.md index fa7ee03e85..6b9f158fbb 100644 --- a/docs/code-mode/code-mode-isolates.md +++ b/docs/code-mode/code-mode-isolates.md @@ -93,6 +93,7 @@ const driver = createQuickJSIsolateDriver({ memoryLimit: 128, // MB timeout: 30_000, // ms maxStackSize: 524288, // bytes (512 KiB) + wasmLocation: '/assets/quickjs/emscripten-module.wasm', }) ``` @@ -104,11 +105,24 @@ const driver = createQuickJSIsolateDriver({ | `memoryLimit` | `number` | `128` | Maximum heap memory for the QuickJS VM, in megabytes. | | `timeout` | `number` | `30000` | Maximum wall-clock time per execution, in milliseconds. | | `maxStackSize` | `number` | `524288` | Maximum call stack size in bytes (default: 512 KiB). Increase for deeply recursive code; decrease to catch runaway recursion sooner. | +| `wasmLocation` | `string` | — | URL or path from which Emscripten loads the QuickJS WASM binary. When omitted, `quickjs-emscripten` resolves its bundled binary. | + +### Serving the WASM binary + +Set `wasmLocation` when the QuickJS WASM binary is hosted in a public directory or on a CDN: + +```typescript +const driver = createQuickJSIsolateDriver({ + wasmLocation: 'https://cdn.example.com/quickjs/emscripten-module.wasm', +}) +``` + +Serve the synchronous release binary exported by `@jitl/quickjs-wasmfile-release-sync/wasm`. For a cross-origin URL, configure the host to allow cross-origin requests. ### How it works -QuickJS WASM uses an asyncified execution model — the WASM module can pause while awaiting host async functions (your tools). Executions are serialized through a global queue to prevent concurrent WASM calls, which the asyncify model does not support. Fatal errors (memory exhaustion, stack overflow) are detected, the VM is disposed, and a structured error is returned. Console output is captured and returned with the result. +QuickJS runs the synchronous WASM build and bridges host async functions (your tools) through QuickJS promises, avoiding suspension of the WASM stack. Fatal errors (memory exhaustion, stack overflow) are detected, the VM is disposed, and a structured error is returned. Console output is captured and returned with the result. > **Performance note:** QuickJS interprets JavaScript rather than JIT-compiling it, so compute-heavy scripts run slower than with the Node driver. For typical LLM-generated scripts that are mostly waiting on `external_*` tool calls, this difference is not significant. diff --git a/docs/config.json b/docs/config.json index e05bf369e8..72730d07ec 100644 --- a/docs/config.json +++ b/docs/config.json @@ -384,7 +384,7 @@ "label": "Code Mode Isolate Drivers", "to": "code-mode/code-mode-isolates", "addedAt": "2026-04-15", - "updatedAt": "2026-06-11" + "updatedAt": "2026-07-20" }, { "label": "Lazy Tools", diff --git a/packages/ai-isolate-quickjs/README.md b/packages/ai-isolate-quickjs/README.md index 9c58bfb9fa..c718d50985 100644 --- a/packages/ai-isolate-quickjs/README.md +++ b/packages/ai-isolate-quickjs/README.md @@ -18,6 +18,7 @@ const driver = createQuickJSIsolateDriver({ timeout: 30000, // execution timeout in ms (default: 30000) memoryLimit: 128, // memory limit in MB (default: 128) maxStackSize: 512 * 1024, // max stack size in bytes (default: 512 KiB) + wasmLocation: '/assets/quickjs/emscripten-module.wasm', // optional public URL or path }) const executeTypescript = createCodeModeTool({ @@ -31,6 +32,19 @@ const executeTypescript = createCodeModeTool({ - `timeout` — Default execution timeout in milliseconds (default: 30000) - `memoryLimit` — Default QuickJS runtime memory limit in MB (default: 128) - `maxStackSize` — Default QuickJS runtime max stack size in bytes (default: 524288) +- `wasmLocation` — Optional URL or path from which Emscripten loads the QuickJS WASM binary. When omitted, `quickjs-emscripten` resolves its bundled binary. + +## Serving the WASM Binary + +Use `wasmLocation` when your runtime requires the QuickJS WASM binary to be served from a public directory or CDN: + +```typescript +const driver = createQuickJSIsolateDriver({ + wasmLocation: 'https://cdn.example.com/quickjs/emscripten-module.wasm', +}) +``` + +The configured file must be the synchronous release binary exported by `@jitl/quickjs-wasmfile-release-sync/wasm`. Ensure cross-origin requests are allowed when serving it from another origin. ## Tradeoffs vs Node Driver @@ -44,7 +58,7 @@ const executeTypescript = createCodeModeTool({ ## How It Works -Uses [QuickJS](https://bellard.org/quickjs/) compiled to WebAssembly via [`quickjs-emscripten`](https://github.com/nicolo-ribaudo/quickjs-emscripten). Each execution creates a fresh async QuickJS context with tool bindings injected as global async functions. +Uses [QuickJS](https://bellard.org/quickjs/) compiled to WebAssembly via [`quickjs-emscripten`](https://github.com/nicolo-ribaudo/quickjs-emscripten). Each execution creates a fresh QuickJS context with tool bindings injected as global async functions. ## Runtime Limits and Errors diff --git a/packages/ai-isolate-quickjs/src/isolate-driver.ts b/packages/ai-isolate-quickjs/src/isolate-driver.ts index 6c97c7fc6d..bb247fc6e1 100644 --- a/packages/ai-isolate-quickjs/src/isolate-driver.ts +++ b/packages/ai-isolate-quickjs/src/isolate-driver.ts @@ -1,4 +1,9 @@ -import { getQuickJS } from 'quickjs-emscripten' +import { + RELEASE_SYNC, + getQuickJS, + newQuickJSWASMModule, + newVariant, +} from 'quickjs-emscripten' import { QuickJSIsolateContext } from './isolate-context' import type { ExecState } from './isolate-context' import type { QuickJSContext } from 'quickjs-emscripten' @@ -35,6 +40,14 @@ export interface QuickJSIsolateDriverConfig { * Applied via QuickJS `runtime.setMaxStackSize`. */ maxStackSize?: number + + /** + * URL or path from which Emscripten loads the QuickJS WASM binary. + * + * When omitted, `quickjs-emscripten` resolves its bundled WASM binary. + * Set this when serving the binary from a public directory or CDN. + */ + wasmLocation?: string } /** @@ -185,6 +198,18 @@ export function createQuickJSIsolateDriver( const defaultMemoryLimit = config.memoryLimit ?? DEFAULT_MEMORY_LIMIT_MB const defaultMaxStackSize = config.maxStackSize ?? DEFAULT_MAX_STACK_SIZE_BYTES + let customQuickJSModule: ReturnType | undefined + + const loadQuickJS = () => { + if (config.wasmLocation === undefined) { + return getQuickJS() + } + + customQuickJSModule ??= newQuickJSWASMModule( + newVariant(RELEASE_SYNC, { wasmLocation: config.wasmLocation }), + ) + return customQuickJSModule + } return { async createContext(isolateConfig: IsolateConfig): Promise { @@ -195,7 +220,7 @@ export function createQuickJSIsolateDriver( // Create a plain (non-asyncify) QuickJS context. Host async functions // are bridged with QuickJS promises instead of asyncify suspensions, // so the sync WASM build is sufficient and sidesteps asyncify bugs. - const QuickJS = await getQuickJS() + const QuickJS = await loadQuickJS() const vm = QuickJS.newContext() // Enforce heap and stack limits so OOM/stack overflow surface as JS errors diff --git a/packages/ai-isolate-quickjs/tests/wasm-location.test.ts b/packages/ai-isolate-quickjs/tests/wasm-location.test.ts new file mode 100644 index 0000000000..3366f9b815 --- /dev/null +++ b/packages/ai-isolate-quickjs/tests/wasm-location.test.ts @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const quickJSMocks = vi.hoisted(() => { + const contextError = new Error('context created') + const releaseVariant = { type: 'sync' } + + return { + contextError, + releaseVariant, + getQuickJS: vi.fn(), + newVariant: vi.fn(() => releaseVariant), + newQuickJSWASMModule: vi.fn(async () => ({ + newContext: () => { + throw contextError + }, + })), + } +}) + +vi.mock('quickjs-emscripten', () => ({ + getQuickJS: quickJSMocks.getQuickJS, + newQuickJSWASMModule: quickJSMocks.newQuickJSWASMModule, + newVariant: quickJSMocks.newVariant, + RELEASE_SYNC: quickJSMocks.releaseVariant, +})) + +import { createQuickJSIsolateDriver } from '../src/isolate-driver' + +describe('QuickJS WASM loading', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('uses the shared QuickJS module by default', async () => { + quickJSMocks.getQuickJS.mockResolvedValue({ + newContext: () => { + throw quickJSMocks.contextError + }, + }) + const driver = createQuickJSIsolateDriver() + + await expect(driver.createContext({ bindings: {} })).rejects.toThrow( + quickJSMocks.contextError, + ) + + expect(quickJSMocks.getQuickJS).toHaveBeenCalledOnce() + expect(quickJSMocks.newVariant).not.toHaveBeenCalled() + expect(quickJSMocks.newQuickJSWASMModule).not.toHaveBeenCalled() + }) + + it('loads a custom WASM location once per driver', async () => { + const wasmLocation = 'https://cdn.example.com/quickjs.wasm' + const driver = createQuickJSIsolateDriver({ wasmLocation }) + + await expect(driver.createContext({ bindings: {} })).rejects.toThrow( + quickJSMocks.contextError, + ) + await expect(driver.createContext({ bindings: {} })).rejects.toThrow( + quickJSMocks.contextError, + ) + + expect(quickJSMocks.getQuickJS).not.toHaveBeenCalled() + expect(quickJSMocks.newVariant).toHaveBeenCalledOnce() + expect(quickJSMocks.newVariant).toHaveBeenCalledWith( + quickJSMocks.releaseVariant, + { wasmLocation }, + ) + expect(quickJSMocks.newQuickJSWASMModule).toHaveBeenCalledOnce() + }) +}) \ No newline at end of file From 67a908a89a07db2539feec477f4acbf7c96d3b68 Mon Sep 17 00:00:00 2001 From: SilvioYMollov <156681038+SilvioYMollov@users.noreply.github.com> Date: Thu, 13 Aug 2026 08:54:09 +0300 Subject: [PATCH 2/6] Update docs/config.json Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- docs/config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/config.json b/docs/config.json index 72730d07ec..f682576630 100644 --- a/docs/config.json +++ b/docs/config.json @@ -384,7 +384,7 @@ "label": "Code Mode Isolate Drivers", "to": "code-mode/code-mode-isolates", "addedAt": "2026-04-15", - "updatedAt": "2026-07-20" + "updatedAt": "2026-08-13" }, { "label": "Lazy Tools", From 16187e9ca53fcc9ff5835d82325ed4fc89ce3cb0 Mon Sep 17 00:00:00 2001 From: Silvio Date: Thu, 13 Aug 2026 11:17:05 +0300 Subject: [PATCH 3/6] feat(ai-isolate-quickjs): standardize code formatting --- .../ai-isolate-quickjs/src/isolate-driver.ts | 174 +++++++++--------- .../tests/wasm-location.test.ts | 64 +++---- packages/ai-isolate-quickjs/vite.config.ts | 38 ++-- 3 files changed, 139 insertions(+), 137 deletions(-) diff --git a/packages/ai-isolate-quickjs/src/isolate-driver.ts b/packages/ai-isolate-quickjs/src/isolate-driver.ts index bb247fc6e1..27456f679b 100644 --- a/packages/ai-isolate-quickjs/src/isolate-driver.ts +++ b/packages/ai-isolate-quickjs/src/isolate-driver.ts @@ -3,22 +3,22 @@ import { getQuickJS, newQuickJSWASMModule, newVariant, -} from 'quickjs-emscripten' -import { QuickJSIsolateContext } from './isolate-context' -import type { ExecState } from './isolate-context' -import type { QuickJSContext } from 'quickjs-emscripten' +} from "quickjs-emscripten"; +import { QuickJSIsolateContext } from "./isolate-context"; +import type { ExecState } from "./isolate-context"; +import type { QuickJSContext } from "quickjs-emscripten"; import type { IsolateConfig, IsolateContext, IsolateDriver, ToolBinding, -} from '@tanstack/ai-code-mode' +} from "@tanstack/ai-code-mode"; /** Default memory limit in MB (matches Node isolate driver default). */ -const DEFAULT_MEMORY_LIMIT_MB = 128 +const DEFAULT_MEMORY_LIMIT_MB = 128; /** Default max stack size in bytes for QuickJS runtime. */ -const DEFAULT_MAX_STACK_SIZE_BYTES = 512 * 1024 +const DEFAULT_MAX_STACK_SIZE_BYTES = 512 * 1024; /** * Configuration for the QuickJS WASM isolate driver @@ -27,19 +27,19 @@ export interface QuickJSIsolateDriverConfig { /** * Default execution timeout in ms (default: 30000) */ - timeout?: number + timeout?: number; /** * Default memory limit in MB (default: 128). * Applied via QuickJS `runtime.setMemoryLimit`. */ - memoryLimit?: number + memoryLimit?: number; /** * Default max stack size in bytes (default: 512 KiB). * Applied via QuickJS `runtime.setMaxStackSize`. */ - maxStackSize?: number + maxStackSize?: number; /** * URL or path from which Emscripten loads the QuickJS WASM binary. @@ -47,7 +47,7 @@ export interface QuickJSIsolateDriverConfig { * When omitted, `quickjs-emscripten` resolves its bundled WASM binary. * Set this when serving the binary from a public directory or CDN. */ - wasmLocation?: string + wasmLocation?: string; } /** @@ -59,12 +59,12 @@ async function invokeBinding( argsJson: string, ): Promise { try { - const args = JSON.parse(argsJson) - const result = await binding.execute(args) - return JSON.stringify({ success: true, value: result }) + const args = JSON.parse(argsJson); + const result = await binding.execute(args); + return JSON.stringify({ success: true, value: result }); } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - return JSON.stringify({ success: false, error: errorMessage }) + const errorMessage = error instanceof Error ? error.message : String(error); + return JSON.stringify({ success: false, error: errorMessage }); } } @@ -85,27 +85,27 @@ function injectBinding( execState: ExecState, ): void { const toolFn = vm.newFunction(name, (argsHandle) => { - const argsJson = vm.getString(argsHandle) - const promise = vm.newPromise() + const argsJson = vm.getString(argsHandle); + const promise = vm.newPromise(); // A timed-out execution cancels every outstanding tool call by settling // its deferred with a timeout envelope, so the guest program itself can // settle and the VM can be disposed (freeing a runtime that still holds // an unsettled program promise aborts the shared WASM module). const resolveWithPayload = (payloadJson: string) => { - execState.pendingCancels.delete(cancel) - if (!vm.alive || !promise.alive) return - const payloadHandle = vm.newString(payloadJson) - promise.resolve(payloadHandle) - payloadHandle.dispose() - } + execState.pendingCancels.delete(cancel); + if (!vm.alive || !promise.alive) return; + const payloadHandle = vm.newString(payloadJson); + promise.resolve(payloadHandle); + payloadHandle.dispose(); + }; const cancel = () => resolveWithPayload( - JSON.stringify({ success: false, error: 'Execution timed out' }), - ) - execState.pendingCancels.add(cancel) + JSON.stringify({ success: false, error: "Execution timed out" }), + ); + execState.pendingCancels.add(cancel); - void invokeBinding(binding, argsJson).then(resolveWithPayload) + void invokeBinding(binding, argsJson).then(resolveWithPayload); // Resume guest code waiting on the promise. Defense in depth: outside an // active execute() the interrupt deadline is 0, so a stray job from an @@ -113,28 +113,28 @@ function injectBinding( void promise.settled.then(() => { try { if (vm.runtime.alive) { - const jobs = vm.runtime.executePendingJobs() + const jobs = vm.runtime.executePendingJobs(); if (jobs.error) { // Errors thrown inside guest async code reject the observed program // promise instead of surfacing here; anything that does land here // would otherwise be silently swallowed and leak its handle. logs.push( `ERROR: uncaught error in sandboxed code: ${JSON.stringify(vm.dump(jobs.error))}`, - ) - jobs.error.dispose() + ); + jobs.error.dispose(); } } } finally { - promise.dispose() + promise.dispose(); } - }) + }); - return promise.handle - }) + return promise.handle; + }); // Set on global - the VM keeps its own reference - vm.setProp(vm.global, `__${name}_impl`, toolFn) - toolFn.dispose() + vm.setProp(vm.global, `__${name}_impl`, toolFn); + toolFn.dispose(); // Create wrapper that parses input and output // Function names match the binding keys (e.g., external_fetchWeather) @@ -147,14 +147,14 @@ function injectBinding( } return result.value; } - ` - const wrapperResult = vm.evalCode(wrapperCode) + `; + const wrapperResult = vm.evalCode(wrapperCode); if (wrapperResult.error) { - const errorStr = vm.dump(wrapperResult.error) - wrapperResult.error.dispose() - throw new Error(`Failed to create wrapper for ${name}: ${errorStr}`) + const errorStr = vm.dump(wrapperResult.error); + wrapperResult.error.dispose(); + throw new Error(`Failed to create wrapper for ${name}: ${errorStr}`); } - wrapperResult.value.dispose() + wrapperResult.value.dispose(); } /** @@ -194,75 +194,77 @@ function injectBinding( export function createQuickJSIsolateDriver( config: QuickJSIsolateDriverConfig = {}, ): IsolateDriver { - const defaultTimeout = config.timeout ?? 30000 - const defaultMemoryLimit = config.memoryLimit ?? DEFAULT_MEMORY_LIMIT_MB + const defaultTimeout = config.timeout ?? 30000; + const defaultMemoryLimit = config.memoryLimit ?? DEFAULT_MEMORY_LIMIT_MB; const defaultMaxStackSize = - config.maxStackSize ?? DEFAULT_MAX_STACK_SIZE_BYTES - let customQuickJSModule: ReturnType | undefined + config.maxStackSize ?? DEFAULT_MAX_STACK_SIZE_BYTES; + let customQuickJSModule: ReturnType | undefined; const loadQuickJS = () => { if (config.wasmLocation === undefined) { - return getQuickJS() + return getQuickJS(); } customQuickJSModule ??= newQuickJSWASMModule( newVariant(RELEASE_SYNC, { wasmLocation: config.wasmLocation }), - ) - return customQuickJSModule - } + ); + return customQuickJSModule; + }; return { async createContext(isolateConfig: IsolateConfig): Promise { - const timeout = isolateConfig.timeout ?? defaultTimeout - const memoryLimitMb = isolateConfig.memoryLimit ?? defaultMemoryLimit - const maxStackSizeBytes = defaultMaxStackSize + const timeout = isolateConfig.timeout ?? defaultTimeout; + const memoryLimitMb = isolateConfig.memoryLimit ?? defaultMemoryLimit; + const maxStackSizeBytes = defaultMaxStackSize; // Create a plain (non-asyncify) QuickJS context. Host async functions // are bridged with QuickJS promises instead of asyncify suspensions, // so the sync WASM build is sufficient and sidesteps asyncify bugs. - const QuickJS = await loadQuickJS() - const vm = QuickJS.newContext() + const QuickJS = await loadQuickJS(); + const vm = QuickJS.newContext(); // Enforce heap and stack limits so OOM/stack overflow surface as JS errors // instead of growing WASM memory until the host process OOMs. - vm.runtime.setMemoryLimit(memoryLimitMb * 1024 * 1024) - vm.runtime.setMaxStackSize(maxStackSizeBytes) + vm.runtime.setMemoryLimit(memoryLimitMb * 1024 * 1024); + vm.runtime.setMaxStackSize(maxStackSizeBytes); // Set up console.log capture - const logs: Array = [] + const logs: Array = []; // Create console object - const consoleObj = vm.newObject() + const consoleObj = vm.newObject(); // Helper to create console methods const createConsoleMethod = (prefix: string) => { return vm.newFunction(`console.${prefix}`, (...args) => { const parts = args.map((arg) => { - const str = vm.getString(arg) - return str - }) - const msg = prefix ? `${prefix}: ${parts.join(' ')}` : parts.join(' ') - logs.push(msg) - }) - } + const str = vm.getString(arg); + return str; + }); + const msg = prefix + ? `${prefix}: ${parts.join(" ")}` + : parts.join(" "); + logs.push(msg); + }); + }; - const logFn = createConsoleMethod('') - const errorFn = createConsoleMethod('ERROR') - const warnFn = createConsoleMethod('WARN') - const infoFn = createConsoleMethod('INFO') + const logFn = createConsoleMethod(""); + const errorFn = createConsoleMethod("ERROR"); + const warnFn = createConsoleMethod("WARN"); + const infoFn = createConsoleMethod("INFO"); - vm.setProp(consoleObj, 'log', logFn) - vm.setProp(consoleObj, 'error', errorFn) - vm.setProp(consoleObj, 'warn', warnFn) - vm.setProp(consoleObj, 'info', infoFn) - vm.setProp(vm.global, 'console', consoleObj) + vm.setProp(consoleObj, "log", logFn); + vm.setProp(consoleObj, "error", errorFn); + vm.setProp(consoleObj, "warn", warnFn); + vm.setProp(consoleObj, "info", infoFn); + vm.setProp(vm.global, "console", consoleObj); // Dispose console handles - logFn.dispose() - errorFn.dispose() - warnFn.dispose() - infoFn.dispose() - consoleObj.dispose() + logFn.dispose(); + errorFn.dispose(); + warnFn.dispose(); + infoFn.dispose(); + consoleObj.dispose(); // Shared between execute() and the tool bindings: the interrupt // deadline (0 means "no execution active" — any guest job that tries @@ -271,16 +273,16 @@ export function createQuickJSIsolateDriver( const execState: ExecState = { deadline: 0, pendingCancels: new Set<() => void>(), - } + }; // Inject each tool binding as an async function for (const [name, binding] of Object.entries(isolateConfig.bindings)) { - injectBinding(vm, name, binding, logs, execState) + injectBinding(vm, name, binding, logs, execState); } - vm.runtime.setInterruptHandler(() => Date.now() > execState.deadline) + vm.runtime.setInterruptHandler(() => Date.now() > execState.deadline); - return new QuickJSIsolateContext(vm, logs, timeout, execState) + return new QuickJSIsolateContext(vm, logs, timeout, execState); }, - } + }; } diff --git a/packages/ai-isolate-quickjs/tests/wasm-location.test.ts b/packages/ai-isolate-quickjs/tests/wasm-location.test.ts index 3366f9b815..2e60c79945 100644 --- a/packages/ai-isolate-quickjs/tests/wasm-location.test.ts +++ b/packages/ai-isolate-quickjs/tests/wasm-location.test.ts @@ -1,8 +1,8 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from "vitest"; const quickJSMocks = vi.hoisted(() => { - const contextError = new Error('context created') - const releaseVariant = { type: 'sync' } + const contextError = new Error("context created"); + const releaseVariant = { type: "sync" }; return { contextError, @@ -11,60 +11,60 @@ const quickJSMocks = vi.hoisted(() => { newVariant: vi.fn(() => releaseVariant), newQuickJSWASMModule: vi.fn(async () => ({ newContext: () => { - throw contextError + throw contextError; }, })), - } -}) + }; +}); -vi.mock('quickjs-emscripten', () => ({ +vi.mock("quickjs-emscripten", () => ({ getQuickJS: quickJSMocks.getQuickJS, newQuickJSWASMModule: quickJSMocks.newQuickJSWASMModule, newVariant: quickJSMocks.newVariant, RELEASE_SYNC: quickJSMocks.releaseVariant, -})) +})); -import { createQuickJSIsolateDriver } from '../src/isolate-driver' +import { createQuickJSIsolateDriver } from "../src/isolate-driver"; -describe('QuickJS WASM loading', () => { +describe("QuickJS WASM loading", () => { beforeEach(() => { - vi.clearAllMocks() - }) + vi.clearAllMocks(); + }); - it('uses the shared QuickJS module by default', async () => { + it("uses the shared QuickJS module by default", async () => { quickJSMocks.getQuickJS.mockResolvedValue({ newContext: () => { - throw quickJSMocks.contextError + throw quickJSMocks.contextError; }, - }) - const driver = createQuickJSIsolateDriver() + }); + const driver = createQuickJSIsolateDriver(); await expect(driver.createContext({ bindings: {} })).rejects.toThrow( quickJSMocks.contextError, - ) + ); - expect(quickJSMocks.getQuickJS).toHaveBeenCalledOnce() - expect(quickJSMocks.newVariant).not.toHaveBeenCalled() - expect(quickJSMocks.newQuickJSWASMModule).not.toHaveBeenCalled() - }) + expect(quickJSMocks.getQuickJS).toHaveBeenCalledOnce(); + expect(quickJSMocks.newVariant).not.toHaveBeenCalled(); + expect(quickJSMocks.newQuickJSWASMModule).not.toHaveBeenCalled(); + }); - it('loads a custom WASM location once per driver', async () => { - const wasmLocation = 'https://cdn.example.com/quickjs.wasm' - const driver = createQuickJSIsolateDriver({ wasmLocation }) + it("loads a custom WASM location once per driver", async () => { + const wasmLocation = "https://cdn.example.com/quickjs.wasm"; + const driver = createQuickJSIsolateDriver({ wasmLocation }); await expect(driver.createContext({ bindings: {} })).rejects.toThrow( quickJSMocks.contextError, - ) + ); await expect(driver.createContext({ bindings: {} })).rejects.toThrow( quickJSMocks.contextError, - ) + ); - expect(quickJSMocks.getQuickJS).not.toHaveBeenCalled() - expect(quickJSMocks.newVariant).toHaveBeenCalledOnce() + expect(quickJSMocks.getQuickJS).not.toHaveBeenCalled(); + expect(quickJSMocks.newVariant).toHaveBeenCalledOnce(); expect(quickJSMocks.newVariant).toHaveBeenCalledWith( quickJSMocks.releaseVariant, { wasmLocation }, - ) - expect(quickJSMocks.newQuickJSWASMModule).toHaveBeenCalledOnce() - }) -}) \ No newline at end of file + ); + expect(quickJSMocks.newQuickJSWASMModule).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/ai-isolate-quickjs/vite.config.ts b/packages/ai-isolate-quickjs/vite.config.ts index 77bcc2e60b..e088b0860d 100644 --- a/packages/ai-isolate-quickjs/vite.config.ts +++ b/packages/ai-isolate-quickjs/vite.config.ts @@ -1,36 +1,36 @@ -import { defineConfig, mergeConfig } from 'vitest/config' -import { tanstackViteConfig } from '@tanstack/vite-config' -import packageJson from './package.json' +import { defineConfig, mergeConfig } from "vitest/config"; +import { tanstackViteConfig } from "@tanstack/vite-config"; +import packageJson from "./package.json"; const config = defineConfig({ test: { name: packageJson.name, - dir: './', + dir: "./", watch: false, globals: true, - environment: 'node', - include: ['tests/**/*.test.ts'], + environment: "node", + include: ["src/**/*.test.ts", "tests/**/*.test.ts"], coverage: { - provider: 'v8', - reporter: ['text', 'json', 'html', 'lcov'], + provider: "v8", + reporter: ["text", "json", "html", "lcov"], exclude: [ - 'node_modules/', - 'dist/', - 'tests/', - '**/*.test.ts', - '**/*.config.ts', - '**/types.ts', + "node_modules/", + "dist/", + "tests/", + "**/*.test.ts", + "**/*.config.ts", + "**/types.ts", ], - include: ['src/**/*.ts'], + include: ["src/**/*.ts"], }, }, -}) +}); export default mergeConfig( config, tanstackViteConfig({ - entry: ['./src/index.ts'], - srcDir: './src', + entry: ["./src/index.ts"], + srcDir: "./src", cjs: false, }), -) +); From 7db3c7cb2ddb6b00e7a65dfbc92a026761e9ce43 Mon Sep 17 00:00:00 2001 From: Silvio Date: Thu, 13 Aug 2026 17:52:52 +0300 Subject: [PATCH 4/6] fix formatting --- .../ai-isolate-quickjs/src/isolate-driver.ts | 174 +++++++++--------- .../tests/wasm-location.test.ts | 64 +++---- packages/ai-isolate-quickjs/vite.config.ts | 38 ++-- 3 files changed, 137 insertions(+), 139 deletions(-) diff --git a/packages/ai-isolate-quickjs/src/isolate-driver.ts b/packages/ai-isolate-quickjs/src/isolate-driver.ts index 27456f679b..bb247fc6e1 100644 --- a/packages/ai-isolate-quickjs/src/isolate-driver.ts +++ b/packages/ai-isolate-quickjs/src/isolate-driver.ts @@ -3,22 +3,22 @@ import { getQuickJS, newQuickJSWASMModule, newVariant, -} from "quickjs-emscripten"; -import { QuickJSIsolateContext } from "./isolate-context"; -import type { ExecState } from "./isolate-context"; -import type { QuickJSContext } from "quickjs-emscripten"; +} from 'quickjs-emscripten' +import { QuickJSIsolateContext } from './isolate-context' +import type { ExecState } from './isolate-context' +import type { QuickJSContext } from 'quickjs-emscripten' import type { IsolateConfig, IsolateContext, IsolateDriver, ToolBinding, -} from "@tanstack/ai-code-mode"; +} from '@tanstack/ai-code-mode' /** Default memory limit in MB (matches Node isolate driver default). */ -const DEFAULT_MEMORY_LIMIT_MB = 128; +const DEFAULT_MEMORY_LIMIT_MB = 128 /** Default max stack size in bytes for QuickJS runtime. */ -const DEFAULT_MAX_STACK_SIZE_BYTES = 512 * 1024; +const DEFAULT_MAX_STACK_SIZE_BYTES = 512 * 1024 /** * Configuration for the QuickJS WASM isolate driver @@ -27,19 +27,19 @@ export interface QuickJSIsolateDriverConfig { /** * Default execution timeout in ms (default: 30000) */ - timeout?: number; + timeout?: number /** * Default memory limit in MB (default: 128). * Applied via QuickJS `runtime.setMemoryLimit`. */ - memoryLimit?: number; + memoryLimit?: number /** * Default max stack size in bytes (default: 512 KiB). * Applied via QuickJS `runtime.setMaxStackSize`. */ - maxStackSize?: number; + maxStackSize?: number /** * URL or path from which Emscripten loads the QuickJS WASM binary. @@ -47,7 +47,7 @@ export interface QuickJSIsolateDriverConfig { * When omitted, `quickjs-emscripten` resolves its bundled WASM binary. * Set this when serving the binary from a public directory or CDN. */ - wasmLocation?: string; + wasmLocation?: string } /** @@ -59,12 +59,12 @@ async function invokeBinding( argsJson: string, ): Promise { try { - const args = JSON.parse(argsJson); - const result = await binding.execute(args); - return JSON.stringify({ success: true, value: result }); + const args = JSON.parse(argsJson) + const result = await binding.execute(args) + return JSON.stringify({ success: true, value: result }) } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - return JSON.stringify({ success: false, error: errorMessage }); + const errorMessage = error instanceof Error ? error.message : String(error) + return JSON.stringify({ success: false, error: errorMessage }) } } @@ -85,27 +85,27 @@ function injectBinding( execState: ExecState, ): void { const toolFn = vm.newFunction(name, (argsHandle) => { - const argsJson = vm.getString(argsHandle); - const promise = vm.newPromise(); + const argsJson = vm.getString(argsHandle) + const promise = vm.newPromise() // A timed-out execution cancels every outstanding tool call by settling // its deferred with a timeout envelope, so the guest program itself can // settle and the VM can be disposed (freeing a runtime that still holds // an unsettled program promise aborts the shared WASM module). const resolveWithPayload = (payloadJson: string) => { - execState.pendingCancels.delete(cancel); - if (!vm.alive || !promise.alive) return; - const payloadHandle = vm.newString(payloadJson); - promise.resolve(payloadHandle); - payloadHandle.dispose(); - }; + execState.pendingCancels.delete(cancel) + if (!vm.alive || !promise.alive) return + const payloadHandle = vm.newString(payloadJson) + promise.resolve(payloadHandle) + payloadHandle.dispose() + } const cancel = () => resolveWithPayload( - JSON.stringify({ success: false, error: "Execution timed out" }), - ); - execState.pendingCancels.add(cancel); + JSON.stringify({ success: false, error: 'Execution timed out' }), + ) + execState.pendingCancels.add(cancel) - void invokeBinding(binding, argsJson).then(resolveWithPayload); + void invokeBinding(binding, argsJson).then(resolveWithPayload) // Resume guest code waiting on the promise. Defense in depth: outside an // active execute() the interrupt deadline is 0, so a stray job from an @@ -113,28 +113,28 @@ function injectBinding( void promise.settled.then(() => { try { if (vm.runtime.alive) { - const jobs = vm.runtime.executePendingJobs(); + const jobs = vm.runtime.executePendingJobs() if (jobs.error) { // Errors thrown inside guest async code reject the observed program // promise instead of surfacing here; anything that does land here // would otherwise be silently swallowed and leak its handle. logs.push( `ERROR: uncaught error in sandboxed code: ${JSON.stringify(vm.dump(jobs.error))}`, - ); - jobs.error.dispose(); + ) + jobs.error.dispose() } } } finally { - promise.dispose(); + promise.dispose() } - }); + }) - return promise.handle; - }); + return promise.handle + }) // Set on global - the VM keeps its own reference - vm.setProp(vm.global, `__${name}_impl`, toolFn); - toolFn.dispose(); + vm.setProp(vm.global, `__${name}_impl`, toolFn) + toolFn.dispose() // Create wrapper that parses input and output // Function names match the binding keys (e.g., external_fetchWeather) @@ -147,14 +147,14 @@ function injectBinding( } return result.value; } - `; - const wrapperResult = vm.evalCode(wrapperCode); + ` + const wrapperResult = vm.evalCode(wrapperCode) if (wrapperResult.error) { - const errorStr = vm.dump(wrapperResult.error); - wrapperResult.error.dispose(); - throw new Error(`Failed to create wrapper for ${name}: ${errorStr}`); + const errorStr = vm.dump(wrapperResult.error) + wrapperResult.error.dispose() + throw new Error(`Failed to create wrapper for ${name}: ${errorStr}`) } - wrapperResult.value.dispose(); + wrapperResult.value.dispose() } /** @@ -194,77 +194,75 @@ function injectBinding( export function createQuickJSIsolateDriver( config: QuickJSIsolateDriverConfig = {}, ): IsolateDriver { - const defaultTimeout = config.timeout ?? 30000; - const defaultMemoryLimit = config.memoryLimit ?? DEFAULT_MEMORY_LIMIT_MB; + const defaultTimeout = config.timeout ?? 30000 + const defaultMemoryLimit = config.memoryLimit ?? DEFAULT_MEMORY_LIMIT_MB const defaultMaxStackSize = - config.maxStackSize ?? DEFAULT_MAX_STACK_SIZE_BYTES; - let customQuickJSModule: ReturnType | undefined; + config.maxStackSize ?? DEFAULT_MAX_STACK_SIZE_BYTES + let customQuickJSModule: ReturnType | undefined const loadQuickJS = () => { if (config.wasmLocation === undefined) { - return getQuickJS(); + return getQuickJS() } customQuickJSModule ??= newQuickJSWASMModule( newVariant(RELEASE_SYNC, { wasmLocation: config.wasmLocation }), - ); - return customQuickJSModule; - }; + ) + return customQuickJSModule + } return { async createContext(isolateConfig: IsolateConfig): Promise { - const timeout = isolateConfig.timeout ?? defaultTimeout; - const memoryLimitMb = isolateConfig.memoryLimit ?? defaultMemoryLimit; - const maxStackSizeBytes = defaultMaxStackSize; + const timeout = isolateConfig.timeout ?? defaultTimeout + const memoryLimitMb = isolateConfig.memoryLimit ?? defaultMemoryLimit + const maxStackSizeBytes = defaultMaxStackSize // Create a plain (non-asyncify) QuickJS context. Host async functions // are bridged with QuickJS promises instead of asyncify suspensions, // so the sync WASM build is sufficient and sidesteps asyncify bugs. - const QuickJS = await loadQuickJS(); - const vm = QuickJS.newContext(); + const QuickJS = await loadQuickJS() + const vm = QuickJS.newContext() // Enforce heap and stack limits so OOM/stack overflow surface as JS errors // instead of growing WASM memory until the host process OOMs. - vm.runtime.setMemoryLimit(memoryLimitMb * 1024 * 1024); - vm.runtime.setMaxStackSize(maxStackSizeBytes); + vm.runtime.setMemoryLimit(memoryLimitMb * 1024 * 1024) + vm.runtime.setMaxStackSize(maxStackSizeBytes) // Set up console.log capture - const logs: Array = []; + const logs: Array = [] // Create console object - const consoleObj = vm.newObject(); + const consoleObj = vm.newObject() // Helper to create console methods const createConsoleMethod = (prefix: string) => { return vm.newFunction(`console.${prefix}`, (...args) => { const parts = args.map((arg) => { - const str = vm.getString(arg); - return str; - }); - const msg = prefix - ? `${prefix}: ${parts.join(" ")}` - : parts.join(" "); - logs.push(msg); - }); - }; + const str = vm.getString(arg) + return str + }) + const msg = prefix ? `${prefix}: ${parts.join(' ')}` : parts.join(' ') + logs.push(msg) + }) + } - const logFn = createConsoleMethod(""); - const errorFn = createConsoleMethod("ERROR"); - const warnFn = createConsoleMethod("WARN"); - const infoFn = createConsoleMethod("INFO"); + const logFn = createConsoleMethod('') + const errorFn = createConsoleMethod('ERROR') + const warnFn = createConsoleMethod('WARN') + const infoFn = createConsoleMethod('INFO') - vm.setProp(consoleObj, "log", logFn); - vm.setProp(consoleObj, "error", errorFn); - vm.setProp(consoleObj, "warn", warnFn); - vm.setProp(consoleObj, "info", infoFn); - vm.setProp(vm.global, "console", consoleObj); + vm.setProp(consoleObj, 'log', logFn) + vm.setProp(consoleObj, 'error', errorFn) + vm.setProp(consoleObj, 'warn', warnFn) + vm.setProp(consoleObj, 'info', infoFn) + vm.setProp(vm.global, 'console', consoleObj) // Dispose console handles - logFn.dispose(); - errorFn.dispose(); - warnFn.dispose(); - infoFn.dispose(); - consoleObj.dispose(); + logFn.dispose() + errorFn.dispose() + warnFn.dispose() + infoFn.dispose() + consoleObj.dispose() // Shared between execute() and the tool bindings: the interrupt // deadline (0 means "no execution active" — any guest job that tries @@ -273,16 +271,16 @@ export function createQuickJSIsolateDriver( const execState: ExecState = { deadline: 0, pendingCancels: new Set<() => void>(), - }; + } // Inject each tool binding as an async function for (const [name, binding] of Object.entries(isolateConfig.bindings)) { - injectBinding(vm, name, binding, logs, execState); + injectBinding(vm, name, binding, logs, execState) } - vm.runtime.setInterruptHandler(() => Date.now() > execState.deadline); + vm.runtime.setInterruptHandler(() => Date.now() > execState.deadline) - return new QuickJSIsolateContext(vm, logs, timeout, execState); + return new QuickJSIsolateContext(vm, logs, timeout, execState) }, - }; + } } diff --git a/packages/ai-isolate-quickjs/tests/wasm-location.test.ts b/packages/ai-isolate-quickjs/tests/wasm-location.test.ts index 2e60c79945..97acd20ec8 100644 --- a/packages/ai-isolate-quickjs/tests/wasm-location.test.ts +++ b/packages/ai-isolate-quickjs/tests/wasm-location.test.ts @@ -1,8 +1,8 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from 'vitest' const quickJSMocks = vi.hoisted(() => { - const contextError = new Error("context created"); - const releaseVariant = { type: "sync" }; + const contextError = new Error('context created') + const releaseVariant = { type: 'sync' } return { contextError, @@ -11,60 +11,60 @@ const quickJSMocks = vi.hoisted(() => { newVariant: vi.fn(() => releaseVariant), newQuickJSWASMModule: vi.fn(async () => ({ newContext: () => { - throw contextError; + throw contextError }, })), - }; -}); + } +}) -vi.mock("quickjs-emscripten", () => ({ +vi.mock('quickjs-emscripten', () => ({ getQuickJS: quickJSMocks.getQuickJS, newQuickJSWASMModule: quickJSMocks.newQuickJSWASMModule, newVariant: quickJSMocks.newVariant, RELEASE_SYNC: quickJSMocks.releaseVariant, -})); +})) -import { createQuickJSIsolateDriver } from "../src/isolate-driver"; +import { createQuickJSIsolateDriver } from '../src/isolate-driver' -describe("QuickJS WASM loading", () => { +describe('QuickJS WASM loading', () => { beforeEach(() => { - vi.clearAllMocks(); - }); + vi.clearAllMocks() + }) - it("uses the shared QuickJS module by default", async () => { + it('uses the shared QuickJS module by default', async () => { quickJSMocks.getQuickJS.mockResolvedValue({ newContext: () => { - throw quickJSMocks.contextError; + throw quickJSMocks.contextError }, - }); - const driver = createQuickJSIsolateDriver(); + }) + const driver = createQuickJSIsolateDriver() await expect(driver.createContext({ bindings: {} })).rejects.toThrow( quickJSMocks.contextError, - ); + ) - expect(quickJSMocks.getQuickJS).toHaveBeenCalledOnce(); - expect(quickJSMocks.newVariant).not.toHaveBeenCalled(); - expect(quickJSMocks.newQuickJSWASMModule).not.toHaveBeenCalled(); - }); + expect(quickJSMocks.getQuickJS).toHaveBeenCalledOnce() + expect(quickJSMocks.newVariant).not.toHaveBeenCalled() + expect(quickJSMocks.newQuickJSWASMModule).not.toHaveBeenCalled() + }) - it("loads a custom WASM location once per driver", async () => { - const wasmLocation = "https://cdn.example.com/quickjs.wasm"; - const driver = createQuickJSIsolateDriver({ wasmLocation }); + it('loads a custom WASM location once per driver', async () => { + const wasmLocation = 'https://cdn.example.com/quickjs.wasm' + const driver = createQuickJSIsolateDriver({ wasmLocation }) await expect(driver.createContext({ bindings: {} })).rejects.toThrow( quickJSMocks.contextError, - ); + ) await expect(driver.createContext({ bindings: {} })).rejects.toThrow( quickJSMocks.contextError, - ); + ) - expect(quickJSMocks.getQuickJS).not.toHaveBeenCalled(); - expect(quickJSMocks.newVariant).toHaveBeenCalledOnce(); + expect(quickJSMocks.getQuickJS).not.toHaveBeenCalled() + expect(quickJSMocks.newVariant).toHaveBeenCalledOnce() expect(quickJSMocks.newVariant).toHaveBeenCalledWith( quickJSMocks.releaseVariant, { wasmLocation }, - ); - expect(quickJSMocks.newQuickJSWASMModule).toHaveBeenCalledOnce(); - }); -}); + ) + expect(quickJSMocks.newQuickJSWASMModule).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/ai-isolate-quickjs/vite.config.ts b/packages/ai-isolate-quickjs/vite.config.ts index e088b0860d..68a4c83533 100644 --- a/packages/ai-isolate-quickjs/vite.config.ts +++ b/packages/ai-isolate-quickjs/vite.config.ts @@ -1,36 +1,36 @@ -import { defineConfig, mergeConfig } from "vitest/config"; -import { tanstackViteConfig } from "@tanstack/vite-config"; -import packageJson from "./package.json"; +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import packageJson from './package.json' const config = defineConfig({ test: { name: packageJson.name, - dir: "./", + dir: './', watch: false, globals: true, - environment: "node", - include: ["src/**/*.test.ts", "tests/**/*.test.ts"], + environment: 'node', + include: ['src/**/*.test.ts', 'tests/**/*.test.ts'], coverage: { - provider: "v8", - reporter: ["text", "json", "html", "lcov"], + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], exclude: [ - "node_modules/", - "dist/", - "tests/", - "**/*.test.ts", - "**/*.config.ts", - "**/types.ts", + 'node_modules/', + 'dist/', + 'tests/', + '**/*.test.ts', + '**/*.config.ts', + '**/types.ts', ], - include: ["src/**/*.ts"], + include: ['src/**/*.ts'], }, }, -}); +}) export default mergeConfig( config, tanstackViteConfig({ - entry: ["./src/index.ts"], - srcDir: "./src", + entry: ['./src/index.ts'], + srcDir: './src', cjs: false, }), -); +) From 975c627419b83712a97d167a66b57b169b7ee23e Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 10:07:27 +0000 Subject: [PATCH 5/6] ci: apply automated fixes --- .changeset/quickjs-wasm-location.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/quickjs-wasm-location.md b/.changeset/quickjs-wasm-location.md index 3a398e7969..9a06ddf948 100644 --- a/.changeset/quickjs-wasm-location.md +++ b/.changeset/quickjs-wasm-location.md @@ -2,4 +2,4 @@ '@tanstack/ai-isolate-quickjs': minor --- -Add a `wasmLocation` driver option for loading the QuickJS WASM binary from a custom URL or path, such as a public directory or CDN. \ No newline at end of file +Add a `wasmLocation` driver option for loading the QuickJS WASM binary from a custom URL or path, such as a public directory or CDN. From d9cbef95603822bfa1c932410efb3e0cb7b80bde Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 14 Aug 2026 13:06:18 +0200 Subject: [PATCH 6/6] fix(docs): import createQuickJSIsolateDriver in wasmLocation snippet --- docs/code-mode/code-mode-isolates.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/code-mode/code-mode-isolates.md b/docs/code-mode/code-mode-isolates.md index 6b9f158fbb..2426838801 100644 --- a/docs/code-mode/code-mode-isolates.md +++ b/docs/code-mode/code-mode-isolates.md @@ -112,6 +112,8 @@ const driver = createQuickJSIsolateDriver({ Set `wasmLocation` when the QuickJS WASM binary is hosted in a public directory or on a CDN: ```typescript +import { createQuickJSIsolateDriver } from '@tanstack/ai-isolate-quickjs' + const driver = createQuickJSIsolateDriver({ wasmLocation: 'https://cdn.example.com/quickjs/emscripten-module.wasm', })