Skip to content

Commit 81e3aee

Browse files
fix(code-mode): drop esbuild for edge-safe TypeScript stripping (#487) (#799)
* fix(code-mode): drop esbuild for edge-safe TypeScript stripping (#487) @tanstack/ai-code-mode hard-depended on esbuild to strip TypeScript before sandbox execution. esbuild ships a Node-native binary and pulls in Node-only built-ins (e.g. require("pnpapi")), which broke browser bundles and edge runtimes such as Cloudflare Workers/Pages. Replace esbuild with sucrase, a pure-JavaScript transform with no native binary, on the default TypeScript-stripping path. Add an optional `transpile` escape hatch on createCodeModeTool so callers who don't need edge safety can swap in a heavier Node-only transpiler (e.g. esbuild). sucrase is a type-stripper rather than a down-leveler, so unlike esbuild it does not compile a few exotic constructs (value `namespace` blocks, decorators, the `accessor` keyword, post-ES2022 syntax such as `using` and the `/v` regex flag). These are documented on stripTypeScript and in the changeset, with the transpile hook as the workaround. Add an edge-safety guard test asserting no source imports esbuild or a Node-only built-in, and keep the dependency out of every install-facing bucket. * test(code-mode): escape module names in edge-safety import scan Escape regex metacharacters before interpolating each FORBIDDEN entry into the import-scan pattern. The current entries contain no metacharacters, so behavior is unchanged, but this keeps the pattern correct if an entry ever contains one and silences a static-analysis ReDoS warning. * ci: apply automated fixes --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 5e57c62 commit 81e3aee

11 files changed

Lines changed: 296 additions & 25 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@tanstack/ai-code-mode': patch
3+
---
4+
5+
fix(code-mode): drop esbuild for edge-safe TypeScript stripping
6+
7+
`@tanstack/ai-code-mode` hard-depended on `esbuild` to strip TypeScript before sandbox execution. esbuild ships a Node-native binary and pulls in Node-only built-ins (e.g. `require("pnpapi")`), which broke browser bundles and edge runtimes such as Cloudflare Workers/Pages. The default transpiler is now `sucrase`, a pure-JavaScript transform that is safe to bundle for browsers and edge runtimes.
8+
9+
sucrase is a type-stripper rather than a down-leveler, so unlike esbuild it does not compile a few exotic constructs: TypeScript value `namespace` blocks are dropped, decorators / the `accessor` keyword pass through un-lowered, and post-ES2022 syntax (`using`, RegExp `/v`·`/d` flags) is left as-is (fine on modern V8/Node sandboxes, but may fail on older engines like QuickJS). A new `transpile` escape hatch on `createCodeModeTool` lets you swap in a heavier Node-only transpiler (e.g. esbuild) when you need that coverage and don't need edge safety.

docs/code-mode/code-mode.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ For full configuration options for each driver, see [Isolate Drivers](./code-mod
215215

216216
These utilities are used internally and are exported for custom pipelines:
217217

218-
- **`stripTypeScript(code)`** — Strips TypeScript syntax using esbuild, converting to plain JavaScript.
218+
- **`stripTypeScript(code)`** — Strips TypeScript syntax using sucrase (edge-safe, no native binary), converting to plain JavaScript.
219219
- **`toolsToBindings(tools, prefix?)`** — Converts TanStack AI tools into `Record<string, ToolBinding>` for sandbox injection.
220220
- **`generateTypeStubs(bindings, options?)`** — Generates TypeScript type declarations from tool bindings for system prompts.
221221

packages/ai-code-mode/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ Lower-level functions if you need only the tool or only the prompt. `createCodeM
9696

9797
These utilities are used internally and exported for custom pipelines:
9898

99-
- **`stripTypeScript(code)`** — Strips TypeScript syntax using esbuild.
99+
- **`stripTypeScript(code)`** — Strips TypeScript syntax using sucrase (edge-safe, no native binary).
100100
- **`toolsToBindings(tools, prefix?)`** — Converts tools to `ToolBinding` records for sandbox injection.
101101
- **`generateTypeStubs(bindings, options?)`** — Generates TypeScript type declarations from tool bindings.
102102

packages/ai-code-mode/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@
6060
"tanstack-intent"
6161
],
6262
"dependencies": {
63-
"esbuild": "^0.25.12"
63+
"sucrase": "^3.35.0"
6464
},
6565
"peerDependencies": {
6666
"@tanstack/ai": "workspace:*",

packages/ai-code-mode/src/create-code-mode-tool.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ export function createCodeModeTool(
9393
timeout = 30000,
9494
memoryLimit = 128,
9595
getSkillBindings,
96+
transpile = stripTypeScript,
9697
} = config
9798

9899
// Validate tools
@@ -142,12 +143,13 @@ export function createCodeModeTool(
142143
})
143144

144145
try {
145-
// Step 1: Strip TypeScript (also serves as syntax validation via esbuild)
146+
// Step 1: Strip TypeScript (also serves as syntax validation via the
147+
// transpiler — sucrase by default, or a user-supplied `transpile`)
146148
let strippedCode: string
147149
try {
148-
strippedCode = await stripTypeScript(typescriptCode)
150+
strippedCode = await transpile(typescriptCode)
149151
} catch (error) {
150-
// Type/syntax error from esbuild
152+
// Type/syntax error from the transpiler
151153
return {
152154
success: false,
153155
error: {

packages/ai-code-mode/src/strip-typescript.ts

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { transform } from 'esbuild'
1+
import { transform } from 'sucrase'
22

33
// Unique markers for wrapping/unwrapping code
44
const WRAPPER_START = '___TANSTACK_WRAPPER_START___'
@@ -11,34 +11,56 @@ const WRAPPER_END = '___TANSTACK_WRAPPER_END___'
1111
* code with type annotations, it will be converted to valid JavaScript
1212
* before being sent to the sandbox for execution.
1313
*
14-
* Uses esbuild's transform API which is extremely fast and handles all
15-
* TypeScript syntax including:
14+
* Uses sucrase's pure-JavaScript `transform`, which strips the TypeScript
15+
* syntax that LLM-generated snippets use in practice:
1616
* - Type annotations (: string, : number, etc.)
1717
* - Generic types (Array<T>, Record<K, V>, etc.)
1818
* - Interface and type declarations
1919
* - Type assertions
2020
* - Enums (converted to JavaScript objects)
2121
*
22+
* Unlike esbuild, sucrase has no native binary and pulls in no Node-only
23+
* built-ins on its `transform` path, so this module is safe to bundle for
24+
* browsers and edge runtimes (Cloudflare Workers/Pages etc.).
25+
*
26+
* Limitations vs esbuild: sucrase is a type-stripper, not a down-leveler.
27+
* `disableESTransforms` leaves modern ECMAScript syntax untouched (the sandbox
28+
* engines are modern), and sucrase does NOT compile a few exotic constructs:
29+
* - TypeScript value `namespace`/`module` blocks are DROPPED (not emitted as an
30+
* IIFE), so referencing the namespace at runtime throws `ReferenceError`.
31+
* - Decorators and the `accessor` keyword pass through un-lowered, so the
32+
* sandbox sees invalid syntax.
33+
* - Post-ES2022 syntax (`using` declarations, RegExp `/v`·`/d` flags) is passed
34+
* through; it runs on modern V8/Node sandboxes but may fail on older engines
35+
* (e.g. QuickJS).
36+
* If you need any of these, supply a heavier (Node-only) transpiler via the
37+
* `transpile` option on `createCodeModeTool`.
38+
*
2239
* The code is wrapped in an async function before transformation to allow
2340
* top-level `return` and `await` statements, then unwrapped after.
2441
*
42+
* Note on errors: sucrase reports syntax errors with a position relative to the
43+
* *wrapped* code (offset by the one-line wrapper prefix), so any line numbers
44+
* surfaced downstream (e.g. `CodeModeToolResult.error.line`) are approximate.
45+
*
2546
* @param code - TypeScript or JavaScript code
2647
* @returns Plain JavaScript code with all type syntax removed
27-
* @throws Error if esbuild fails (e.g., syntax error) or wrapper extraction fails
48+
* @throws Error if sucrase fails (e.g., syntax error) or wrapper extraction fails
2849
*/
50+
// sucrase's transform is synchronous, but we keep the published Promise-returning
51+
// signature so existing `await stripTypeScript(...)` callers (and a custom async
52+
// `transpile` hook) stay source-compatible across this swap.
53+
// eslint-disable-next-line @typescript-eslint/require-await
2954
export async function stripTypeScript(code: string): Promise<string> {
30-
// Wrap the code in an async function to allow top-level return/await
31-
// This is necessary because esbuild's ESM format doesn't allow top-level returns
55+
// Wrap the code in an async function to allow top-level return/await.
56+
// This is necessary because top-level `return` is invalid outside a function.
3257
const wrappedCode = `async function ${WRAPPER_START}() {\n${code}\n}; ${WRAPPER_END}`
3358

34-
const result = await transform(wrappedCode, {
35-
loader: 'ts',
36-
// Don't minify - keep the code readable for debugging
37-
minify: false,
38-
// Don't use keepNames as it adds __name() helper calls that aren't available in the sandbox
39-
keepNames: false,
40-
// Target modern JavaScript (ES2022 has top-level await)
41-
target: 'es2022',
59+
const result = transform(wrappedCode, {
60+
// Only strip/lower TypeScript-specific syntax...
61+
transforms: ['typescript'],
62+
// ...and leave modern ECMAScript syntax untouched for the sandbox engines.
63+
disableESTransforms: true,
4264
})
4365

4466
// Extract the code from inside the wrapper function

packages/ai-code-mode/src/types.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,43 @@ export interface CodeModeToolConfig {
195195
* ```
196196
*/
197197
getSkillBindings?: () => Promise<Record<string, ToolBinding>>
198+
199+
/**
200+
* Optional escape hatch to swap out the TypeScript-stripping step.
201+
*
202+
* Receives the raw model-generated code and must return runnable JavaScript
203+
* with all TypeScript syntax removed. Defaults to the built-in
204+
* {@link stripTypeScript}, which uses sucrase and is safe to bundle for
205+
* browsers and edge runtimes (Cloudflare Workers/Pages etc.).
206+
*
207+
* Provide your own only to trade the edge-safe default for a faster
208+
* Node-only transpiler. A custom transpiler MUST tolerate top-level `return`
209+
* and `await` in its input (the default wraps the code in an async function
210+
* internally to allow this).
211+
*
212+
* NOTE: This only affects `createCodeModeTool`. The skills helpers
213+
* (`skillsToTools`, `codeModeWithSkills` in `@tanstack/ai-code-mode-skills`)
214+
* call the exported `stripTypeScript` directly, so they ignore this hook — but
215+
* they still get the edge-safe sucrase default, so #487 is fixed for them too;
216+
* they just can't be pointed at a different transpiler.
217+
*
218+
* @example
219+
* ```typescript
220+
* // Node-only fast path using esbuild (NOT edge-safe — Node only). esbuild
221+
* // rejects top-level `return`, so reuse the same async-function wrapper the
222+
* // default uses, then slice the body back out. `keepNames: false` stops
223+
* // esbuild injecting `__name()` helpers the sandbox can't resolve.
224+
* import { transformSync } from 'esbuild'
225+
* transpile: (code) => {
226+
* const out = transformSync(`async function _w(){\n${code}\n}`, {
227+
* loader: 'ts',
228+
* keepNames: false,
229+
* }).code
230+
* return out.slice(out.indexOf('{') + 1, out.lastIndexOf('}'))
231+
* }
232+
* ```
233+
*/
234+
transpile?: (code: string) => string | Promise<string>
198235
}
199236

200237
/**

packages/ai-code-mode/tests/create-code-mode-tool.test.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,14 +140,68 @@ describe('createCodeModeTool', () => {
140140
tools: [createMockTool('fetchWeather')],
141141
})
142142

143-
// Invalid syntax that esbuild will reject — stripTypeScript now throws
143+
// Invalid syntax the transpiler will reject — stripTypeScript now throws
144144
const result = await tool.execute!({
145145
typescriptCode: 'const x: = invalid{{{syntax',
146146
})
147147
expect(result.success).toBe(false)
148148
expect(result.error?.name).toBe('TypeScriptError')
149149
})
150150

151+
it('uses a custom transpile hook instead of the default', async () => {
152+
const { driver, mockContext } = createMockDriver()
153+
const transpile = vi.fn((code: string) => `/* custom */ ${code}`)
154+
155+
const tool = createCodeModeTool({
156+
driver,
157+
tools: [createMockTool('fetchWeather')],
158+
transpile,
159+
})
160+
161+
await tool.execute!({ typescriptCode: 'return 1' })
162+
163+
expect(transpile).toHaveBeenCalledWith('return 1')
164+
const executed = vi.mocked(mockContext.execute).mock.calls[0]?.[0]
165+
expect(executed).toBe('/* custom */ return 1')
166+
})
167+
168+
it('awaits an async transpile hook', async () => {
169+
const { driver, mockContext } = createMockDriver()
170+
const transpile = vi.fn(async (code: string) =>
171+
Promise.resolve(`async:${code}`),
172+
)
173+
174+
const tool = createCodeModeTool({
175+
driver,
176+
tools: [createMockTool('fetchWeather')],
177+
transpile,
178+
})
179+
180+
await tool.execute!({ typescriptCode: 'return 1' })
181+
182+
const executed = vi.mocked(mockContext.execute).mock.calls[0]?.[0]
183+
expect(executed).toBe('async:return 1')
184+
})
185+
186+
it('surfaces a custom transpile error as a TypeScriptError', async () => {
187+
const { driver } = createMockDriver()
188+
const transpile = vi.fn(() => {
189+
throw new Error('custom transpile failed')
190+
})
191+
192+
const tool = createCodeModeTool({
193+
driver,
194+
tools: [createMockTool('fetchWeather')],
195+
transpile,
196+
})
197+
198+
const result = await tool.execute!({ typescriptCode: 'return 1' })
199+
expect(result.success).toBe(false)
200+
expect(result.error?.name).toBe('TypeScriptError')
201+
expect(result.error?.message).toBe('custom transpile failed')
202+
expect(driver.createContext).not.toHaveBeenCalled()
203+
})
204+
151205
it('emits code_mode:execution_started event', async () => {
152206
const { driver } = createMockDriver()
153207
const emitCustomEvent = vi.fn<ToolExecutionContext['emitCustomEvent']>()
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { readFileSync, readdirSync } from 'node:fs'
2+
import { dirname, join, resolve } from 'node:path'
3+
import { fileURLToPath } from 'node:url'
4+
import { describe, expect, it } from 'vitest'
5+
6+
/**
7+
* Edge-safety guard for issue #487.
8+
*
9+
* `@tanstack/ai-code-mode` must bundle cleanly for browsers and edge runtimes
10+
* (Cloudflare Workers/Pages etc.). That means the source must not import
11+
* esbuild (a Node-native binary that also pulls in `require("pnpapi")`) or any
12+
* Node-only built-in module. This is a fast static guard — the full
13+
* browser/Workers bundle smoke test lives outside the unit suite.
14+
*/
15+
16+
const here = dirname(fileURLToPath(import.meta.url))
17+
const pkgRoot = resolve(here, '..')
18+
const srcDir = join(pkgRoot, 'src')
19+
20+
// Modules that break edge/browser bundling if imported from source.
21+
const FORBIDDEN = [
22+
'esbuild',
23+
'fs',
24+
'path',
25+
'os',
26+
'child_process',
27+
'worker_threads',
28+
'module',
29+
'vm',
30+
'crypto',
31+
]
32+
33+
// One matcher per forbidden module, compiled once. Matches the real import
34+
// forms — `from 'mod'`, `import('mod')`, `require('mod')` — tolerating the
35+
// `node:` prefix and a `/subpath` (so `node:fs/promises` is still caught).
36+
// `mod` is escaped before interpolation: the current FORBIDDEN entries have no
37+
// regex metacharacters (so this is a no-op today), but it keeps the pattern
38+
// correct if a future entry contains one and silences a static-analysis warning.
39+
const escapeRegex = (s: string): string =>
40+
s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
41+
const FORBIDDEN_PATTERNS = FORBIDDEN.map((mod) => ({
42+
mod,
43+
pattern: new RegExp(
44+
`(?:from|import|require)\\s*\\(?\\s*['"](?:node:)?${escapeRegex(mod)}(?:/[^'"]*)?['"]`,
45+
),
46+
}))
47+
48+
function collectTsFiles(dir: string): Array<string> {
49+
const out: Array<string> = []
50+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
51+
const full = join(dir, entry.name)
52+
if (entry.isDirectory()) out.push(...collectTsFiles(full))
53+
else if (entry.name.endsWith('.ts')) out.push(full)
54+
}
55+
return out
56+
}
57+
58+
// Remove comments while PRESERVING string/template literals, so a JSDoc example
59+
// that mentions esbuild (the documented opt-in `transpile` adapter) doesn't trip
60+
// the scan, and — equally important — a `//` or `/* */` sequence inside a string
61+
// literal can't swallow a real import on the same line. The leading
62+
// string-literal alternative is matched first so its contents are consumed (and
63+
// kept) before the comment alternatives can see them.
64+
function stripComments(text: string): string {
65+
return text.replace(
66+
/(["'`])(?:\\.|(?!\1)[^\\])*\1|\/\*[\s\S]*?\*\/|\/\/[^\n]*/g,
67+
(match, quote: string | undefined) => (quote ? match : ''),
68+
)
69+
}
70+
71+
describe('edge-safety (#487)', () => {
72+
it('does not depend on esbuild in any install-facing bucket', () => {
73+
const pkg = JSON.parse(
74+
readFileSync(join(pkgRoot, 'package.json'), 'utf8'),
75+
) as {
76+
dependencies?: Record<string, string>
77+
peerDependencies?: Record<string, string>
78+
optionalDependencies?: Record<string, string>
79+
}
80+
// Buckets that pull a package into a consumer's install (and thus its
81+
// bundle). devDependencies don't ship, so they're intentionally not checked.
82+
for (const bucket of [
83+
pkg.dependencies,
84+
pkg.peerDependencies,
85+
pkg.optionalDependencies,
86+
]) {
87+
expect(bucket ?? {}).not.toHaveProperty('esbuild')
88+
}
89+
})
90+
91+
it('no source file imports esbuild or a Node-only built-in', () => {
92+
const files = collectTsFiles(srcDir)
93+
expect(files.length).toBeGreaterThan(0)
94+
95+
const offenders: Array<string> = []
96+
for (const file of files) {
97+
const text = stripComments(readFileSync(file, 'utf8'))
98+
for (const { mod, pattern } of FORBIDDEN_PATTERNS) {
99+
if (pattern.test(text)) {
100+
offenders.push(`${file.replace(pkgRoot, '.')} -> ${mod}`)
101+
}
102+
}
103+
}
104+
105+
expect(offenders).toEqual([])
106+
})
107+
})
108+
109+
describe('edge-safety guard self-test', () => {
110+
// Mirror the scan against crafted inputs to lock in the false-positive /
111+
// false-negative fixes the guard depends on (a comment-only reference must be
112+
// ignored; a real import must survive even when a comment-like string shares
113+
// its line; subpath imports must still match).
114+
const hits = (src: string): Array<string> => {
115+
const text = stripComments(src)
116+
return FORBIDDEN_PATTERNS.filter(({ pattern }) => pattern.test(text)).map(
117+
({ mod }) => mod,
118+
)
119+
}
120+
121+
it('flags a real Node-only import', () => {
122+
expect(hits(`import { readFile } from 'fs'`)).toContain('fs')
123+
expect(hits(`const x = require('esbuild')`)).toContain('esbuild')
124+
})
125+
126+
it('ignores a reference that lives only in a comment', () => {
127+
expect(hits(`/** import { transformSync } from 'esbuild' */`)).toEqual([])
128+
expect(hits(`// import fs from 'fs'`)).toEqual([])
129+
})
130+
131+
it('still flags a real import sharing a line with a comment-like string', () => {
132+
expect(hits(`const s = "a//b"; import fs from 'fs'`)).toContain('fs')
133+
expect(
134+
hits(`const a = "/*"; import { x } from 'path'; const b = "*/"`),
135+
).toContain('path')
136+
})
137+
138+
it('flags Node-only subpath imports', () => {
139+
expect(hits(`import { readFile } from 'node:fs/promises'`)).toContain('fs')
140+
expect(hits(`import x from 'path/posix'`)).toContain('path')
141+
})
142+
})

0 commit comments

Comments
 (0)