@tanstack/ai-isolate-quickjs 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # @tanstack/ai-isolate-quickjs
2
+
3
+ QuickJS WASM driver for TanStack AI Code Mode. Runs everywhere — Node.js, browsers, and edge runtimes — with zero native dependencies.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pnpm add @tanstack/ai-isolate-quickjs
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```typescript
14
+ import { createQuickJSIsolateDriver } from '@tanstack/ai-isolate-quickjs'
15
+ import { createCodeModeTool } from '@tanstack/ai-code-mode'
16
+
17
+ const driver = createQuickJSIsolateDriver({
18
+ timeout: 30000, // execution timeout in ms (default: 30000)
19
+ memoryLimit: 128, // memory limit in MB (default: 128)
20
+ maxStackSize: 512 * 1024, // max stack size in bytes (default: 512 KiB)
21
+ })
22
+
23
+ const executeTypescript = createCodeModeTool({
24
+ driver,
25
+ tools: [myTool],
26
+ })
27
+ ```
28
+
29
+ ## Config Options
30
+
31
+ - `timeout` — Default execution timeout in milliseconds (default: 30000)
32
+ - `memoryLimit` — Default QuickJS runtime memory limit in MB (default: 128)
33
+ - `maxStackSize` — Default QuickJS runtime max stack size in bytes (default: 524288)
34
+
35
+ ## Tradeoffs vs Node Driver
36
+
37
+ | | QuickJS (WASM) | Node (`isolated-vm`) |
38
+ | --------------- | -------------------------- | ----------------------- |
39
+ | Native deps | None | Yes (C++ addon) |
40
+ | Browser support | Yes | No |
41
+ | Performance | Slower (interpreted) | Faster (V8 JIT) |
42
+ | Memory limit | Configurable | Configurable |
43
+ | Best for | Browser, edge, portability | Server-side performance |
44
+
45
+ ## How It Works
46
+
47
+ 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.
48
+
49
+ ## Runtime Limits and Errors
50
+
51
+ - QuickJS enforces runtime memory and stack limits for each context.
52
+ - Exceeding limits can produce normalized errors such as `MemoryLimitError` or `StackOverflowError`.
53
+ - Certain fatal limit conditions may dispose the underlying VM; create a fresh context before running more code after disposal.
54
+
55
+ ## License
56
+
57
+ MIT
@@ -0,0 +1,10 @@
1
+ import { NormalizedError } from '@tanstack/ai-code-mode';
2
+ /**
3
+ * Whether this normalized error indicates the QuickJS VM should not be reused
4
+ * (memory or stack limit exceeded).
5
+ */
6
+ export declare function isFatalQuickJSLimitError(error: NormalizedError): boolean;
7
+ /**
8
+ * Normalize various error types into a consistent format
9
+ */
10
+ export declare function normalizeError(error: unknown): NormalizedError;
@@ -0,0 +1,60 @@
1
+ const MEMORY_LIMIT_ERROR = "MemoryLimitError";
2
+ const STACK_OVERFLOW_ERROR = "StackOverflowError";
3
+ function isFatalQuickJSLimitError(error) {
4
+ return error.name === MEMORY_LIMIT_ERROR || error.name === STACK_OVERFLOW_ERROR;
5
+ }
6
+ function normalizeError(error) {
7
+ if (error instanceof Error) {
8
+ const msg = error.message;
9
+ const lower = msg.toLowerCase();
10
+ if (lower.includes("out of memory") || lower.includes("memory alloc") || error.name === "InternalError" && lower.includes("memory")) {
11
+ return {
12
+ name: MEMORY_LIMIT_ERROR,
13
+ message: "Code execution exceeded memory limit",
14
+ stack: error.stack
15
+ };
16
+ }
17
+ if (lower.includes("stack overflow")) {
18
+ return {
19
+ name: STACK_OVERFLOW_ERROR,
20
+ message: "Code execution exceeded stack size limit",
21
+ stack: error.stack
22
+ };
23
+ }
24
+ if (error.name === "RuntimeError" && lower.includes("unreachable")) {
25
+ return {
26
+ name: "WasmRuntimeError",
27
+ message: msg || "WebAssembly runtime error",
28
+ stack: error.stack
29
+ };
30
+ }
31
+ return {
32
+ name: error.name,
33
+ message: error.message,
34
+ stack: error.stack
35
+ };
36
+ }
37
+ if (typeof error === "string") {
38
+ return {
39
+ name: "Error",
40
+ message: error
41
+ };
42
+ }
43
+ if (typeof error === "object" && error !== null) {
44
+ const errObj = error;
45
+ return {
46
+ name: String(errObj.name || "Error"),
47
+ message: String(errObj.message || "Unknown error"),
48
+ stack: errObj.stack ? String(errObj.stack) : void 0
49
+ };
50
+ }
51
+ return {
52
+ name: "UnknownError",
53
+ message: String(error)
54
+ };
55
+ }
56
+ export {
57
+ isFatalQuickJSLimitError,
58
+ normalizeError
59
+ };
60
+ //# sourceMappingURL=error-normalizer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"error-normalizer.js","sources":["../../src/error-normalizer.ts"],"sourcesContent":["import type { NormalizedError } from '@tanstack/ai-code-mode'\n\nconst MEMORY_LIMIT_ERROR = 'MemoryLimitError'\nconst STACK_OVERFLOW_ERROR = 'StackOverflowError'\n\n/**\n * Whether this normalized error indicates the QuickJS VM should not be reused\n * (memory or stack limit exceeded).\n */\nexport function isFatalQuickJSLimitError(error: NormalizedError): boolean {\n return (\n error.name === MEMORY_LIMIT_ERROR || error.name === STACK_OVERFLOW_ERROR\n )\n}\n\n/**\n * Normalize various error types into a consistent format\n */\nexport function normalizeError(error: unknown): NormalizedError {\n if (error instanceof Error) {\n const msg = error.message\n const lower = msg.toLowerCase()\n\n if (\n lower.includes('out of memory') ||\n lower.includes('memory alloc') ||\n (error.name === 'InternalError' && lower.includes('memory'))\n ) {\n return {\n name: MEMORY_LIMIT_ERROR,\n message: 'Code execution exceeded memory limit',\n stack: error.stack,\n }\n }\n\n if (lower.includes('stack overflow')) {\n return {\n name: STACK_OVERFLOW_ERROR,\n message: 'Code execution exceeded stack size limit',\n stack: error.stack,\n }\n }\n\n if (error.name === 'RuntimeError' && lower.includes('unreachable')) {\n return {\n name: 'WasmRuntimeError',\n message: msg || 'WebAssembly runtime error',\n stack: error.stack,\n }\n }\n\n return {\n name: error.name,\n message: error.message,\n stack: error.stack,\n }\n }\n\n if (typeof error === 'string') {\n return {\n name: 'Error',\n message: error,\n }\n }\n\n if (typeof error === 'object' && error !== null) {\n const errObj = error as Record<string, unknown>\n return {\n name: String(errObj.name || 'Error'),\n message: String(errObj.message || 'Unknown error'),\n stack: errObj.stack ? String(errObj.stack) : undefined,\n }\n }\n\n return {\n name: 'UnknownError',\n message: String(error),\n }\n}\n"],"names":[],"mappings":"AAEA,MAAM,qBAAqB;AAC3B,MAAM,uBAAuB;AAMtB,SAAS,yBAAyB,OAAiC;AACxE,SACE,MAAM,SAAS,sBAAsB,MAAM,SAAS;AAExD;AAKO,SAAS,eAAe,OAAiC;AAC9D,MAAI,iBAAiB,OAAO;AAC1B,UAAM,MAAM,MAAM;AAClB,UAAM,QAAQ,IAAI,YAAA;AAElB,QACE,MAAM,SAAS,eAAe,KAC9B,MAAM,SAAS,cAAc,KAC5B,MAAM,SAAS,mBAAmB,MAAM,SAAS,QAAQ,GAC1D;AACA,aAAO;AAAA,QACL,MAAM;AAAA,QACN,SAAS;AAAA,QACT,OAAO,MAAM;AAAA,MAAA;AAAA,IAEjB;AAEA,QAAI,MAAM,SAAS,gBAAgB,GAAG;AACpC,aAAO;AAAA,QACL,MAAM;AAAA,QACN,SAAS;AAAA,QACT,OAAO,MAAM;AAAA,MAAA;AAAA,IAEjB;AAEA,QAAI,MAAM,SAAS,kBAAkB,MAAM,SAAS,aAAa,GAAG;AAClE,aAAO;AAAA,QACL,MAAM;AAAA,QACN,SAAS,OAAO;AAAA,QAChB,OAAO,MAAM;AAAA,MAAA;AAAA,IAEjB;AAEA,WAAO;AAAA,MACL,MAAM,MAAM;AAAA,MACZ,SAAS,MAAM;AAAA,MACf,OAAO,MAAM;AAAA,IAAA;AAAA,EAEjB;AAEA,MAAI,OAAO,UAAU,UAAU;AAC7B,WAAO;AAAA,MACL,MAAM;AAAA,MACN,SAAS;AAAA,IAAA;AAAA,EAEb;AAEA,MAAI,OAAO,UAAU,YAAY,UAAU,MAAM;AAC/C,UAAM,SAAS;AACf,WAAO;AAAA,MACL,MAAM,OAAO,OAAO,QAAQ,OAAO;AAAA,MACnC,SAAS,OAAO,OAAO,WAAW,eAAe;AAAA,MACjD,OAAO,OAAO,QAAQ,OAAO,OAAO,KAAK,IAAI;AAAA,IAAA;AAAA,EAEjD;AAEA,SAAO;AAAA,IACL,MAAM;AAAA,IACN,SAAS,OAAO,KAAK;AAAA,EAAA;AAEzB;"}
@@ -0,0 +1,2 @@
1
+ export { createQuickJSIsolateDriver, type QuickJSIsolateDriverConfig, } from './isolate-driver.js';
2
+ export type { IsolateDriver, IsolateConfig, IsolateContext, ExecutionResult, NormalizedError, } from '@tanstack/ai-code-mode';
@@ -0,0 +1,5 @@
1
+ import { createQuickJSIsolateDriver } from "./isolate-driver.js";
2
+ export {
3
+ createQuickJSIsolateDriver
4
+ };
5
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";"}
@@ -0,0 +1,15 @@
1
+ import { QuickJSAsyncContext } from 'quickjs-emscripten';
2
+ import { ExecutionResult, IsolateContext } from '@tanstack/ai-code-mode';
3
+ /**
4
+ * IsolateContext implementation using QuickJS WASM
5
+ */
6
+ export declare class QuickJSIsolateContext implements IsolateContext {
7
+ private vm;
8
+ private logs;
9
+ private timeout;
10
+ private disposed;
11
+ private executing;
12
+ constructor(vm: QuickJSAsyncContext, logs: Array<string>, timeout: number);
13
+ execute<T = unknown>(code: string): Promise<ExecutionResult<T>>;
14
+ dispose(): Promise<void>;
15
+ }
@@ -0,0 +1,122 @@
1
+ import { wrapCode } from "@tanstack/ai-code-mode";
2
+ import { normalizeError, isFatalQuickJSLimitError } from "./error-normalizer.js";
3
+ let globalExecQueue = Promise.resolve();
4
+ class QuickJSIsolateContext {
5
+ constructor(vm, logs, timeout) {
6
+ this.disposed = false;
7
+ this.executing = false;
8
+ this.vm = vm;
9
+ this.logs = logs;
10
+ this.timeout = timeout;
11
+ }
12
+ async execute(code) {
13
+ if (this.disposed) {
14
+ return {
15
+ success: false,
16
+ error: {
17
+ name: "DisposedError",
18
+ message: "Context has been disposed"
19
+ },
20
+ logs: []
21
+ };
22
+ }
23
+ let resolve;
24
+ const myTurn = new Promise((r) => {
25
+ resolve = r;
26
+ });
27
+ const waitForPrev = globalExecQueue;
28
+ globalExecQueue = myTurn;
29
+ await waitForPrev;
30
+ if (this.disposed) {
31
+ resolve();
32
+ return {
33
+ success: false,
34
+ error: {
35
+ name: "DisposedError",
36
+ message: "Context has been disposed"
37
+ },
38
+ logs: []
39
+ };
40
+ }
41
+ this.executing = true;
42
+ this.logs.length = 0;
43
+ const releaseVmAfterFatalLimit = () => {
44
+ if (this.disposed) return;
45
+ try {
46
+ this.vm.runtime.setInterruptHandler(() => false);
47
+ } catch {
48
+ }
49
+ this.disposed = true;
50
+ this.vm.dispose();
51
+ };
52
+ const fail = (error) => {
53
+ const normalized = normalizeError(error);
54
+ if (isFatalQuickJSLimitError(normalized)) {
55
+ releaseVmAfterFatalLimit();
56
+ }
57
+ return {
58
+ success: false,
59
+ error: normalized,
60
+ logs: [...this.logs]
61
+ };
62
+ };
63
+ try {
64
+ const wrappedCode = wrapCode(code);
65
+ const deadline = Date.now() + this.timeout;
66
+ this.vm.runtime.setInterruptHandler(() => {
67
+ return Date.now() > deadline;
68
+ });
69
+ try {
70
+ const result = await this.vm.evalCodeAsync(wrappedCode);
71
+ let parsedResult;
72
+ try {
73
+ const promiseHandle = this.vm.unwrapResult(result);
74
+ const nativePromise = this.vm.resolvePromise(promiseHandle);
75
+ promiseHandle.dispose();
76
+ this.vm.runtime.executePendingJobs();
77
+ const resolvedResult = await nativePromise;
78
+ const valueHandle = this.vm.unwrapResult(resolvedResult);
79
+ const dumpedResult = this.vm.dump(valueHandle);
80
+ valueHandle.dispose();
81
+ if (typeof dumpedResult === "string") {
82
+ try {
83
+ parsedResult = JSON.parse(dumpedResult);
84
+ } catch {
85
+ parsedResult = dumpedResult;
86
+ }
87
+ } else {
88
+ parsedResult = dumpedResult;
89
+ }
90
+ return {
91
+ success: true,
92
+ value: parsedResult,
93
+ logs: [...this.logs]
94
+ };
95
+ } catch (unwrapError) {
96
+ return fail(unwrapError);
97
+ }
98
+ } finally {
99
+ if (!this.disposed) {
100
+ this.vm.runtime.setInterruptHandler(() => false);
101
+ }
102
+ }
103
+ } catch (error) {
104
+ return fail(error);
105
+ } finally {
106
+ this.executing = false;
107
+ resolve();
108
+ }
109
+ }
110
+ async dispose() {
111
+ if (this.disposed) return;
112
+ if (this.executing) {
113
+ await globalExecQueue;
114
+ }
115
+ this.disposed = true;
116
+ this.vm.dispose();
117
+ }
118
+ }
119
+ export {
120
+ QuickJSIsolateContext
121
+ };
122
+ //# sourceMappingURL=isolate-context.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"isolate-context.js","sources":["../../src/isolate-context.ts"],"sourcesContent":["import { wrapCode } from '@tanstack/ai-code-mode'\nimport { isFatalQuickJSLimitError, normalizeError } from './error-normalizer'\nimport type { QuickJSAsyncContext } from 'quickjs-emscripten'\nimport type { ExecutionResult, IsolateContext } from '@tanstack/ai-code-mode'\n\n/**\n * Serializes all QuickJS evalCodeAsync calls across contexts.\n * Required because newAsyncContext() reuses a singleton WASM module\n * whose asyncify stack can only handle one suspension at a time.\n */\nlet globalExecQueue: Promise<void> = Promise.resolve()\n\n/**\n * IsolateContext implementation using QuickJS WASM\n */\nexport class QuickJSIsolateContext implements IsolateContext {\n private vm: QuickJSAsyncContext\n private logs: Array<string>\n private timeout: number\n private disposed = false\n private executing = false\n\n constructor(vm: QuickJSAsyncContext, logs: Array<string>, timeout: number) {\n this.vm = vm\n this.logs = logs\n this.timeout = timeout\n }\n\n async execute<T = unknown>(code: string): Promise<ExecutionResult<T>> {\n if (this.disposed) {\n return {\n success: false,\n error: {\n name: 'DisposedError',\n message: 'Context has been disposed',\n },\n logs: [],\n }\n }\n\n // Serialize through the global queue to prevent concurrent\n // WASM asyncify suspensions across contexts.\n let resolve!: () => void\n const myTurn = new Promise<void>((r) => {\n resolve = r\n })\n const waitForPrev = globalExecQueue\n globalExecQueue = myTurn\n\n await waitForPrev\n\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- dispose() may be called concurrently while awaiting the queue\n if (this.disposed) {\n resolve()\n return {\n success: false,\n error: {\n name: 'DisposedError',\n message: 'Context has been disposed',\n },\n logs: [],\n }\n }\n\n this.executing = true\n this.logs.length = 0\n\n const releaseVmAfterFatalLimit = () => {\n if (this.disposed) return\n try {\n this.vm.runtime.setInterruptHandler(() => false)\n } catch {\n // ignore if runtime is already torn down\n }\n this.disposed = true\n this.vm.dispose()\n }\n\n const fail = (error: unknown) => {\n const normalized = normalizeError(error)\n if (isFatalQuickJSLimitError(normalized)) {\n releaseVmAfterFatalLimit()\n }\n return {\n success: false as const,\n error: normalized,\n logs: [...this.logs],\n }\n }\n\n try {\n const wrappedCode = wrapCode(code)\n\n const deadline = Date.now() + this.timeout\n this.vm.runtime.setInterruptHandler(() => {\n return Date.now() > deadline\n })\n\n try {\n const result = await this.vm.evalCodeAsync(wrappedCode)\n\n let parsedResult: T\n try {\n const promiseHandle = this.vm.unwrapResult(result)\n\n // evalCodeAsync returns a Promise handle (our wrapper is an async IIFE).\n // Use resolvePromise + executePendingJobs to properly await the\n // QuickJS promise without re-entering the WASM asyncify state.\n const nativePromise = this.vm.resolvePromise(promiseHandle)\n promiseHandle.dispose()\n this.vm.runtime.executePendingJobs()\n const resolvedResult = await nativePromise\n\n const valueHandle = this.vm.unwrapResult(resolvedResult)\n const dumpedResult = this.vm.dump(valueHandle)\n valueHandle.dispose()\n\n if (typeof dumpedResult === 'string') {\n try {\n parsedResult = JSON.parse(dumpedResult) as T\n } catch {\n parsedResult = dumpedResult as T\n }\n } else {\n parsedResult = dumpedResult as T\n }\n\n return {\n success: true,\n value: parsedResult,\n logs: [...this.logs],\n }\n } catch (unwrapError) {\n return fail(unwrapError)\n }\n } finally {\n // fail() may set disposed when releasing the VM after memory/stack limit errors\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- disposed set in fail()\n if (!this.disposed) {\n this.vm.runtime.setInterruptHandler(() => false)\n }\n }\n } catch (error) {\n return fail(error)\n } finally {\n this.executing = false\n resolve()\n }\n }\n\n async dispose(): Promise<void> {\n if (this.disposed) return\n\n // If an execution is in flight, wait for the global queue to drain\n // before disposing the VM. Otherwise the asyncified callback would\n // try to access a freed context.\n if (this.executing) {\n await globalExecQueue\n }\n\n this.disposed = true\n this.vm.dispose()\n }\n}\n"],"names":[],"mappings":"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"}
@@ -0,0 +1,55 @@
1
+ import { IsolateDriver } from '@tanstack/ai-code-mode';
2
+ /**
3
+ * Configuration for the QuickJS WASM isolate driver
4
+ */
5
+ export interface QuickJSIsolateDriverConfig {
6
+ /**
7
+ * Default execution timeout in ms (default: 30000)
8
+ */
9
+ timeout?: number;
10
+ /**
11
+ * Default memory limit in MB (default: 128).
12
+ * Applied via QuickJS `runtime.setMemoryLimit`.
13
+ */
14
+ memoryLimit?: number;
15
+ /**
16
+ * Default max stack size in bytes (default: 512 KiB).
17
+ * Applied via QuickJS `runtime.setMaxStackSize`.
18
+ */
19
+ maxStackSize?: number;
20
+ }
21
+ /**
22
+ * Create a QuickJS WASM isolate driver
23
+ *
24
+ * This driver uses QuickJS compiled to WebAssembly via Emscripten.
25
+ * It provides a sandboxed JavaScript environment that runs anywhere
26
+ * (Node.js, browser, edge) without native dependencies.
27
+ *
28
+ * Tools are injected as async functions that bridge back to the host.
29
+ *
30
+ * @example
31
+ * ```typescript
32
+ * import { createQuickJSIsolateDriver } from '@tanstack/ai-isolate-quickjs'
33
+ *
34
+ * const driver = createQuickJSIsolateDriver({
35
+ * timeout: 30000,
36
+ * })
37
+ *
38
+ * const context = await driver.createContext({
39
+ * bindings: {
40
+ * readFile: {
41
+ * name: 'readFile',
42
+ * description: 'Read a file',
43
+ * inputSchema: { type: 'object', properties: { path: { type: 'string' } } },
44
+ * execute: async ({ path }) => fs.readFile(path, 'utf-8'),
45
+ * },
46
+ * },
47
+ * })
48
+ *
49
+ * const result = await context.execute(`
50
+ * const content = await readFile({ path: './data.json' })
51
+ * return JSON.parse(content)
52
+ * `)
53
+ * ```
54
+ */
55
+ export declare function createQuickJSIsolateDriver(config?: QuickJSIsolateDriverConfig): IsolateDriver;
@@ -0,0 +1,88 @@
1
+ import { newAsyncContext } from "quickjs-emscripten";
2
+ import { QuickJSIsolateContext } from "./isolate-context.js";
3
+ const DEFAULT_MEMORY_LIMIT_MB = 128;
4
+ const DEFAULT_MAX_STACK_SIZE_BYTES = 512 * 1024;
5
+ function createQuickJSIsolateDriver(config = {}) {
6
+ const defaultTimeout = config.timeout ?? 3e4;
7
+ const defaultMemoryLimit = config.memoryLimit ?? DEFAULT_MEMORY_LIMIT_MB;
8
+ const defaultMaxStackSize = config.maxStackSize ?? DEFAULT_MAX_STACK_SIZE_BYTES;
9
+ return {
10
+ async createContext(isolateConfig) {
11
+ const timeout = isolateConfig.timeout ?? defaultTimeout;
12
+ const memoryLimitMb = isolateConfig.memoryLimit ?? defaultMemoryLimit;
13
+ const maxStackSizeBytes = defaultMaxStackSize;
14
+ const vm = await newAsyncContext();
15
+ vm.runtime.setMemoryLimit(memoryLimitMb * 1024 * 1024);
16
+ vm.runtime.setMaxStackSize(maxStackSizeBytes);
17
+ const logs = [];
18
+ const consoleObj = vm.newObject();
19
+ const createConsoleMethod = (prefix) => {
20
+ return vm.newFunction(`console.${prefix}`, (...args) => {
21
+ const parts = args.map((arg) => {
22
+ const str = vm.getString(arg);
23
+ return str;
24
+ });
25
+ const msg = prefix ? `${prefix}: ${parts.join(" ")}` : parts.join(" ");
26
+ logs.push(msg);
27
+ });
28
+ };
29
+ const logFn = createConsoleMethod("");
30
+ const errorFn = createConsoleMethod("ERROR");
31
+ const warnFn = createConsoleMethod("WARN");
32
+ const infoFn = createConsoleMethod("INFO");
33
+ vm.setProp(consoleObj, "log", logFn);
34
+ vm.setProp(consoleObj, "error", errorFn);
35
+ vm.setProp(consoleObj, "warn", warnFn);
36
+ vm.setProp(consoleObj, "info", infoFn);
37
+ vm.setProp(vm.global, "console", consoleObj);
38
+ logFn.dispose();
39
+ errorFn.dispose();
40
+ warnFn.dispose();
41
+ infoFn.dispose();
42
+ consoleObj.dispose();
43
+ for (const [name, binding] of Object.entries(isolateConfig.bindings)) {
44
+ const toolFn = vm.newAsyncifiedFunction(name, async (argsHandle) => {
45
+ try {
46
+ const argsJson = vm.getString(argsHandle);
47
+ const args = JSON.parse(argsJson);
48
+ const result = await binding.execute(args);
49
+ const returnHandle = vm.newString(
50
+ JSON.stringify({ success: true, value: result })
51
+ );
52
+ return returnHandle;
53
+ } catch (error) {
54
+ const errorMessage = error instanceof Error ? error.message : String(error);
55
+ const returnHandle = vm.newString(
56
+ JSON.stringify({ success: false, error: errorMessage })
57
+ );
58
+ return returnHandle;
59
+ }
60
+ });
61
+ vm.setProp(vm.global, `__${name}_impl`, toolFn);
62
+ toolFn.dispose();
63
+ const wrapperCode = `
64
+ async function ${name}(input) {
65
+ const resultJson = await __${name}_impl(JSON.stringify(input));
66
+ const result = JSON.parse(resultJson);
67
+ if (!result.success) {
68
+ throw new Error(result.error);
69
+ }
70
+ return result.value;
71
+ }
72
+ `;
73
+ const wrapperResult = vm.evalCode(wrapperCode);
74
+ if (wrapperResult.error) {
75
+ const errorStr = vm.dump(wrapperResult.error);
76
+ wrapperResult.error.dispose();
77
+ throw new Error(`Failed to create wrapper for ${name}: ${errorStr}`);
78
+ }
79
+ wrapperResult.value.dispose();
80
+ }
81
+ return new QuickJSIsolateContext(vm, logs, timeout);
82
+ }
83
+ };
84
+ }
85
+ export {
86
+ createQuickJSIsolateDriver
87
+ };
88
+ //# sourceMappingURL=isolate-driver.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"isolate-driver.js","sources":["../../src/isolate-driver.ts"],"sourcesContent":["import { newAsyncContext } from 'quickjs-emscripten'\nimport { QuickJSIsolateContext } from './isolate-context'\nimport type {\n IsolateConfig,\n IsolateContext,\n IsolateDriver,\n} from '@tanstack/ai-code-mode'\n\n/** Default memory limit in MB (matches Node isolate driver default). */\nconst DEFAULT_MEMORY_LIMIT_MB = 128\n\n/** Default max stack size in bytes for QuickJS runtime. */\nconst DEFAULT_MAX_STACK_SIZE_BYTES = 512 * 1024\n\n/**\n * Configuration for the QuickJS WASM isolate driver\n */\nexport interface QuickJSIsolateDriverConfig {\n /**\n * Default execution timeout in ms (default: 30000)\n */\n timeout?: number\n\n /**\n * Default memory limit in MB (default: 128).\n * Applied via QuickJS `runtime.setMemoryLimit`.\n */\n memoryLimit?: number\n\n /**\n * Default max stack size in bytes (default: 512 KiB).\n * Applied via QuickJS `runtime.setMaxStackSize`.\n */\n maxStackSize?: number\n}\n\n/**\n * Create a QuickJS WASM isolate driver\n *\n * This driver uses QuickJS compiled to WebAssembly via Emscripten.\n * It provides a sandboxed JavaScript environment that runs anywhere\n * (Node.js, browser, edge) without native dependencies.\n *\n * Tools are injected as async functions that bridge back to the host.\n *\n * @example\n * ```typescript\n * import { createQuickJSIsolateDriver } from '@tanstack/ai-isolate-quickjs'\n *\n * const driver = createQuickJSIsolateDriver({\n * timeout: 30000,\n * })\n *\n * const context = await driver.createContext({\n * bindings: {\n * readFile: {\n * name: 'readFile',\n * description: 'Read a file',\n * inputSchema: { type: 'object', properties: { path: { type: 'string' } } },\n * execute: async ({ path }) => fs.readFile(path, 'utf-8'),\n * },\n * },\n * })\n *\n * const result = await context.execute(`\n * const content = await readFile({ path: './data.json' })\n * return JSON.parse(content)\n * `)\n * ```\n */\nexport function createQuickJSIsolateDriver(\n config: QuickJSIsolateDriverConfig = {},\n): IsolateDriver {\n const defaultTimeout = config.timeout ?? 30000\n const defaultMemoryLimit = config.memoryLimit ?? DEFAULT_MEMORY_LIMIT_MB\n const defaultMaxStackSize =\n config.maxStackSize ?? DEFAULT_MAX_STACK_SIZE_BYTES\n\n return {\n async createContext(isolateConfig: IsolateConfig): Promise<IsolateContext> {\n const timeout = isolateConfig.timeout ?? defaultTimeout\n const memoryLimitMb = isolateConfig.memoryLimit ?? defaultMemoryLimit\n const maxStackSizeBytes = defaultMaxStackSize\n\n // Create async QuickJS context (supports async host functions)\n const vm = await newAsyncContext()\n\n // Enforce heap and stack limits so OOM/stack overflow surface as JS errors\n // instead of growing WASM memory until the host process OOMs.\n vm.runtime.setMemoryLimit(memoryLimitMb * 1024 * 1024)\n vm.runtime.setMaxStackSize(maxStackSizeBytes)\n\n // Set up console.log capture\n const logs: Array<string> = []\n\n // Create console object\n const consoleObj = vm.newObject()\n\n // Helper to create console methods\n const createConsoleMethod = (prefix: string) => {\n return vm.newFunction(`console.${prefix}`, (...args) => {\n const parts = args.map((arg) => {\n const str = vm.getString(arg)\n return str\n })\n const msg = prefix ? `${prefix}: ${parts.join(' ')}` : parts.join(' ')\n logs.push(msg)\n })\n }\n\n const logFn = createConsoleMethod('')\n const errorFn = createConsoleMethod('ERROR')\n const warnFn = createConsoleMethod('WARN')\n const infoFn = createConsoleMethod('INFO')\n\n vm.setProp(consoleObj, 'log', logFn)\n vm.setProp(consoleObj, 'error', errorFn)\n vm.setProp(consoleObj, 'warn', warnFn)\n vm.setProp(consoleObj, 'info', infoFn)\n vm.setProp(vm.global, 'console', consoleObj)\n\n // Dispose console handles\n logFn.dispose()\n errorFn.dispose()\n warnFn.dispose()\n infoFn.dispose()\n consoleObj.dispose()\n\n // Inject each tool binding as an async function\n for (const [name, binding] of Object.entries(isolateConfig.bindings)) {\n // Create async function that calls back to host\n // newAsyncifiedFunction receives QuickJS handles as arguments\n const toolFn = vm.newAsyncifiedFunction(name, async (argsHandle) => {\n try {\n // Get the input argument - argsHandle is a QuickJS handle\n const argsJson = vm.getString(argsHandle)\n const args = JSON.parse(argsJson)\n\n // Execute the tool on the host\n const result = await binding.execute(args)\n\n // Return result as JSON string handle\n const returnHandle = vm.newString(\n JSON.stringify({ success: true, value: result }),\n )\n return returnHandle\n } catch (error) {\n const errorMessage =\n error instanceof Error ? error.message : String(error)\n const returnHandle = vm.newString(\n JSON.stringify({ success: false, error: errorMessage }),\n )\n return returnHandle\n }\n })\n\n // Set on global - the VM keeps its own reference\n vm.setProp(vm.global, `__${name}_impl`, toolFn)\n toolFn.dispose()\n\n // Create wrapper that parses input and output\n // Function names match the binding keys (e.g., external_fetchWeather)\n const wrapperCode = `\n async function ${name}(input) {\n const resultJson = await __${name}_impl(JSON.stringify(input));\n const result = JSON.parse(resultJson);\n if (!result.success) {\n throw new Error(result.error);\n }\n return result.value;\n }\n `\n const wrapperResult = vm.evalCode(wrapperCode)\n if (wrapperResult.error) {\n const errorStr = vm.dump(wrapperResult.error)\n wrapperResult.error.dispose()\n throw new Error(`Failed to create wrapper for ${name}: ${errorStr}`)\n }\n wrapperResult.value.dispose()\n }\n\n return new QuickJSIsolateContext(vm, logs, timeout)\n },\n }\n}\n"],"names":[],"mappings":";;AASA,MAAM,0BAA0B;AAGhC,MAAM,+BAA+B,MAAM;AA0DpC,SAAS,2BACd,SAAqC,IACtB;AACf,QAAM,iBAAiB,OAAO,WAAW;AACzC,QAAM,qBAAqB,OAAO,eAAe;AACjD,QAAM,sBACJ,OAAO,gBAAgB;AAEzB,SAAO;AAAA,IACL,MAAM,cAAc,eAAuD;AACzE,YAAM,UAAU,cAAc,WAAW;AACzC,YAAM,gBAAgB,cAAc,eAAe;AACnD,YAAM,oBAAoB;AAG1B,YAAM,KAAK,MAAM,gBAAA;AAIjB,SAAG,QAAQ,eAAe,gBAAgB,OAAO,IAAI;AACrD,SAAG,QAAQ,gBAAgB,iBAAiB;AAG5C,YAAM,OAAsB,CAAA;AAG5B,YAAM,aAAa,GAAG,UAAA;AAGtB,YAAM,sBAAsB,CAAC,WAAmB;AAC9C,eAAO,GAAG,YAAY,WAAW,MAAM,IAAI,IAAI,SAAS;AACtD,gBAAM,QAAQ,KAAK,IAAI,CAAC,QAAQ;AAC9B,kBAAM,MAAM,GAAG,UAAU,GAAG;AAC5B,mBAAO;AAAA,UACT,CAAC;AACD,gBAAM,MAAM,SAAS,GAAG,MAAM,KAAK,MAAM,KAAK,GAAG,CAAC,KAAK,MAAM,KAAK,GAAG;AACrE,eAAK,KAAK,GAAG;AAAA,QACf,CAAC;AAAA,MACH;AAEA,YAAM,QAAQ,oBAAoB,EAAE;AACpC,YAAM,UAAU,oBAAoB,OAAO;AAC3C,YAAM,SAAS,oBAAoB,MAAM;AACzC,YAAM,SAAS,oBAAoB,MAAM;AAEzC,SAAG,QAAQ,YAAY,OAAO,KAAK;AACnC,SAAG,QAAQ,YAAY,SAAS,OAAO;AACvC,SAAG,QAAQ,YAAY,QAAQ,MAAM;AACrC,SAAG,QAAQ,YAAY,QAAQ,MAAM;AACrC,SAAG,QAAQ,GAAG,QAAQ,WAAW,UAAU;AAG3C,YAAM,QAAA;AACN,cAAQ,QAAA;AACR,aAAO,QAAA;AACP,aAAO,QAAA;AACP,iBAAW,QAAA;AAGX,iBAAW,CAAC,MAAM,OAAO,KAAK,OAAO,QAAQ,cAAc,QAAQ,GAAG;AAGpE,cAAM,SAAS,GAAG,sBAAsB,MAAM,OAAO,eAAe;AAClE,cAAI;AAEF,kBAAM,WAAW,GAAG,UAAU,UAAU;AACxC,kBAAM,OAAO,KAAK,MAAM,QAAQ;AAGhC,kBAAM,SAAS,MAAM,QAAQ,QAAQ,IAAI;AAGzC,kBAAM,eAAe,GAAG;AAAA,cACtB,KAAK,UAAU,EAAE,SAAS,MAAM,OAAO,QAAQ;AAAA,YAAA;AAEjD,mBAAO;AAAA,UACT,SAAS,OAAO;AACd,kBAAM,eACJ,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;AACvD,kBAAM,eAAe,GAAG;AAAA,cACtB,KAAK,UAAU,EAAE,SAAS,OAAO,OAAO,cAAc;AAAA,YAAA;AAExD,mBAAO;AAAA,UACT;AAAA,QACF,CAAC;AAGD,WAAG,QAAQ,GAAG,QAAQ,KAAK,IAAI,SAAS,MAAM;AAC9C,eAAO,QAAA;AAIP,cAAM,cAAc;AAAA,2BACD,IAAI;AAAA,yCACU,IAAI;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAQrC,cAAM,gBAAgB,GAAG,SAAS,WAAW;AAC7C,YAAI,cAAc,OAAO;AACvB,gBAAM,WAAW,GAAG,KAAK,cAAc,KAAK;AAC5C,wBAAc,MAAM,QAAA;AACpB,gBAAM,IAAI,MAAM,gCAAgC,IAAI,KAAK,QAAQ,EAAE;AAAA,QACrE;AACA,sBAAc,MAAM,QAAA;AAAA,MACtB;AAEA,aAAO,IAAI,sBAAsB,IAAI,MAAM,OAAO;AAAA,IACpD;AAAA,EAAA;AAEJ;"}
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@tanstack/ai-isolate-quickjs",
3
+ "version": "0.1.0",
4
+ "description": "QuickJS WASM driver for TanStack AI Code Mode - runs everywhere",
5
+ "author": "",
6
+ "license": "MIT",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/TanStack/ai.git",
13
+ "directory": "packages/typescript/ai-isolate-quickjs"
14
+ },
15
+ "type": "module",
16
+ "module": "./dist/esm/index.js",
17
+ "types": "./dist/esm/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/esm/index.d.ts",
21
+ "import": "./dist/esm/index.js"
22
+ }
23
+ },
24
+ "sideEffects": false,
25
+ "files": [
26
+ "dist",
27
+ "src"
28
+ ],
29
+ "scripts": {
30
+ "build": "vite build",
31
+ "clean": "premove ./build ./dist",
32
+ "lint:fix": "eslint ./src --fix",
33
+ "test:build": "publint --strict",
34
+ "test:eslint": "eslint ./src",
35
+ "test:lib": "vitest --passWithNoTests",
36
+ "test:lib:dev": "pnpm test:lib --watch",
37
+ "test:types": "tsc"
38
+ },
39
+ "keywords": [
40
+ "ai",
41
+ "tanstack",
42
+ "code-mode",
43
+ "isolate",
44
+ "sandbox",
45
+ "quickjs",
46
+ "wasm"
47
+ ],
48
+ "dependencies": {
49
+ "quickjs-emscripten": "^0.31.0"
50
+ },
51
+ "peerDependencies": {
52
+ "@tanstack/ai-code-mode": "workspace:*"
53
+ },
54
+ "devDependencies": {
55
+ "@vitest/coverage-v8": "4.0.14"
56
+ }
57
+ }
@@ -0,0 +1,79 @@
1
+ import type { NormalizedError } from '@tanstack/ai-code-mode'
2
+
3
+ const MEMORY_LIMIT_ERROR = 'MemoryLimitError'
4
+ const STACK_OVERFLOW_ERROR = 'StackOverflowError'
5
+
6
+ /**
7
+ * Whether this normalized error indicates the QuickJS VM should not be reused
8
+ * (memory or stack limit exceeded).
9
+ */
10
+ export function isFatalQuickJSLimitError(error: NormalizedError): boolean {
11
+ return (
12
+ error.name === MEMORY_LIMIT_ERROR || error.name === STACK_OVERFLOW_ERROR
13
+ )
14
+ }
15
+
16
+ /**
17
+ * Normalize various error types into a consistent format
18
+ */
19
+ export function normalizeError(error: unknown): NormalizedError {
20
+ if (error instanceof Error) {
21
+ const msg = error.message
22
+ const lower = msg.toLowerCase()
23
+
24
+ if (
25
+ lower.includes('out of memory') ||
26
+ lower.includes('memory alloc') ||
27
+ (error.name === 'InternalError' && lower.includes('memory'))
28
+ ) {
29
+ return {
30
+ name: MEMORY_LIMIT_ERROR,
31
+ message: 'Code execution exceeded memory limit',
32
+ stack: error.stack,
33
+ }
34
+ }
35
+
36
+ if (lower.includes('stack overflow')) {
37
+ return {
38
+ name: STACK_OVERFLOW_ERROR,
39
+ message: 'Code execution exceeded stack size limit',
40
+ stack: error.stack,
41
+ }
42
+ }
43
+
44
+ if (error.name === 'RuntimeError' && lower.includes('unreachable')) {
45
+ return {
46
+ name: 'WasmRuntimeError',
47
+ message: msg || 'WebAssembly runtime error',
48
+ stack: error.stack,
49
+ }
50
+ }
51
+
52
+ return {
53
+ name: error.name,
54
+ message: error.message,
55
+ stack: error.stack,
56
+ }
57
+ }
58
+
59
+ if (typeof error === 'string') {
60
+ return {
61
+ name: 'Error',
62
+ message: error,
63
+ }
64
+ }
65
+
66
+ if (typeof error === 'object' && error !== null) {
67
+ const errObj = error as Record<string, unknown>
68
+ return {
69
+ name: String(errObj.name || 'Error'),
70
+ message: String(errObj.message || 'Unknown error'),
71
+ stack: errObj.stack ? String(errObj.stack) : undefined,
72
+ }
73
+ }
74
+
75
+ return {
76
+ name: 'UnknownError',
77
+ message: String(error),
78
+ }
79
+ }
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ export {
2
+ createQuickJSIsolateDriver,
3
+ type QuickJSIsolateDriverConfig,
4
+ } from './isolate-driver'
5
+
6
+ // Re-export types from ai-code-mode for convenience
7
+ export type {
8
+ IsolateDriver,
9
+ IsolateConfig,
10
+ IsolateContext,
11
+ ExecutionResult,
12
+ NormalizedError,
13
+ } from '@tanstack/ai-code-mode'
@@ -0,0 +1,164 @@
1
+ import { wrapCode } from '@tanstack/ai-code-mode'
2
+ import { isFatalQuickJSLimitError, normalizeError } from './error-normalizer'
3
+ import type { QuickJSAsyncContext } from 'quickjs-emscripten'
4
+ import type { ExecutionResult, IsolateContext } from '@tanstack/ai-code-mode'
5
+
6
+ /**
7
+ * Serializes all QuickJS evalCodeAsync calls across contexts.
8
+ * Required because newAsyncContext() reuses a singleton WASM module
9
+ * whose asyncify stack can only handle one suspension at a time.
10
+ */
11
+ let globalExecQueue: Promise<void> = Promise.resolve()
12
+
13
+ /**
14
+ * IsolateContext implementation using QuickJS WASM
15
+ */
16
+ export class QuickJSIsolateContext implements IsolateContext {
17
+ private vm: QuickJSAsyncContext
18
+ private logs: Array<string>
19
+ private timeout: number
20
+ private disposed = false
21
+ private executing = false
22
+
23
+ constructor(vm: QuickJSAsyncContext, logs: Array<string>, timeout: number) {
24
+ this.vm = vm
25
+ this.logs = logs
26
+ this.timeout = timeout
27
+ }
28
+
29
+ async execute<T = unknown>(code: string): Promise<ExecutionResult<T>> {
30
+ if (this.disposed) {
31
+ return {
32
+ success: false,
33
+ error: {
34
+ name: 'DisposedError',
35
+ message: 'Context has been disposed',
36
+ },
37
+ logs: [],
38
+ }
39
+ }
40
+
41
+ // Serialize through the global queue to prevent concurrent
42
+ // WASM asyncify suspensions across contexts.
43
+ let resolve!: () => void
44
+ const myTurn = new Promise<void>((r) => {
45
+ resolve = r
46
+ })
47
+ const waitForPrev = globalExecQueue
48
+ globalExecQueue = myTurn
49
+
50
+ await waitForPrev
51
+
52
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- dispose() may be called concurrently while awaiting the queue
53
+ if (this.disposed) {
54
+ resolve()
55
+ return {
56
+ success: false,
57
+ error: {
58
+ name: 'DisposedError',
59
+ message: 'Context has been disposed',
60
+ },
61
+ logs: [],
62
+ }
63
+ }
64
+
65
+ this.executing = true
66
+ this.logs.length = 0
67
+
68
+ const releaseVmAfterFatalLimit = () => {
69
+ if (this.disposed) return
70
+ try {
71
+ this.vm.runtime.setInterruptHandler(() => false)
72
+ } catch {
73
+ // ignore if runtime is already torn down
74
+ }
75
+ this.disposed = true
76
+ this.vm.dispose()
77
+ }
78
+
79
+ const fail = (error: unknown) => {
80
+ const normalized = normalizeError(error)
81
+ if (isFatalQuickJSLimitError(normalized)) {
82
+ releaseVmAfterFatalLimit()
83
+ }
84
+ return {
85
+ success: false as const,
86
+ error: normalized,
87
+ logs: [...this.logs],
88
+ }
89
+ }
90
+
91
+ try {
92
+ const wrappedCode = wrapCode(code)
93
+
94
+ const deadline = Date.now() + this.timeout
95
+ this.vm.runtime.setInterruptHandler(() => {
96
+ return Date.now() > deadline
97
+ })
98
+
99
+ try {
100
+ const result = await this.vm.evalCodeAsync(wrappedCode)
101
+
102
+ let parsedResult: T
103
+ try {
104
+ const promiseHandle = this.vm.unwrapResult(result)
105
+
106
+ // evalCodeAsync returns a Promise handle (our wrapper is an async IIFE).
107
+ // Use resolvePromise + executePendingJobs to properly await the
108
+ // QuickJS promise without re-entering the WASM asyncify state.
109
+ const nativePromise = this.vm.resolvePromise(promiseHandle)
110
+ promiseHandle.dispose()
111
+ this.vm.runtime.executePendingJobs()
112
+ const resolvedResult = await nativePromise
113
+
114
+ const valueHandle = this.vm.unwrapResult(resolvedResult)
115
+ const dumpedResult = this.vm.dump(valueHandle)
116
+ valueHandle.dispose()
117
+
118
+ if (typeof dumpedResult === 'string') {
119
+ try {
120
+ parsedResult = JSON.parse(dumpedResult) as T
121
+ } catch {
122
+ parsedResult = dumpedResult as T
123
+ }
124
+ } else {
125
+ parsedResult = dumpedResult as T
126
+ }
127
+
128
+ return {
129
+ success: true,
130
+ value: parsedResult,
131
+ logs: [...this.logs],
132
+ }
133
+ } catch (unwrapError) {
134
+ return fail(unwrapError)
135
+ }
136
+ } finally {
137
+ // fail() may set disposed when releasing the VM after memory/stack limit errors
138
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- disposed set in fail()
139
+ if (!this.disposed) {
140
+ this.vm.runtime.setInterruptHandler(() => false)
141
+ }
142
+ }
143
+ } catch (error) {
144
+ return fail(error)
145
+ } finally {
146
+ this.executing = false
147
+ resolve()
148
+ }
149
+ }
150
+
151
+ async dispose(): Promise<void> {
152
+ if (this.disposed) return
153
+
154
+ // If an execution is in flight, wait for the global queue to drain
155
+ // before disposing the VM. Otherwise the asyncified callback would
156
+ // try to access a freed context.
157
+ if (this.executing) {
158
+ await globalExecQueue
159
+ }
160
+
161
+ this.disposed = true
162
+ this.vm.dispose()
163
+ }
164
+ }
@@ -0,0 +1,185 @@
1
+ import { newAsyncContext } from 'quickjs-emscripten'
2
+ import { QuickJSIsolateContext } from './isolate-context'
3
+ import type {
4
+ IsolateConfig,
5
+ IsolateContext,
6
+ IsolateDriver,
7
+ } from '@tanstack/ai-code-mode'
8
+
9
+ /** Default memory limit in MB (matches Node isolate driver default). */
10
+ const DEFAULT_MEMORY_LIMIT_MB = 128
11
+
12
+ /** Default max stack size in bytes for QuickJS runtime. */
13
+ const DEFAULT_MAX_STACK_SIZE_BYTES = 512 * 1024
14
+
15
+ /**
16
+ * Configuration for the QuickJS WASM isolate driver
17
+ */
18
+ export interface QuickJSIsolateDriverConfig {
19
+ /**
20
+ * Default execution timeout in ms (default: 30000)
21
+ */
22
+ timeout?: number
23
+
24
+ /**
25
+ * Default memory limit in MB (default: 128).
26
+ * Applied via QuickJS `runtime.setMemoryLimit`.
27
+ */
28
+ memoryLimit?: number
29
+
30
+ /**
31
+ * Default max stack size in bytes (default: 512 KiB).
32
+ * Applied via QuickJS `runtime.setMaxStackSize`.
33
+ */
34
+ maxStackSize?: number
35
+ }
36
+
37
+ /**
38
+ * Create a QuickJS WASM isolate driver
39
+ *
40
+ * This driver uses QuickJS compiled to WebAssembly via Emscripten.
41
+ * It provides a sandboxed JavaScript environment that runs anywhere
42
+ * (Node.js, browser, edge) without native dependencies.
43
+ *
44
+ * Tools are injected as async functions that bridge back to the host.
45
+ *
46
+ * @example
47
+ * ```typescript
48
+ * import { createQuickJSIsolateDriver } from '@tanstack/ai-isolate-quickjs'
49
+ *
50
+ * const driver = createQuickJSIsolateDriver({
51
+ * timeout: 30000,
52
+ * })
53
+ *
54
+ * const context = await driver.createContext({
55
+ * bindings: {
56
+ * readFile: {
57
+ * name: 'readFile',
58
+ * description: 'Read a file',
59
+ * inputSchema: { type: 'object', properties: { path: { type: 'string' } } },
60
+ * execute: async ({ path }) => fs.readFile(path, 'utf-8'),
61
+ * },
62
+ * },
63
+ * })
64
+ *
65
+ * const result = await context.execute(`
66
+ * const content = await readFile({ path: './data.json' })
67
+ * return JSON.parse(content)
68
+ * `)
69
+ * ```
70
+ */
71
+ export function createQuickJSIsolateDriver(
72
+ config: QuickJSIsolateDriverConfig = {},
73
+ ): IsolateDriver {
74
+ const defaultTimeout = config.timeout ?? 30000
75
+ const defaultMemoryLimit = config.memoryLimit ?? DEFAULT_MEMORY_LIMIT_MB
76
+ const defaultMaxStackSize =
77
+ config.maxStackSize ?? DEFAULT_MAX_STACK_SIZE_BYTES
78
+
79
+ return {
80
+ async createContext(isolateConfig: IsolateConfig): Promise<IsolateContext> {
81
+ const timeout = isolateConfig.timeout ?? defaultTimeout
82
+ const memoryLimitMb = isolateConfig.memoryLimit ?? defaultMemoryLimit
83
+ const maxStackSizeBytes = defaultMaxStackSize
84
+
85
+ // Create async QuickJS context (supports async host functions)
86
+ const vm = await newAsyncContext()
87
+
88
+ // Enforce heap and stack limits so OOM/stack overflow surface as JS errors
89
+ // instead of growing WASM memory until the host process OOMs.
90
+ vm.runtime.setMemoryLimit(memoryLimitMb * 1024 * 1024)
91
+ vm.runtime.setMaxStackSize(maxStackSizeBytes)
92
+
93
+ // Set up console.log capture
94
+ const logs: Array<string> = []
95
+
96
+ // Create console object
97
+ const consoleObj = vm.newObject()
98
+
99
+ // Helper to create console methods
100
+ const createConsoleMethod = (prefix: string) => {
101
+ return vm.newFunction(`console.${prefix}`, (...args) => {
102
+ const parts = args.map((arg) => {
103
+ const str = vm.getString(arg)
104
+ return str
105
+ })
106
+ const msg = prefix ? `${prefix}: ${parts.join(' ')}` : parts.join(' ')
107
+ logs.push(msg)
108
+ })
109
+ }
110
+
111
+ const logFn = createConsoleMethod('')
112
+ const errorFn = createConsoleMethod('ERROR')
113
+ const warnFn = createConsoleMethod('WARN')
114
+ const infoFn = createConsoleMethod('INFO')
115
+
116
+ vm.setProp(consoleObj, 'log', logFn)
117
+ vm.setProp(consoleObj, 'error', errorFn)
118
+ vm.setProp(consoleObj, 'warn', warnFn)
119
+ vm.setProp(consoleObj, 'info', infoFn)
120
+ vm.setProp(vm.global, 'console', consoleObj)
121
+
122
+ // Dispose console handles
123
+ logFn.dispose()
124
+ errorFn.dispose()
125
+ warnFn.dispose()
126
+ infoFn.dispose()
127
+ consoleObj.dispose()
128
+
129
+ // Inject each tool binding as an async function
130
+ for (const [name, binding] of Object.entries(isolateConfig.bindings)) {
131
+ // Create async function that calls back to host
132
+ // newAsyncifiedFunction receives QuickJS handles as arguments
133
+ const toolFn = vm.newAsyncifiedFunction(name, async (argsHandle) => {
134
+ try {
135
+ // Get the input argument - argsHandle is a QuickJS handle
136
+ const argsJson = vm.getString(argsHandle)
137
+ const args = JSON.parse(argsJson)
138
+
139
+ // Execute the tool on the host
140
+ const result = await binding.execute(args)
141
+
142
+ // Return result as JSON string handle
143
+ const returnHandle = vm.newString(
144
+ JSON.stringify({ success: true, value: result }),
145
+ )
146
+ return returnHandle
147
+ } catch (error) {
148
+ const errorMessage =
149
+ error instanceof Error ? error.message : String(error)
150
+ const returnHandle = vm.newString(
151
+ JSON.stringify({ success: false, error: errorMessage }),
152
+ )
153
+ return returnHandle
154
+ }
155
+ })
156
+
157
+ // Set on global - the VM keeps its own reference
158
+ vm.setProp(vm.global, `__${name}_impl`, toolFn)
159
+ toolFn.dispose()
160
+
161
+ // Create wrapper that parses input and output
162
+ // Function names match the binding keys (e.g., external_fetchWeather)
163
+ const wrapperCode = `
164
+ async function ${name}(input) {
165
+ const resultJson = await __${name}_impl(JSON.stringify(input));
166
+ const result = JSON.parse(resultJson);
167
+ if (!result.success) {
168
+ throw new Error(result.error);
169
+ }
170
+ return result.value;
171
+ }
172
+ `
173
+ const wrapperResult = vm.evalCode(wrapperCode)
174
+ if (wrapperResult.error) {
175
+ const errorStr = vm.dump(wrapperResult.error)
176
+ wrapperResult.error.dispose()
177
+ throw new Error(`Failed to create wrapper for ${name}: ${errorStr}`)
178
+ }
179
+ wrapperResult.value.dispose()
180
+ }
181
+
182
+ return new QuickJSIsolateContext(vm, logs, timeout)
183
+ },
184
+ }
185
+ }