diff --git a/.changeset/quickjs-wasm-location.md b/.changeset/quickjs-wasm-location.md new file mode 100644 index 0000000000..9a06ddf948 --- /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. diff --git a/docs/code-mode/code-mode-isolates.md b/docs/code-mode/code-mode-isolates.md index fa7ee03e85..2426838801 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,26 @@ 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 +import { createQuickJSIsolateDriver } from '@tanstack/ai-isolate-quickjs' + +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 ea417c3de3..e1a5aa44b6 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-08-13" }, { "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..97acd20ec8 --- /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() + }) +}) diff --git a/packages/ai-isolate-quickjs/vite.config.ts b/packages/ai-isolate-quickjs/vite.config.ts index 77bcc2e60b..68a4c83533 100644 --- a/packages/ai-isolate-quickjs/vite.config.ts +++ b/packages/ai-isolate-quickjs/vite.config.ts @@ -9,7 +9,7 @@ const config = defineConfig({ watch: false, globals: true, environment: 'node', - include: ['tests/**/*.test.ts'], + include: ['src/**/*.test.ts', 'tests/**/*.test.ts'], coverage: { provider: 'v8', reporter: ['text', 'json', 'html', 'lcov'],