Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit 18acb96

Browse files
authored
Fix custom tool transpilation and bundling (#10242)
1 parent d54b37e commit 18acb96

8 files changed

Lines changed: 437 additions & 19 deletions

File tree

packages/build/src/esbuild.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,58 @@ export function copyWasms(srcDir: string, distDir: string): void {
158158
})
159159

160160
console.log(`[copyWasms] Copied ${wasmFiles.length} tree-sitter language wasms to ${distDir}`)
161+
162+
// Copy esbuild-wasm files for custom tool transpilation (cross-platform).
163+
copyEsbuildWasmFiles(nodeModulesDir, distDir)
164+
}
165+
166+
/**
167+
* Copy esbuild-wasm files to the dist/bin directory.
168+
*
169+
* This function copies the esbuild-wasm CLI and WASM binary, which provides
170+
* a cross-platform esbuild implementation that works on all platforms.
171+
*
172+
* Files copied:
173+
* - bin/esbuild (Node.js CLI script)
174+
* - esbuild.wasm (WASM binary)
175+
* - wasm_exec_node.js (Go WASM runtime for Node.js)
176+
* - wasm_exec.js (Go WASM runtime dependency)
177+
*/
178+
function copyEsbuildWasmFiles(nodeModulesDir: string, distDir: string): void {
179+
const esbuildWasmDir = path.join(nodeModulesDir, "esbuild-wasm")
180+
181+
if (!fs.existsSync(esbuildWasmDir)) {
182+
throw new Error(`Directory does not exist: ${esbuildWasmDir}`)
183+
}
184+
185+
// Create bin directory in dist.
186+
const binDir = path.join(distDir, "bin")
187+
fs.mkdirSync(binDir, { recursive: true })
188+
189+
// Files to copy - the esbuild CLI script expects wasm_exec_node.js and esbuild.wasm
190+
// to be one directory level up from the bin directory (i.e., in distDir directly).
191+
// wasm_exec_node.js requires wasm_exec.js, so we need to copy that too.
192+
const filesToCopy = [
193+
{ src: path.join(esbuildWasmDir, "bin", "esbuild"), dest: path.join(binDir, "esbuild") },
194+
{ src: path.join(esbuildWasmDir, "esbuild.wasm"), dest: path.join(distDir, "esbuild.wasm") },
195+
{ src: path.join(esbuildWasmDir, "wasm_exec_node.js"), dest: path.join(distDir, "wasm_exec_node.js") },
196+
{ src: path.join(esbuildWasmDir, "wasm_exec.js"), dest: path.join(distDir, "wasm_exec.js") },
197+
]
198+
199+
for (const { src, dest } of filesToCopy) {
200+
fs.copyFileSync(src, dest)
201+
202+
// Make CLI executable.
203+
if (src.endsWith("esbuild")) {
204+
try {
205+
fs.chmodSync(dest, 0o755)
206+
} catch {
207+
// Ignore chmod errors on Windows.
208+
}
209+
}
210+
}
211+
212+
console.log(`[copyWasms] Copied ${filesToCopy.length} esbuild-wasm files to ${distDir}`)
161213
}
162214

