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:
-
Stage 1 (esbuild): Bundle and minify
src/main.tsinto a single ESM file. esbuild correctly resolves CJS/ESM interop, including circular dependencies andexport *re-exports. Thebunmodule is marked external (provided by the embedded runtime). -
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
--bytecodeflag still provides faster startup (bytecode precompilation) - Cross-compilation to all 4 targets works unchanged
- Pipeline is transparent: intermediate
.bundle.mjscan be inspected for debugging
Negative
- Adds
esbuildas 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_VERSIONdefine injection works identically in esbuild’s--definesyntax
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.