Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quickjs-wasm-location.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 17 additions & 1 deletion docs/code-mode/code-mode-isolates.md
Original file line number Diff line number Diff line change
Expand Up @@ -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',
})
```

Expand All @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 15 additions & 1 deletion packages/ai-isolate-quickjs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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

Expand All @@ -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

Expand Down
29 changes: 27 additions & 2 deletions packages/ai-isolate-quickjs/src/isolate-driver.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
}

/**
Expand Down Expand Up @@ -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<typeof newQuickJSWASMModule> | 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<IsolateContext> {
Expand All @@ -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
Expand Down
70 changes: 70 additions & 0 deletions packages/ai-isolate-quickjs/tests/wasm-location.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
2 changes: 1 addition & 1 deletion packages/ai-isolate-quickjs/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
Loading