163215
export function copyLocales(srcDir: string, distDir: string): void {

packages/core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"dependencies": {
1414
"@roo-code/types": "workspace:^",
1515
"esbuild": "^0.25.0",
16+
"execa": "^9.5.2",
1617
"openai": "^5.12.2",
1718
"zod": "^3.25.61"
1819
},
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import fs from "fs"
2+
import os from "os"
3+
import path from "path"
4+
5+
import { getEsbuildScriptPath, runEsbuild } from "../esbuild-runner.js"
6+
7+
describe("getEsbuildScriptPath", () => {
8+
it("should find esbuild-wasm script in node_modules in development", () => {
9+
const scriptPath = getEsbuildScriptPath()
10+
11+
// Should find the script.
12+
expect(typeof scriptPath).toBe("string")
13+
expect(scriptPath.length).toBeGreaterThan(0)
14+
15+
// The script should exist.
16+
expect(fs.existsSync(scriptPath)).toBe(true)
17+
18+
// Should be the esbuild script (not a binary).
19+
expect(scriptPath).toMatch(/esbuild$/)
20+
})
21+
22+
it("should prefer production path when extensionPath is provided and script exists", () => {
23+
// Create a temporary directory with a fake script.
24+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "esbuild-runner-test-"))
25+
const binDir = path.join(tempDir, "dist", "bin")
26+
fs.mkdirSync(binDir, { recursive: true })
27+
28+
const fakeScriptPath = path.join(binDir, "esbuild")
29+
fs.writeFileSync(fakeScriptPath, "#!/usr/bin/env node\nconsole.log('fake esbuild')")
30+
31+
try {
32+
const result = getEsbuildScriptPath(tempDir)
33+
expect(result).toBe(fakeScriptPath)
34+
} finally {
35+
fs.rmSync(tempDir, { recursive: true, force: true })
36+
}
37+
})
38+
39+
it("should fall back to node_modules when production script does not exist", () => {
40+
// Pass a non-existent extension path.
41+
const result = getEsbuildScriptPath("/nonexistent/extension/path")
42+
43+
// Should fall back to development path.
44+
expect(typeof result).toBe("string")
45+
expect(result.length).toBeGreaterThan(0)
46+
expect(fs.existsSync(result)).toBe(true)
47+
})
48+
})
49+
50+
describe("runEsbuild", () => {
51+
let tempDir: string
52+
53+
beforeEach(() => {
54+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "esbuild-runner-test-"))
55+
})
56+
57+
afterEach(() => {
58+
fs.rmSync(tempDir, { recursive: true, force: true })
59+
})
60+
61+
it("should compile a TypeScript file to ESM", async () => {
62+
// Create a simple TypeScript file.
63+
const inputFile = path.join(tempDir, "input.ts")
64+
const outputFile = path.join(tempDir, "output.mjs")
65+
66+
fs.writeFileSync(
67+
inputFile,
68+
`
69+
export const greeting = "Hello, World!"
70+
export function add(a: number, b: number): number {
71+
return a + b
72+
}
73+
`,
74+
)
75+
76+
await runEsbuild({
77+
entryPoint: inputFile,
78+
outfile: outputFile,
79+
format: "esm",
80+
platform: "node",
81+
target: "node18",
82+
bundle: true,
83+
})
84+
85+
// Verify output file exists.
86+
expect(fs.existsSync(outputFile)).toBe(true)
87+
88+
// Verify output content is valid JavaScript.
89+
const outputContent = fs.readFileSync(outputFile, "utf-8")
90+
expect(outputContent).toContain("Hello, World!")
91+
expect(outputContent).toContain("add")
92+
}, 30000)
93+
94+
it("should generate inline source maps when specified", async () => {
95+
const inputFile = path.join(tempDir, "input.ts")
96+
const outputFile = path.join(tempDir, "output.mjs")
97+
98+
fs.writeFileSync(inputFile, `export const value = 42`)
99+
100+
await runEsbuild({ entryPoint: inputFile, outfile: outputFile, format: "esm", sourcemap: "inline" })
101+
102+
const outputContent = fs.readFileSync(outputFile, "utf-8")
103+
expect(outputContent).toContain("sourceMappingURL=data:")
104+
}, 30000)
105+
106+
it("should throw an error for invalid TypeScript", async () => {
107+
const inputFile = path.join(tempDir, "invalid.ts")
108+
const outputFile = path.join(tempDir, "output.mjs")
109+
110+
// Write syntactically invalid TypeScript.
111+
fs.writeFileSync(inputFile, `export const value = {{{ invalid syntax`)
112+
113+
await expect(runEsbuild({ entryPoint: inputFile, outfile: outputFile, format: "esm" })).rejects.toThrow()
114+
}, 30000)
115+
116+
it("should throw an error for non-existent file", async () => {
117+
const nonExistentFile = path.join(tempDir, "does-not-exist.ts")
118+
const outputFile = path.join(tempDir, "output.mjs")
119+
120+
await expect(runEsbuild({ entryPoint: nonExistentFile, outfile: outputFile, format: "esm" })).rejects.toThrow()
121+
}, 30000)
122+
123+
it("should bundle dependencies when bundle option is true", async () => {
124+
// Create two files where one imports the other.
125+
const libFile = path.join(tempDir, "lib.ts")
126+
const mainFile = path.join(tempDir, "main.ts")
127+
const outputFile = path.join(tempDir, "output.mjs")
128+
129+
fs.writeFileSync(libFile, `export const PI = 3.14159`)
130+
fs.writeFileSync(
131+
mainFile,
132+
`
133+
import { PI } from "./lib.js"
134+
export const circumference = (r: number) => 2 * PI * r
135+
`,
136+
)
137+
138+
await runEsbuild({ entryPoint: mainFile, outfile: outputFile, format: "esm", bundle: true })
139+
140+
const outputContent = fs.readFileSync(outputFile, "utf-8")
141+
// The PI constant should be bundled inline.
142+
expect(outputContent).toContain("3.14159")
143+
}, 30000)
144+
145+
it("should respect platform option", async () => {
146+
const inputFile = path.join(tempDir, "input.ts")
147+
const outputFile = path.join(tempDir, "output.mjs")
148+
149+
fs.writeFileSync(inputFile, `export const value = process.env.NODE_ENV`)
150+
151+
await runEsbuild({ entryPoint: inputFile, outfile: outputFile, format: "esm", platform: "node" })
152+
153+
// File should be created successfully.
154+
expect(fs.existsSync(outputFile)).toBe(true)
155+
}, 30000)
156+
})

