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
52 changes: 52 additions & 0 deletions packages/build/src/esbuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,58 @@ export function copyWasms(srcDir: string, distDir: string): void {
})

console.log(`[copyWasms] Copied ${wasmFiles.length} tree-sitter language wasms to ${distDir}`)

// Copy esbuild-wasm files for custom tool transpilation (cross-platform).
copyEsbuildWasmFiles(nodeModulesDir, distDir)
}

/**
* Copy esbuild-wasm files to the dist/bin directory.
*
* This function copies the esbuild-wasm CLI and WASM binary, which provides
* a cross-platform esbuild implementation that works on all platforms.
*
* Files copied:
* - bin/esbuild (Node.js CLI script)
* - esbuild.wasm (WASM binary)
* - wasm_exec_node.js (Go WASM runtime for Node.js)
* - wasm_exec.js (Go WASM runtime dependency)
*/
function copyEsbuildWasmFiles(nodeModulesDir: string, distDir: string): void {
const esbuildWasmDir = path.join(nodeModulesDir, "esbuild-wasm")

if (!fs.existsSync(esbuildWasmDir)) {
throw new Error(`Directory does not exist: ${esbuildWasmDir}`)
}

// Create bin directory in dist.
const binDir = path.join(distDir, "bin")
fs.mkdirSync(binDir, { recursive: true })

// Files to copy - the esbuild CLI script expects wasm_exec_node.js and esbuild.wasm
// to be one directory level up from the bin directory (i.e., in distDir directly).
// wasm_exec_node.js requires wasm_exec.js, so we need to copy that too.
const filesToCopy = [
{ src: path.join(esbuildWasmDir, "bin", "esbuild"), dest: path.join(binDir, "esbuild") },
{ src: path.join(esbuildWasmDir, "esbuild.wasm"), dest: path.join(distDir, "esbuild.wasm") },
{ src: path.join(esbuildWasmDir, "wasm_exec_node.js"), dest: path.join(distDir, "wasm_exec_node.js") },
{ src: path.join(esbuildWasmDir, "wasm_exec.js"), dest: path.join(distDir, "wasm_exec.js") },
]

for (const { src, dest } of filesToCopy) {
fs.copyFileSync(src, dest)

// Make CLI executable.
if (src.endsWith("esbuild")) {
try {
fs.chmodSync(dest, 0o755)
} catch {
// Ignore chmod errors on Windows.
}
}
}

console.log(`[copyWasms] Copied ${filesToCopy.length} esbuild-wasm files to ${distDir}`)
}

export function copyLocales(srcDir: string, distDir: string): void {
Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"dependencies": {
"@roo-code/types": "workspace:^",
"esbuild": "^0.25.0",
"execa": "^9.5.2",
"openai": "^5.12.2",
"zod": "^3.25.61"
},
Expand Down
156 changes: 156 additions & 0 deletions packages/core/src/custom-tools/__tests__/esbuild-runner.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import fs from "fs"
import os from "os"
import path from "path"

import { getEsbuildScriptPath, runEsbuild } from "../esbuild-runner.js"

describe("getEsbuildScriptPath", () => {
it("should find esbuild-wasm script in node_modules in development", () => {
const scriptPath = getEsbuildScriptPath()

// Should find the script.
expect(typeof scriptPath).toBe("string")
expect(scriptPath.length).toBeGreaterThan(0)

// The script should exist.
expect(fs.existsSync(scriptPath)).toBe(true)

// Should be the esbuild script (not a binary).
expect(scriptPath).toMatch(/esbuild$/)
})

it("should prefer production path when extensionPath is provided and script exists", () => {
// Create a temporary directory with a fake script.
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "esbuild-runner-test-"))
const binDir = path.join(tempDir, "dist", "bin")
fs.mkdirSync(binDir, { recursive: true })

const fakeScriptPath = path.join(binDir, "esbuild")
fs.writeFileSync(fakeScriptPath, "#!/usr/bin/env node\nconsole.log('fake esbuild')")

try {
const result = getEsbuildScriptPath(tempDir)
expect(result).toBe(fakeScriptPath)
} finally {
fs.rmSync(tempDir, { recursive: true, force: true })
}
})

it("should fall back to node_modules when production script does not exist", () => {
// Pass a non-existent extension path.
const result = getEsbuildScriptPath("/nonexistent/extension/path")

// Should fall back to development path.
expect(typeof result).toBe("string")
expect(result.length).toBeGreaterThan(0)
expect(fs.existsSync(result)).toBe(true)
})
})

describe("runEsbuild", () => {
let tempDir: string

beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "esbuild-runner-test-"))
})

afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true })
})

it("should compile a TypeScript file to ESM", async () => {
// Create a simple TypeScript file.
const inputFile = path.join(tempDir, "input.ts")
const outputFile = path.join(tempDir, "output.mjs")

fs.writeFileSync(
inputFile,
`
export const greeting = "Hello, World!"
export function add(a: number, b: number): number {
return a + b
}
`,
)

await runEsbuild({
entryPoint: inputFile,
outfile: outputFile,
format: "esm",
platform: "node",
target: "node18",
bundle: true,
})

// Verify output file exists.
expect(fs.existsSync(outputFile)).toBe(true)

// Verify output content is valid JavaScript.
const outputContent = fs.readFileSync(outputFile, "utf-8")
expect(outputContent).toContain("Hello, World!")
expect(outputContent).toContain("add")
}, 30000)

