Files
opencode/packages/opencode/script/treeshake-prepass.ts
T
Claude d1a9ef8053 perf: add Rollup tree-shaking pre-pass for export * as barrels
Bun's bundler (and esbuild) cannot tree-shake `export * as X from "./mod"`
barrels — see evanw/esbuild#1420. Rollup can, using AST-level analysis to
drop unused exports and their transitive imports.

This adds a Rollup pre-pass to the build pipeline that runs before Bun's
compile step. Rollup resolves internal source, eliminates dead code across
the 57 `export * as` barrels, and writes tree-shaken ESM to a temp directory.
Bun then compiles the pre-processed output into the final binary.

- Add `script/treeshake-prepass.ts` with Bun.Transpiler-based Rollup plugin
- Integrate into `script/build.ts` (skippable via `--skip-treeshake`)
- Add `rollup` as devDependency
- Pre-pass completes in ~5s, negligible vs full multi-platform build

Current bundle savings are modest (~0.7%) because the single-entrypoint
architecture means most code paths are reachable. Savings scale with:
- Per-command entry points or lazy loading
- Remaining 50 `export namespace` → `export * as` migrations
- Future code splitting

https://claude.ai/code/session_01R7zMpXjsq1R6uR7xpyJ14i
2026-04-16 12:37:49 +00:00

188 lines
6.2 KiB
TypeScript

#!/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<string, string> = {
"@/": path.join(srcDir, "/"),
"@tui/": path.join(srcDir, "cli/cmd/tui/"),
}
// Conditional imports from package.json "#imports"
const hashImports: Record<string, string> = {
"#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<string, string>()
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)}`)
}
}