packages/core/src/custom-tools/custom-tool-registry.ts

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,10 @@ import path from "path"
1313
import { createHash } from "crypto"
1414
import os from "os"
1515

16-
import { build } from "esbuild"
17-
1816
import type { CustomToolDefinition, SerializedCustomToolDefinition, CustomToolParametersSchema } from "@roo-code/types"
1917

2018
import { serializeCustomTool } from "./serialize.js"
19+
import { runEsbuild } from "./esbuild-runner.js"
2120

2221
export interface LoadResult {
2322
loaded: string[]
@@ -29,19 +28,23 @@ export interface RegistryOptions {
2928
cacheDir?: string
3029
/** Additional paths for resolving node modules (useful for tools outside node_modules). */
3130
nodePaths?: string[]
31+
/** Path to the extension root directory (for finding bundled esbuild binary in production). */
32+
extensionPath?: string
3233
}
3334

3435
export class CustomToolRegistry {
3536
private tools = new Map<string, CustomToolDefinition>()
3637
private tsCache = new Map<string, string>()
3738
private cacheDir: string
3839
private nodePaths: string[]
40+
private extensionPath?: string
3941
private lastLoaded: Map<string, number> = new Map()
4042

4143
constructor(options?: RegistryOptions) {
4244
this.cacheDir = options?.cacheDir ?? path.join(os.tmpdir(), "dynamic-tools-cache")
4345
// Default to current working directory's node_modules.
4446
this.nodePaths = options?.nodePaths ?? [path.join(process.cwd(), "node_modules")]
47+
this.extensionPath = options?.extensionPath
4548
}
4649

4750
/**
@@ -180,6 +183,21 @@ export class CustomToolRegistry {
180183
this.tools.clear()
181184
}
182185

186+
/**
187+
* Set the extension path for finding bundled esbuild binary.
188+
* This should be called with context.extensionPath when the extension activates.
189+
*/
190+
setExtensionPath(extensionPath: string): void {
191+
this.extensionPath = extensionPath
192+
}
193+
194+
/**
195+
* Get the current extension path.
196+
*/
197+
getExtensionPath(): string | undefined {
198+
return this.extensionPath
199+
}
200+
183201
/**
184202
* Clear the TypeScript compilation cache (both in-memory and on disk).
185203
*/
@@ -229,19 +247,21 @@ export class CustomToolRegistry {
229247
const hash = createHash("sha256").update(cacheKey).digest("hex").slice(0, 16)
230248
const tempFile = path.join(this.cacheDir, `${hash}.mjs`)
231249

232-
// Bundle the TypeScript file with dependencies.
233-
await build({
234-
entryPoints: [absolutePath],
235-
bundle: true,
236-
format: "esm",
237-
platform: "node",
238-
target: "node18",
239-
outfile: tempFile,
240-
sourcemap: "inline",
241-
packages: "bundle",
242-
// Include node_modules paths for module resolution.
243-
nodePaths: this.nodePaths,
244-
})
250+
// Bundle the TypeScript file with dependencies using esbuild CLI.
251+
await runEsbuild(
252+
{
253+
entryPoint: absolutePath,
254+
outfile: tempFile,
255+
format: "esm",
256+
platform: "node",
257+
target: "node18",
258+
bundle: true,
259+
sourcemap: "inline",
260+
packages: "bundle",
261+
nodePaths: this.nodePaths,
262+
},
263+
this.extensionPath,
264+
)
245265

246266
this.tsCache.set(cacheKey, tempFile)
247267
return import(`file://${tempFile}`)

0 commit comments

Comments
 (0)