Skip to content

ADR-022: Two-Stage CLI Compilation

Context

The Weltenwanderer CLI (packages/cli/) needs to ship as standalone executables for 4 platforms (linux-x64, linux-arm64, darwin-x64, darwin-arm64). Bun’s bun build --compile creates single-file executables by bundling all dependencies and embedding the Bun runtime.

Bun 1.3.x has a bundler bug in CJS-to-ESM namespace hoisting: when processing langium/lib/utils/cancellation.js — which contains export * from 'vscode-jsonrpc/lib/common/cancellation.js' — Bun generates 23 references to an exports_cancellation namespace variable but never emits its initialization. This causes ReferenceError: exports_cancellation is not defined at runtime when any Langium document processing code path is reached (validate, generate commands). The --version flag works because it exits before touching Langium.

The bug is NOT caused by minification. It reproduces with all flag combinations: --minify, --minify-whitespace, --minify-syntax, --bytecode, and bare --compile with no optimization flags. The root cause is Bun’s bundler failing to emit the CJS module wrapper for vscode-jsonrpc’s circular dependency chain when re-exported via ESM export *.

Related: https://github.com/oven-sh/bun/issues/5654

Decision

Use a two-stage compilation pipeline:

  1. Stage 1 (esbuild): Bundle and minify src/main.ts into a single ESM file. esbuild correctly resolves CJS/ESM interop, including circular dependencies and export * re-exports. The bun module is marked external (provided by the embedded runtime).

  2. Stage 2 (Bun): Compile the pre-bundled ESM file into standalone executables with --compile --bytecode. Since esbuild has already resolved all module dependencies into flat ESM, Bun’s bundler processes only 1 module (no CJS/ESM interop needed).

Compilation pipeline

src/main.ts
▼ esbuild --bundle --platform=node --format=esm --minify --external:bun
.bundle.mjs (single ESM file, ~542 KB)
▼ bun build --compile --bytecode --target=bun-{os}-{arch}
weltenwanderer-{os}-{arch} (standalone executable, ~64-104 MB)

Consequences

Positive

  • All CLI commands (validate, generate, —version) work correctly in compiled binaries
  • esbuild handles minification, producing a 542 KB bundle (vs 1.1 MB unminified)
  • Bun’s --bytecode flag still provides faster startup (bytecode precompilation)
  • Cross-compilation to all 4 targets works unchanged
  • Pipeline is transparent: intermediate .bundle.mjs can be inspected for debugging

Negative

  • Adds esbuild as a devDependency (~5 MB)
  • Two-stage pipeline is slightly slower than single-stage (adds ~90ms for esbuild step)
  • Workaround may become unnecessary if Bun fixes the namespace hoisting bug

Neutral

  • Binary sizes are unchanged (dominated by embedded Bun runtime, not application code)
  • The BUILD_VERSION define injection works identically in esbuild’s --define syntax

Alternatives Considered

Remove --minify only

Insufficient. The crash occurs without any optimization flags. The debugger agent initially tried this but the fix was incomplete.

Use --packages=external

Keeps node_modules as external requires, but --compile embeds no filesystem. The binary fails with Cannot find package 'langium'.

Patch Langium’s re-export

Would require forking or monkey-patching langium/lib/utils/cancellation.js to use direct imports instead of export *. Fragile across Langium version updates.

Wait for Bun fix

The bug has been open since 2023 (issue #5654). No timeline for resolution. The two-stage approach is a pragmatic workaround that can be simplified later.

Use Node.js SEA (Single Executable Application)

Would avoid Bun’s bundler entirely but loses Bun-specific features (Bun.Glob, faster startup) and requires a different build pipeline.