Sfoglia il codice sorgente

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
Claude 1 giorno fa
parent
commit
d1a9ef8053

+ 1 - 0
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",

+ 1 - 0
packages/opencode/.gitignore

@@ -1,4 +1,5 @@
 research
+.rollup-tmp
 dist
 dist-*
 gen

+ 1 - 0
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",

+ 21 - 1
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 }

+ 187 - 0
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<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)}`)
+  }
+}