Skip to content

perf: add Rollup tree-shaking pre-pass for export * as barrels#22819

Closed
kitlangton wants to merge 3 commits into
devfrom
claude/verify-pr-comment-UuyAZ
Closed

perf: add Rollup tree-shaking pre-pass for export * as barrels#22819
kitlangton wants to merge 3 commits into
devfrom
claude/verify-pr-comment-UuyAZ

Conversation

@kitlangton
Copy link
Copy Markdown
Contributor

@kitlangton kitlangton commented Apr 16, 2026

Summary

Adds a Rollup tree-shaking pre-pass to the build pipeline, responding to this comment on #22685 pointing out that Bun's bundler can't tree-shake export * as barrels.

  • Add script/treeshake-prepass.ts — Rollup pre-pass with Bun.Transpiler-based plugin
  • Integrate into script/build.ts (skippable via --skip-treeshake)
  • Add rollup as devDependency, .rollup-tmp to .gitignore

The problem, verified

We tested every bundler against the export * as X from "./mod" pattern that the namespace migration (#22685) introduced. Results with a test module where only small is used but big and heavyFunction are also exported:

Pattern Bun esbuild Rollup
import { small } from "./mod" (direct) 91B ✅ 83B ✅ 56B ✅
import * as Mod from "./mod" (star) 89B ✅ 81B ✅ 56B ✅
export * as Mod barrel 730B 513B 56B
export namespace (old pattern) 315B 265B

Only Rollup can tree-shake export * as barrels. Both Bun and esbuild keep all exports (evanw/esbuild#1420, still open). Rollup does AST-level analysis and traces which properties of the namespace object are actually accessed.

Real-world simulation

We simulated the Provider module scenario from the namespace-treeshake spec — cli/error.ts imports Provider.ModelNotFoundError for lightweight .isInstance() checks, while the Provider module also exports createProvider (which pulls in heavy AI SDK deps):

Build Size Heavy deps
Bun only 1476B All kept (AI SDK, createProvider, layer)
Rollup → Bun 482B Dropped (only ModelNotFoundError + InitError survive)

The pipeline

src/index.ts → [Rollup: tree-shake] → .rollup-tmp/index.js → [Bun: compile] → binary

Rollup resolves internal source (path aliases, hash imports, .txt/.json assets), eliminates dead code across all 57 export * as barrels, and writes tree-shaken ESM. Bun then compiles that output into the final binary. Pre-pass takes ~5s.

Current savings

With the single-entrypoint architecture, bundle savings are ~0.7% (67KB). This is because most code paths ARE reachable from src/index.ts — the CLI uses most of its modules. The infrastructure is correct and validated; the savings scale with:

  1. Per-command lazy loading — we prototyped this (entry chunk drops from ~10MB to 120KB, 98.8% reduction) and will send as a separate PR
  2. Remaining 50 export namespaceexport * as migrations — more barrels = more tree-shake surface

Test plan

  • bun script/treeshake-prepass.ts completes successfully (~5s)
  • Typecheck passes
  • Build integration works with --skip-treeshake fallback
  • Full bun run build --single produces working binary (needs CI)
  • Smoke test passes with tree-shaken entry

https://claude.ai/code/session_01R7zMpXjsq1R6uR7xpyJ14i

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
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a Rollup-based tree-shaking pre-pass to the packages/opencode build pipeline to eliminate dead code introduced by export * as X from "./mod" barrel patterns, then feeds the tree-shaken ESM output into Bun’s compile step.

Changes:

  • Add script/treeshake-prepass.ts to run Rollup with a Bun.Transpiler-backed plugin and emit tree-shaken ESM to .rollup-tmp/.
  • Integrate the pre-pass into script/build.ts (opt-out via --skip-treeshake) and clean up .rollup-tmp after the build.
  • Add Rollup as a dev dependency and ignore .rollup-tmp.

Reviewed changes

Copilot reviewed 4 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/opencode/script/treeshake-prepass.ts Implements Rollup pre-pass with custom resolution/transpile/asset handling and writes tree-shaken ESM output.
packages/opencode/script/build.ts Runs the pre-pass before Bun.build, uses the resulting entrypoint, and removes the temp directory afterward.
packages/opencode/package.json Adds rollup devDependency required for the pre-pass.
packages/opencode/.gitignore Ignores .rollup-tmp output directory.
bun.lock Locks the added rollup dependency.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.


// Path alias mappings from tsconfig.json
const aliases: Record<string, string> = {
"@/": path.join(srcDir, "/"),
input: absEntries,
plugins: [bunTranspilePlugin],
treeshake: {
moduleSideEffects: false, // equivalent to sideEffects: false
Comment on lines +125 to +130
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 }
},
claude added 2 commits April 16, 2026 13:03
Replace 23 static command imports with lazy-loaded dynamic imports.
Each command's heavy dependencies (Provider, Session, MCP, TUI, AI SDKs)
are now only loaded when that specific command is invoked.

Combined with the Rollup tree-shaking pre-pass, this produces 45 chunks
instead of a single bundle. The entry chunk (what loads on startup) drops
from ~9.8MB to ~120KB — a 98.8% reduction in startup load.

- Add `lazyCmd` helper to `cli/cmd/cmd.ts` for type-safe lazy commands
- Inline all builder options (yargs metadata) in `index.ts`
- Dynamic `import()` in handlers defers heavy module loading
- `opencode --help` / `--version` no longer loads AI SDK, MCP, TUI, etc.

https://claude.ai/code/session_01R7zMpXjsq1R6uR7xpyJ14i
@kitlangton kitlangton closed this Apr 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants