diff --git a/bun.lock b/bun.lock index 644de37f2e..b69493e38e 100644 --- a/bun.lock +++ b/bun.lock @@ -450,6 +450,7 @@ "@typescript/native-preview": "catalog:", "drizzle-kit": "catalog:", "drizzle-orm": "catalog:", + "rollup": "4.60.1", "typescript": "catalog:", "vscode-languageserver-types": "3.17.5", "why-is-node-running": "3.2.2", diff --git a/packages/opencode/.gitignore b/packages/opencode/.gitignore index 2b20d9c312..882a1a9b34 100644 --- a/packages/opencode/.gitignore +++ b/packages/opencode/.gitignore @@ -1,4 +1,5 @@ research +.rollup-tmp dist dist-* gen diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 7ed33ebe09..7d71cda479 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -69,6 +69,7 @@ "@typescript/native-preview": "catalog:", "drizzle-kit": "catalog:", "drizzle-orm": "catalog:", + "rollup": "4.60.1", "typescript": "catalog:", "vscode-languageserver-types": "3.17.5", "why-is-node-running": "3.2.2", diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 5aa14d52cd..b2d06e66dc 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -5,6 +5,7 @@ import fs from "fs" import path from "path" import { fileURLToPath } from "url" import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin" +import { treeshakePrepass } from "./treeshake-prepass" const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -50,9 +51,23 @@ console.log(`Loaded ${migrations.length} migrations`) const singleFlag = process.argv.includes("--single") const baselineFlag = process.argv.includes("--baseline") const skipInstall = process.argv.includes("--skip-install") +const skipTreeshake = process.argv.includes("--skip-treeshake") const plugin = createSolidTransformPlugin() const skipEmbedWebUi = process.argv.includes("--skip-embed-web-ui") +// Run Rollup tree-shaking pre-pass on the main entrypoint. +// Bun/esbuild can't tree-shake `export * as X` barrels (evanw/esbuild#1420). +// Rollup can — it does AST-level analysis to drop unused exports and their +// transitive imports. Workers are excluded since they're separate bundles. +const rollupTmpDir = path.join(dir, ".rollup-tmp") +let treeshakenEntry: string | undefined +if (!skipTreeshake) { + const entryMap = await treeshakePrepass(["./src/index.ts"], rollupTmpDir) + treeshakenEntry = entryMap.get("index") +} else { + console.log("[treeshake] Skipped (--skip-treeshake)") +} + const createEmbeddedWebUIBundle = async () => { console.log(`Building Web UI to embed in the binary`) const appDir = path.join(import.meta.dirname, "../../app") @@ -213,7 +228,7 @@ for (const item of targets) { }, files: embeddedFileMap ? { "opencode-web-ui.gen.ts": embeddedFileMap } : {}, entrypoints: [ - "./src/index.ts", + treeshakenEntry ?? "./src/index.ts", parserWorker, workerPath, rgPath, @@ -270,4 +285,9 @@ if (Script.release) { await $`gh release upload v${Script.version} ./dist/*.zip ./dist/*.tar.gz --clobber --repo ${process.env.GH_REPO}` } +// Clean up Rollup temp directory +if (fs.existsSync(rollupTmpDir)) { + fs.rmSync(rollupTmpDir, { recursive: true }) +} + export { binaries } diff --git a/packages/opencode/script/treeshake-prepass.ts b/packages/opencode/script/treeshake-prepass.ts new file mode 100644 index 0000000000..c5c255f259 --- /dev/null +++ b/packages/opencode/script/treeshake-prepass.ts @@ -0,0 +1,187 @@ +#!/usr/bin/env bun +/** + * Rollup tree-shaking pre-pass for the opencode build. + * + * Bun's bundler cannot tree-shake `export * as X from "./mod"` barrels + * (nor can esbuild — see evanw/esbuild#1420). Rollup can. + * + * This script runs Rollup on the source entrypoints to eliminate unused + * exports and their transitive imports, then writes the tree-shaken ESM + * to .rollup-tmp/ for Bun to compile into the final binary. + * + * Usage: + * bun script/treeshake-prepass.ts [entrypoints...] + * + * If no entrypoints are given, defaults to ./src/index.ts. + * Output goes to .rollup-tmp/ preserving the entry filename. + */ + +import { rollup, type Plugin as RollupPlugin } from "rollup" +import path from "path" +import fs from "fs" + +const dir = path.resolve(import.meta.dirname, "..") +const srcDir = path.join(dir, "src") + +// Path alias mappings from tsconfig.json +const aliases: Record = { + "@/": path.join(srcDir, "/"), + "@tui/": path.join(srcDir, "cli/cmd/tui/"), +} + +// Conditional imports from package.json "#imports" +const hashImports: Record = { + "#db": path.join(srcDir, "storage/db.bun.ts"), + "#pty": path.join(srcDir, "pty/pty.bun.ts"), + "#hono": path.join(srcDir, "server/adapter.bun.ts"), +} + +function resolveWithAliases(source: string, importerDir: string): string | null { + // Handle hash imports + if (hashImports[source]) return hashImports[source] + + // Handle path aliases + for (const [alias, target] of Object.entries(aliases)) { + if (source.startsWith(alias)) { + return target + source.slice(alias.length) + } + } + + // Handle relative imports + if (source.startsWith(".")) { + return path.resolve(importerDir, source) + } + + return null +} + +// Binary/asset extensions that Bun imports natively but Rollup can't parse +const assetExtensions = new Set([".wav", ".wasm", ".node", ".png", ".jpg", ".gif", ".svg", ".css"]) + +function tryResolveFile(base: string): string | null { + // Try exact file, then .ts, then .tsx, then /index.ts, then /index.tsx + for (const suffix of ["", ".ts", ".tsx", "/index.ts", "/index.tsx"]) { + const p = base + suffix + if (fs.existsSync(p) && fs.statSync(p).isFile()) return p + } + // Bun.Transpiler rewrites .ts → .js in import paths, so try .ts for .js + if (base.endsWith(".js")) { + const tsBase = base.slice(0, -3) + for (const suffix of [".ts", ".tsx"]) { + const p = tsBase + suffix + if (fs.existsSync(p) && fs.statSync(p).isFile()) return p + } + } + return null +} + +/** + * Rollup plugin that resolves TypeScript paths and transpiles TS/TSX. + * Uses Bun.Transpiler for speed — no separate TS compilation step. + */ +const bunTranspilePlugin: RollupPlugin = { + name: "bun-transpile", + + resolveId(source, importer) { + if (!importer) return null + + const importerDir = path.dirname(importer) + const resolved = resolveWithAliases(source, importerDir) + if (!resolved) return null // external (node_modules, node builtins) + + const file = tryResolveFile(resolved) + if (file) return file + + // If it's a local import we can't resolve (generated file, missing, etc.), + // mark it external so Bun handles it later + return { id: source, external: true } + }, + + load(id) { + if (id.endsWith(".ts") || id.endsWith(".tsx")) { + return fs.readFileSync(id, "utf-8") + } + // Handle non-JS assets that Bun imports natively + if (id.endsWith(".txt")) { + const content = fs.readFileSync(id, "utf-8") + return `export default ${JSON.stringify(content)};` + } + if (id.endsWith(".json")) { + const content = fs.readFileSync(id, "utf-8") + return `export default ${content};` + } + if (id.endsWith(".sql")) { + const content = fs.readFileSync(id, "utf-8") + return `export default ${JSON.stringify(content)};` + } + // Binary assets — return a placeholder (Bun handles the real import) + const ext = path.extname(id) + if (assetExtensions.has(ext)) { + return `export default "asset:${path.basename(id)}";` + } + return null + }, + + transform(code, id) { + if (!id.endsWith(".ts") && !id.endsWith(".tsx")) return null + const loader = id.endsWith(".tsx") ? "tsx" : "ts" + const t = new Bun.Transpiler({ loader, tsconfig: JSON.stringify({ compilerOptions: { jsx: "preserve" } }) }) + return { code: t.transformSync(code), map: null } + }, +} + +export async function treeshakePrepass(entrypoints: string[], outDir: string) { + const absEntries = entrypoints.map((e) => path.resolve(dir, e)) + const startTime = performance.now() + + console.log(`[treeshake] Running Rollup pre-pass on ${absEntries.length} entrypoint(s)...`) + + const bundle = await rollup({ + input: absEntries, + plugins: [bunTranspilePlugin], + treeshake: { + moduleSideEffects: false, // equivalent to sideEffects: false + }, + // Mark everything that isn't local source as external. + // Bun handles node_modules resolution + bundling in the compile step. + external: (id) => { + if (id.startsWith(".") || id.startsWith("/") || id.startsWith("@/") || id.startsWith("@tui/") || id.startsWith("#")) + return false + return true + }, + logLevel: "warn", + }) + + fs.mkdirSync(outDir, { recursive: true }) + const { output } = await bundle.write({ + dir: outDir, + format: "esm", + preserveModules: false, + entryFileNames: "[name].js", + }) + await bundle.close() + + const elapsed = (performance.now() - startTime).toFixed(0) + const totalSize = output.reduce((sum, chunk) => sum + ("code" in chunk ? chunk.code.length : 0), 0) + console.log(`[treeshake] Done in ${elapsed}ms — ${output.length} chunks, ${(totalSize / 1024).toFixed(0)}KB total`) + + // Return a mapping of original entry basenames to output paths + const entryMap = new Map() + for (const chunk of output) { + if (chunk.type === "chunk" && chunk.isEntry) { + entryMap.set(chunk.name, path.join(outDir, chunk.fileName)) + } + } + return entryMap +} + +// CLI mode: run directly +if (import.meta.main) { + const args = process.argv.slice(2) + const entries = args.length > 0 ? args : ["./src/index.ts"] + const outDir = path.join(dir, ".rollup-tmp") + const result = await treeshakePrepass(entries, outDir) + for (const [name, out] of result) { + console.log(` ${name} → ${path.relative(dir, out)}`) + } +}