Roo-Code/packages/build/src/esbuild.ts
2025-12-21 12:43:06 -08:00

338 lines
11 KiB
TypeScript

import * as fs from "fs"
import * as path from "path"
import { execSync } from "child_process"
import { ViewsContainer, Views, Menus, Configuration, Keybindings, contributesSchema } from "./types.js"
function copyDir(srcDir: string, dstDir: string, count: number): number {
const entries = fs.readdirSync(srcDir, { withFileTypes: true })
for (const entry of entries) {
const srcPath = path.join(srcDir, entry.name)
const dstPath = path.join(dstDir, entry.name)
if (entry.isDirectory()) {
fs.mkdirSync(dstPath, { recursive: true })
count = copyDir(srcPath, dstPath, count)
} else {
count = count + 1
fs.copyFileSync(srcPath, dstPath)
}
}
return count
}
function rmDir(dirPath: string, maxRetries: number = 5): void {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
fs.rmSync(dirPath, { recursive: true, force: true })
return
} catch (error) {
const isLastAttempt = attempt === maxRetries
const isRetryableError =
error instanceof Error &&
"code" in error &&
(error.code === "ENOTEMPTY" ||
error.code === "EBUSY" ||
error.code === "EPERM" ||
error.code === "EACCES")
if (isLastAttempt) {
// On the last attempt, try alternative cleanup methods.
try {
console.warn(`[rmDir] Final attempt using alternative cleanup for ${dirPath}`)
// Try to clear readonly flags on Windows.
if (process.platform === "win32") {
try {
execSync(`attrib -R "${dirPath}\\*.*" /S /D`, { stdio: "ignore" })
} catch {
// Ignore attrib errors.
}
}
fs.rmSync(dirPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 })
return
} catch (finalError) {
console.error(`[rmDir] Failed to remove ${dirPath} after ${maxRetries} attempts:`, finalError)
throw finalError
}
}
if (!isRetryableError) {
throw error // Re-throw if it's not a retryable error.
}
// Wait with exponential backoff before retrying, with longer delays for Windows.
const baseDelay = process.platform === "win32" ? 200 : 100
const delay = Math.min(baseDelay * Math.pow(2, attempt - 1), 2000) // Cap at 2s
console.warn(`[rmDir] Attempt ${attempt} failed for ${dirPath}, retrying in ${delay}ms...`)
// Synchronous sleep for simplicity in build scripts.
const start = Date.now()
while (Date.now() - start < delay) {
/* Busy wait */
}
}
}
}
type CopyPathOptions = {
optional?: boolean
}
export function copyPaths(copyPaths: [string, string, CopyPathOptions?][], srcDir: string, dstDir: string) {
copyPaths.forEach(([srcRelPath, dstRelPath, options = {}]) => {
try {
const stats = fs.lstatSync(path.join(srcDir, srcRelPath))
if (stats.isDirectory()) {
if (fs.existsSync(path.join(dstDir, dstRelPath))) {
rmDir(path.join(dstDir, dstRelPath))
}
fs.mkdirSync(path.join(dstDir, dstRelPath), { recursive: true })
const count = copyDir(path.join(srcDir, srcRelPath), path.join(dstDir, dstRelPath), 0)
console.log(`[copyPaths] Copied ${count} files from ${srcRelPath} to ${dstRelPath}`)
} else {
fs.copyFileSync(path.join(srcDir, srcRelPath), path.join(dstDir, dstRelPath))
console.log(`[copyPaths] Copied ${srcRelPath} to ${dstRelPath}`)
}
} catch (error) {
if (options.optional) {
console.warn(`[copyPaths] Optional file not found: ${srcRelPath}`)
} else {
throw error
}
}
})
}
export function copyWasms(srcDir: string, distDir: string): void {
const nodeModulesDir = path.join(srcDir, "node_modules")
fs.mkdirSync(distDir, { recursive: true })
// Tiktoken WASM file.
fs.copyFileSync(
path.join(nodeModulesDir, "tiktoken", "lite", "tiktoken_bg.wasm"),
path.join(distDir, "tiktoken_bg.wasm"),
)
console.log(`[copyWasms] Copied tiktoken WASMs to ${distDir}`)
// Also copy Tiktoken WASMs to the workers directory.
const workersDir = path.join(distDir, "workers")
fs.mkdirSync(workersDir, { recursive: true })
fs.copyFileSync(
path.join(nodeModulesDir, "tiktoken", "lite", "tiktoken_bg.wasm"),
path.join(workersDir, "tiktoken_bg.wasm"),
)
console.log(`[copyWasms] Copied tiktoken WASMs to ${workersDir}`)
// Main tree-sitter WASM file.
fs.copyFileSync(
path.join(nodeModulesDir, "web-tree-sitter", "tree-sitter.wasm"),
path.join(distDir, "tree-sitter.wasm"),
)
console.log(`[copyWasms] Copied tree-sitter.wasm to ${distDir}`)
// Copy language-specific WASM files.
const languageWasmDir = path.join(nodeModulesDir, "tree-sitter-wasms", "out")
if (!fs.existsSync(languageWasmDir)) {
throw new Error(`Directory does not exist: ${languageWasmDir}`)
}
// Dynamically read all WASM files from the directory instead of using a hardcoded list.
const wasmFiles = fs.readdirSync(languageWasmDir).filter((file) => file.endsWith(".wasm"))
wasmFiles.forEach((filename) => {
fs.copyFileSync(path.join(languageWasmDir, filename), path.join(distDir, filename))
})
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 {
const destDir = path.join(distDir, "i18n", "locales")
fs.mkdirSync(destDir, { recursive: true })
const count = copyDir(path.join(srcDir, "i18n", "locales"), destDir, 0)
console.log(`[copyLocales] Copied ${count} locale files to ${destDir}`)
}
export function setupLocaleWatcher(srcDir: string, distDir: string) {
const localesDir = path.join(srcDir, "i18n", "locales")
if (!fs.existsSync(localesDir)) {
console.warn(`Cannot set up watcher: Source locales directory does not exist: ${localesDir}`)
return
}
console.log(`Setting up watcher for locale files in ${localesDir}`)
let debounceTimer: NodeJS.Timeout | null = null
const debouncedCopy = () => {
if (debounceTimer) {
clearTimeout(debounceTimer)
}
// Wait 300ms after last change before copying.
debounceTimer = setTimeout(() => {
console.log("Locale files changed, copying...")
copyLocales(srcDir, distDir)
}, 300)
}
try {
fs.watch(localesDir, { recursive: true }, (_eventType, filename) => {
if (filename && filename.endsWith(".json")) {
console.log(`Locale file ${filename} changed, triggering copy...`)
debouncedCopy()
}
})
console.log("Watcher for locale files is set up")
} catch (error) {
console.error(
`Error setting up watcher for ${localesDir}:`,
error instanceof Error ? error.message : "Unknown error",
)
}
}
export function generatePackageJson({
packageJson: { contributes, ...packageJson },
overrideJson,
substitution,
}: {
packageJson: Record<string, any> // eslint-disable-line @typescript-eslint/no-explicit-any
overrideJson: Record<string, any> // eslint-disable-line @typescript-eslint/no-explicit-any
substitution: [string, string]
}) {
const { viewsContainers, views, commands, menus, submenus, keybindings, configuration } =
contributesSchema.parse(contributes)
const [from, to] = substitution
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result: Record<string, any> = {
...packageJson,
...overrideJson,
contributes: {
viewsContainers: transformArrayRecord<ViewsContainer>(viewsContainers, from, to, ["id"]),
views: transformArrayRecord<Views>(views, from, to, ["id"]),
commands: transformArray(commands, from, to, "command"),
menus: transformArrayRecord<Menus>(menus, from, to, ["command", "submenu", "when"]),
submenus: transformArray(submenus, from, to, "id"),
configuration: {
title: configuration.title,
properties: transformRecord<Configuration["properties"]>(configuration.properties, from, to),
},
},
}
// Only add keybindings if they exist
if (keybindings) {
result.contributes.keybindings = transformArray<Keybindings>(keybindings, from, to, "command")
}
return result
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function transformArrayRecord<T>(obj: Record<string, any[]>, from: string, to: string, props: string[]): T {
return Object.entries(obj).reduce(
(acc, [key, ary]) => ({
...acc,
[key.replaceAll(from, to)]: ary.map((item) => {
const transformedItem = { ...item }
for (const prop of props) {
if (prop in item && typeof item[prop] === "string") {
transformedItem[prop] = item[prop].replaceAll(from, to)
}
}
return transformedItem
}),
}),
{} as T,
)
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function transformArray<T>(arr: any[], from: string, to: string, idProp: string): T[] {
return arr.map(({ [idProp]: id, ...rest }) => ({
[idProp]: id.replaceAll(from, to),
...rest,
}))
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function transformRecord<T>(obj: Record<string, any>, from: string, to: string): T {
return Object.entries(obj).reduce(
(acc, [key, value]) => ({
...acc,
[key.replaceAll(from, to)]: value,
}),
{} as T,
)
}