it("should generate inline source maps when specified", async () => {
const inputFile = path.join(tempDir, "input.ts")
const outputFile = path.join(tempDir, "output.mjs")

fs.writeFileSync(inputFile, `export const value = 42`)

await runEsbuild({ entryPoint: inputFile, outfile: outputFile, format: "esm", sourcemap: "inline" })

const outputContent = fs.readFileSync(outputFile, "utf-8")
expect(outputContent).toContain("sourceMappingURL=data:")
}, 30000)

it("should throw an error for invalid TypeScript", async () => {
const inputFile = path.join(tempDir, "invalid.ts")
const outputFile = path.join(tempDir, "output.mjs")

// Write syntactically invalid TypeScript.
fs.writeFileSync(inputFile, `export const value = {{{ invalid syntax`)

await expect(runEsbuild({ entryPoint: inputFile, outfile: outputFile, format: "esm" })).rejects.toThrow()
}, 30000)

it("should throw an error for non-existent file", async () => {
const nonExistentFile = path.join(tempDir, "does-not-exist.ts")
const outputFile = path.join(tempDir, "output.mjs")

await expect(runEsbuild({ entryPoint: nonExistentFile, outfile: outputFile, format: "esm" })).rejects.toThrow()
}, 30000)

it("should bundle dependencies when bundle option is true", async () => {
// Create two files where one imports the other.
const libFile = path.join(tempDir, "lib.ts")
const mainFile = path.join(tempDir, "main.ts")
const outputFile = path.join(tempDir, "output.mjs")

fs.writeFileSync(libFile, `export const PI = 3.14159`)
fs.writeFileSync(
mainFile,
`
import { PI } from "./lib.js"
export const circumference = (r: number) => 2 * PI * r
`,
)

await runEsbuild({ entryPoint: mainFile, outfile: outputFile, format: "esm", bundle: true })

const outputContent = fs.readFileSync(outputFile, "utf-8")
// The PI constant should be bundled inline.
expect(outputContent).toContain("3.14159")
}, 30000)

it("should respect platform option", async () => {
const inputFile = path.join(tempDir, "input.ts")
const outputFile = path.join(tempDir, "output.mjs")

fs.writeFileSync(inputFile, `export const value = process.env.NODE_ENV`)

await runEsbuild({ entryPoint: inputFile, outfile: outputFile, format: "esm", platform: "node" })

// File should be created successfully.
expect(fs.existsSync(outputFile)).toBe(true)
}, 30000)
})
50 changes: 35 additions & 15 deletions packages/core/src/custom-tools/custom-tool-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,10 @@ import path from "path"
import { createHash } from "crypto"
import os from "os"

import { build } from "esbuild"

import type { CustomToolDefinition, SerializedCustomToolDefinition, CustomToolParametersSchema } from "@roo-code/types"

import { serializeCustomTool } from "./serialize.js"
import { runEsbuild } from "./esbuild-runner.js"

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

export class CustomToolRegistry {
private tools = new Map<string, CustomToolDefinition>()
private tsCache = new Map<string, string>()
private cacheDir: string
private nodePaths: string[]
private extensionPath?: string
private lastLoaded: Map<string, number> = new Map()

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

/**
Expand Down Expand Up @@ -180,6 +183,21 @@ export class CustomToolRegistry {
this.tools.clear()
}

/**
* Set the extension path for finding bundled esbuild binary.
* This should be called with context.extensionPath when the extension activates.
*/
setExtensionPath(extensionPath: string): void {
this.extensionPath = extensionPath
}

/**
* Get the current extension path.
*/
getExtensionPath(): string | undefined {
return this.extensionPath
}

/**
* Clear the TypeScript compilation cache (both in-memory and on disk).
*/
Expand Down Expand Up @@ -229,19 +247,21 @@ export class CustomToolRegistry {
const hash = createHash("sha256").update(cacheKey).digest("hex").slice(0, 16)
const tempFile = path.join(this.cacheDir, `${hash}.mjs`)

// Bundle the TypeScript file with dependencies.
await build({
entryPoints: [absolutePath],
bundle: true,
format: "esm",
platform: "node",
target: "node18",
outfile: tempFile,
sourcemap: "inline",
packages: "bundle",
// Include node_modules paths for module resolution.
nodePaths: this.nodePaths,
})
// Bundle the TypeScript file with dependencies using esbuild CLI.
await runEsbuild(
{
entryPoint: absolutePath,
outfile: tempFile,
format: "esm",
platform: "node",
target: "node18",
bundle: true,
sourcemap: "inline",
packages: "bundle",
nodePaths: this.nodePaths,
},
this.extensionPath,
)

this.tsCache.set(cacheKey, tempFile)
return import(`file://${tempFile}`)
Expand Down
Loading