Skip to content

Latest commit

 

History

History
482 lines (320 loc) · 51.4 KB

File metadata and controls

482 lines (320 loc) · 51.4 KB

NopenCode: opencode built with nub compile

NopenCode is a port of opencode from a bun build --compile executable to a nub compile one, on top of upstream v1.18.11 (e917c12).

The branch carries only hand-written source. The Solid JSX transform is a build step — script/nub-solid-transform.mjs rewrites .tsx in place, the same way the Bun build's onLoad plugin rewrites it in memory — so its output is never committed. Run it before building and git checkout afterwards, or build in a throwaway checkout.

Status

Builds and runs on darwin-arm64, darwin-x64, linux-x64, linux-arm64, linux-arm64-musl and win32-x64, with the TUI rendering on every one. That is the embed shape; --smol is verified with its TUI on darwin-x64 and linux-arm64, and the other four are untried in that shape rather than known good.

A TUI startup failure on darwin-arm64 held that claim open for a day; it turned out to be the development worktree's node_modules rather than anything in the port or in nub compile (below).

Verified from a foreign working directory, with the runtime cache cleared and a fresh HOME, and separately with this source tree moved away entirely:

--version, --help, models, agent list, providers list, four --help surfaces byte-identical to the Bun build, 9/9
TUI renders on both platforms, and responds to input — esc dismisses dialogs, typed text echoes, ctrl+p opens the command palette
Backend serve listens, /doc and /session return 200, a POSTed session persists and reads back stamped "version":"0.0.0-nub"
linux-x64 cross-compiled from macOS, run in debian:bookworm-slim with no Node on the machine — needs libatomic1, see below
linux-arm64 cross-compiled from macOS, run in debian:bookworm-slim at native speed under Docker on the arm64 host, again with no Node on the machine — needs libatomic1 too
darwin-x64 cross-compiled from arm64 macOS, run under Rosetta — commands correct straight away, TUI correct once the binary used its own Node rather than the host's (see below)
linux-arm64-musl cross-compiled from macOS, run in alpine:3.20 under Docker at native arm64 speed with no Node — needs libgcc rather than libatomic1
win32-x64 cross-compiled from macOS, run on native AMD64 Windows Server 2022 — every command matches, TUI renders. ~4 s warm startup there, see below
--smol 21.6 MB, provisions its own Node on a machine that has none: 14 s first run, 2 s after. Needs curl or wget on the box — a slim image has neither, and nub says so rather than failing obscurely
A model response not verified. No usable credential on the test machine: the same prompt fails identically on this binary, the Bun build, and a stock installed opencode (Token refresh failed: 401).

A TUI startup failure that was the install, not the build

For a day this branch could not draw a frame on the development machine. A binary built there started, loaded its config, and exited. The CLI's top-level handler printed only Unexpected error / An error occurred in Effect.tryPromise; printing the Effect's cause gave the real fault:

Error: TuiStartupProvider is missing
    at required (…/src/theme-B5AVrCIr.mjs)
    at useTuiStartup (…/src/theme-B5AVrCIr.mjs)
    at App (…/src/layer-Ckm05hos.mjs)

It no longer reproduces. Four builds from the same commit all render the frame under script/nub-pty-screen.py: released nub 0.8.3, a nub built from main, and that same main build with --no-minify, plus the default recipe below. The only thing that changed between the failing binary and the first working one was a bun install in the worktree — the lockfile came out unchanged, so the resolution was already right and the tree was not.

The two bundles say the same thing. The failing artifact has 261 chunks and 35 get children getters; every artifact built since has 269 and 407. Those getters are Solid's JSX transform output, written by script/nub-solid-transform.mjs before the bundler ever runs, so a difference in how many of them exist is upstream of nub compile entirely. It is also exactly the difference that breaks the context: Provider assigns Owner.context and reads children only after, so a lazy get children() puts the subtree inside the provider's owner and an eager children: puts it outside, where useContext finds nothing.

The leading explanation is an incomplete install — the per-package node_modules symlinks that both packageDir("solid-js") and the transform's own babel-preset-solid resolution depend on. A sibling worktree in this repo was found missing 26 of them. That is unproven: the tree is gone and cannot be re-measured. The practical consequence is the one worth keeping — run bun install before building, and treat a TUI that dies with a missing provider as a suspect install rather than a bundler defect.

Two things it was not. Not a duplicate-instance problem: the extracted bundle has one Solid runtime (one chunk defining createSignal) and one StartupContext. Not minification: an artifact built with minification on renders, and its getters survive intact.

Measured against the Bun build

darwin-arm64, hyperfine, 12 warm runs and 3 cold with the cache wiped between. Host was moderately loaded (~8 on 10 cores), so treat the absolutes as indicative and the ratios as the result.

startup, warm startup, cold on disk gzip -9
Bun 363 ms 392 ms 101.6 MB 33.6 MB
nub, embed 772 ms 4.90 s 45.3 MB 44.4 MB
nub, --smol 822 ms 3.20 s 18.3 MB 17.4 MB

That table is superseded and both of its nub rows are now wrong. Re-measured 2026-09-05 against the same binary shape, warm startup is 425 ms, not 772, and cold is 2.3 s, not 4.9 — nub roughly halved both while this branch sat still. The Bun column cannot be refreshed: this branch's source no longer builds under Bun at all, because four files import node:sqlite and Bun has no such builtin. So the like-for-like comparison the table records is a historical measurement, not something reproducible today.

The explanation attached to it was also wrong. It said nub's launcher "spawns Node as a child, paying two process starts". A nub compile artifact of a one-line program starts in 38.2 ms ± 6.9, against 42.0 ms ± 4.8 for node -e '' on the same host — indistinguishable, and no room for a second process start. Whatever this build spends, it does not spend on the launcher. Warm, the two builds are level: on an alternating timer over 25 runs this build prints --version in 654.8 ms against the release's 665.1 ms, and comes out lower in 11 of 25 paired runs.

What is left is cold start, and it is not startup at all — it is extraction. See below.

The size half of the table stands, and its warning is the part to keep. The on-disk column is the misleading one and should never be quoted alone: nub's binary barely compresses because the embedded Node is already zstd-19, while Bun's compresses 3x because it is stored uncompressed. What you actually ship is the compressed size, and there Bun wins — 33.6 MB against 44.4 MB. --smol is the only shape that beats it, at 17.4 MB, because it carries no runtime at all.

Time to first frame goes the other way

Startup as measured above is --version: exec, runtime init, a small module graph, print, exit. It is not what a user waits for. Time to the first painted TUI frame is, and it does not follow the startup row.

Measured under a pty, nine alternating pairs, medians, fresh temp directory per run. first_byte is process start through to the first output; first_paint is the first 24-bit SGR sequence, which is the first byte of real screen content rather than of the terminal capability prologue.

Both binaries were captured with script/nub-pty-screen.py first, to confirm the frames are comparable rather than one being a splash and the other a full UI. They are: same prompt box, same tab agents ctrl+p commands footer, same status bar. They differ only in what sits above it — a release banner on one, an update dialog on the other, because this build is older than the release it checks against.

first_byte first_paint paired wins
this build (v1.18.11 + port, nub) 572 ms 2604 ms 9/9
released opencode v1.18.29 (Bun) 1114 ms 3068 ms

The same shape holds with both binaries pointed at one empty HOME: 2691 ms against 3079 ms, 8 of 9 pairs.

The --version row above it is stale, and re-measuring changed the conclusion rather than sharpening it. On an alternating timer — 25 runs, arms interleaved — this build prints --version in 654.8 ms against the release's 665.1 ms, and comes out lower in 11 of 25 paired runs. That is a coin flip, not the 2.1x deficit the table claims. A separate six-pair run on a quieter host reads 425.5 ms against 478.1 ms. Warm, the two are level; cold, this build pays 2.4 s the release does not. The cold number is the real one to attack, and it is extraction, not startup: 108 MB unpacked on first run.

Read it as a comparison of two builds, not of two runtimes. The two differ in more than the compiler:

  • Version. v1.18.11 + this port against v1.18.29 — 397 upstream commits, 805 files, +99359/−10454. A newer opencode does more before it paints.
  • Payload. script/build.ts embeds the web UI through opencode-web-ui.gen.ts; script/build-nub.mjs aliases that specifier to an empty module. This build carries less JavaScript.
  • Bytecode. script/build.ts passes no --bytecode, so Bun parses from source on every start. The nub artifact ships no code cache in its SEA blob either, but Node's on-disk compile cache covers the app chunks and the loader turns it on.

Two candidate causes were tested and neither carries the result. A copy of the 28.4 MB release database costs this build 125 ms against its own 311 KB one — 1.05x, where the gap is 460 ms. Emptying the compile cache before every run costs 197 ms of first_byte and 136 ms of first_paint — about a third of the first_byte gap, and the build is still ahead with the cache cold. The remainder is version and payload, which this pair of binaries cannot separate.

Cold start is extraction, and a types-only peer is a fifth of the payload

The first run of a compiled artifact unpacks its payload; every run after that finds it already there. That first run is where essentially the whole cold-start gap lives, since a Bun binary carries no payload to unpack.

Most of that cost turned out to be in the launcher rather than in this tree, and it is fixed upstream: sealing the extracted cache flushed every file serially, and File::sync_all is fcntl(F_FULLFSYNC) on macOS — a full device barrier per file. Flushing concurrently took a cold run from about 2.8 s to about 1.0 s. Nothing in this repo had to change for it.

What is left is the payload's own size: 108 MB across 2813 files, of which 23 MB is the TypeScript compiler, shipped inside @opentui/core/node_modules/typescript and never loaded by anything.

It arrives through a declaration, not an import. bun-ffi-structs names typescript in peerDependencies, and nub compile walks dependencies, optionalDependencies and peerDependencies transitively for every package it ships unbundled — deliberately, because such a package runs from real files and resolves its peers by walking up like any other require. Omitting one would ship a package that fails at run time on a module its own manifest named. Bun's isolated layout is what makes the walk find this one: bun install materializes the peer as a sibling inside the dependent's own store entry, so node_modules/.bun/bun-ffi-structs@0.2.4+…/node_modules/ holds typescript next to bun-ffi-structs.

Here the peer is types-only. bun-ffi-structs publishes files: ["dist"], and its dist/index.js contains no occurrence of the string typescript; OpenTUI has also already inlined that package into its own chunk-node-*.js, so the directory is not on the Node arm's load path at all.

Dropping the peer with a patch was measured and then reverted. It works, and the numbers below are real, but it is a local workaround for a nub compile limitation: patching it out here would make this tree's artifact smaller without making the tool that produced it any better, and it would flatter any comparison drawn against a build that never had the handicap. The 23 MB stays until nub compile stops following a peer nothing imports.

That fix is not a one-liner. The obvious form — follow a peer only when the package's own code names it — never fires here, because bun-ffi-structs/dist/index.js contains import(specifier); the closure scanner treats a call with a computed argument as "cannot answer" and abandons the verdict for the whole package, so every declaration stays reachable. Making it fire means changing that all-or-nothing verdict, which governs bundling decisions well beyond peers.

Measured against a control built from this same tree with the patch removed and the store entry deleted so bun install genuinely re-extracts it — without that deletion the "control" silently reuses the patched copy and comes out byte-identical:

payload files binary cold --version
control 108 MB 2813 46.36 MB 2658 ms
patched 86 MB 2684 43.06 MB 2448 ms

Nine alternating pairs, medians; the patched build was faster in 7 of 9. Warm startup and the TUI are unchanged — the frame renders, models still lists 32 providers. Both cold figures were taken before the launcher fix above and are now roughly a second higher than the same builds would measure today; the ratio between them is the part that still holds.

Note the shape of that result: 20% fewer bytes bought 8% less time, while the file count fell only 5%. Extraction is closer to per-file than per-byte, which is worth knowing before chasing further megabytes.

Two things were checked and left alone:

  • --unbundled @opentui/core does nothing. Removing the flag produces a byte-identical binary and the same payload hash. nub compile already detects that OpenTUI cannot be flattened; the flag only adds to what detection found. It stays for documentation value, not effect.
  • node-gyp, 8 MB inside @npmcli/run-script's closure, is load-bearing. run-script reaches it when a plugin has a native build step, and the build already marks it --external, so the unbundled copy is what makes that external resolvable. That closure is also 1159 files — 41% of the payload's file count, and the largest remaining lever, but not a free one.

21.5% of the remaining payload is byte-identical duplicates — 333 files, 17.7 MB. The dylib ships twice (staged into otui-assets and again inside the ejected package), tree-sitter.wasm four times, every grammar twice. Half of that is this build's staging and half is one package landing under several closure placements. Deduplicating it belongs in nub compile rather than here: giving up the staged asset root would trade away the musl fix below for bytes that a content-addressed payload would return anyway.

musl needs OPENTUI_LIBC set explicitly

Only musl surfaced this, and it failed hard rather than subtly: the TUI died with Missing OpenTUI asset "@opentui/core-linux-arm64/libopentui.so" — the glibc package's key — on a musl binary that had the musl library staged beside it.

OpenTUI does no run-time musl detection. It reads OPENTUI_LIBC from the environment and otherwise assumes glibc, so a musl build asks for the wrong package name and cannot find the library it shipped with. src/nub/otui-asset-root.ts now sets it from OPENCODE_LIBC, which the build already defines from the target triple. The glibc builds are untouched — the assignment is guarded on OPENCODE_LIBC === "musl" — and were re-run afterwards to confirm it.

musl also needs a different system library than glibc does: libgcc (apk add libgcc) rather than libatomic1. nub's diagnostic names the right one for the distro in both cases.

Windows

Cross-compiled from an arm64 Mac and verified on native AMD64 Windows Server 2022: --version, --help (56 lines, identical to the other two platforms), models, agent list, providers list all correct, and the TUI renders. The cache lands at %USERPROFILE%\.cache\nub.

Two caveats worth carrying:

  • Startup is ~3 s warm there against 772 ms on macOS. Root-caused below — it is neither Defender nor disk.
  • The launcher was linked with x86_64-pc-windows-gnu, because that is what cargo-zigbuild can produce from macOS. The release pipeline uses x86_64-pc-windows-msvc. It worked, but a gnu-linked launcher is a different CRT and should not be assumed equivalent for shipping.

The Ctrl-C console guard in terminal-win32.ts now goes through node:ffi. Its kernel32 calls were exercised on a clean Windows Server 2022 box from a nub compile binary cross-built on macOS: dlopen("kernel32.dll"), GetConsoleMode on the console input handle (0x1f7), SetConsoleMode clearing ENABLE_PROCESSED_INPUT (0x1f6), restore, and FlushConsoleInputBuffer all returned success. The guard inside the TUI itself was not driven interactively there — an SSH session has no TTY stdin, so the guard's own early return takes over. Two things that box also showed: the v0.8.3 launcher imports VCRUNTIME140.dll and dies with STATUS_DLL_NOT_FOUND on a machine without the VC++ redistributable (nub main already links the CRT statically), and a launcher older than the nub that compiled the payload refuses it (compiled payload format version 3 is unsupported).

Suggestion: --target 26 means a different Node in each shape

The bare major resolves to 26.6.0 when the Node is embedded and 26.0.0 under --smol. Same flag, same command line, two runtimes six patch releases apart.

That is enough to break an app silently. On 26.0.0 this TUI cannot bring up OpenTUI's native backend and dies with OpenTUI native FFI is not available for this runtime yet; on 26.6.0 it renders. Nothing in the build output suggests the two shapes differ — each prints the version it chose, but only side by side does the mismatch show. Pinning --target 26.6.0 makes --smol work, which is what script/build-nub.mjs now does by default.

A major-only target reasonably means "the newest 26 you can get". Whatever the rule is, both shapes should apply the same one, since the shape flag is about where the runtime comes from and not which runtime it is.

Suggestion: an embed binary adopts a host Node of the wrong architecture

Found by running the darwin-x64 build on an arm64 Mac under Rosetta, which is not an exotic setup — an x64 build is the fallback download, and Apple Silicon users run one whenever a native arm64 build is unavailable.

The TUI died with Missing OpenTUI asset "@opentui/core-darwin-arm64/libopentui.dylib" — the arm64 package — from an x86_64 binary that had staged the x64 one. The binary's own commands worked, so the mismatch only surfaced where a platform-specific file had to be found.

The cause is that the embed shape prefers a Node already on the machine over extracting its own, and here the host Node was arm64 while the launcher process was x86_64. process.arch then reported arm64, so every path the app computes from it named a package the binary never shipped. Confirmed by control: with env -i and no Node on PATH, the same binary extracted its own Node — verified Mach-O 64-bit executable x86_64 — and the TUI rendered correctly.

Adopting a host Node is a good optimisation, and skipping a ~100 MB extraction is worth real effort. The suggestion is only that the adopted Node has to match the triple the binary was built for, not merely the machine it landed on. Otherwise process.arch and process.platform disagree with the build, and every asset, native addon and platform-conditional path an app derives from them points somewhere that does not exist. An app hitting this sees a missing-file error naming a package it never depended on, which is a hard failure to trace back to the launcher.

Suggestion: the warm-start check is O(payload), and it dominates startup

Worth raising because it is invisible on a small binary and severe on a large one. Measured on Windows, median of five runs each:

median
system node -e 0 55 ms
the extracted node -e 0 67 ms
that node running the extracted app directly (node --require <bootstrap> <entry> --version) 1277 ms
the compiled executable 3052 ms

So Node is fine — the extracted copy is 12 ms slower than the system one — the app's own module graph costs ~1210 ms, and the launcher adds ~1775 ms on top before Node even starts.

The obvious culprits are ruled out. Defender exclusions on the executable and the cache changed nothing (2973 ms with, 3000 ms without). Reading the entire 98.6 MB extracted node.exe off that disk takes 78 ms. A full stat-walk of the 5164-file extracted app tree takes 363 ms.

The same shape holds on macOS, which is what makes it a property of the design rather than of Windows. Ten runs each under hyperfine on the arm64 Mac: node -e 0 34.9 ms, that node running the extracted app directly 424.9 ms, the compiled executable 906.6 ms. So the app costs ~390 ms and the launcher adds ~482 ms — more than half the total, for the same 124 MB payload. Windows pays 1775 ms for the identical work; it is the same overhead against a slower disk and CPU.

The cost is app_cache_is_ready in crates/nub-launcher/src/main.rs, which runs on every warm launch. It builds a map holding the full decompressed bytes of every app file (app_bytes, which zstd-decodes each one when the payload is compressed), then walks the extracted tree and byte-compares every file against that map. For this binary that is ~124 MB decompressed and ~124 MB read and compared, on every single invocation. The work scales with payload size rather than staying constant, which is why a 124 MB app pays ~1.8 s and a small one pays nothing noticeable. It is also part of why the warm numbers trail Bun's on macOS, where Bun does no equivalent check.

The completion marker already proves a publication finished; the expensive part is proving the tree still matches afterwards. A digest written once at publish time and checked once at launch, or a manifest of (path, size, mtime), would give the same tamper-evidence at O(files) instead of O(bytes). If full byte verification is deliberate, making it opt-in past some payload size would keep the guarantee where it is cheap and drop ~1.8 s where it is not.

Shipping to a slim container

The embedded Node links a few system libraries. On debian:bookworm-slim the binary stops with a precise diagnostic:

nub: the embedded Node cannot start: it needs libatomic.so.1, which is not installed.
  Debian or Ubuntu:  apt-get install libatomic1
  Alpine:            apk add libatomic

node:*-slim images already carry it; bare debian:*-slim and alpine do not.

Running it

Two halves: a nub that has the compile verb, and this tree.

1. A nub that can compile

nub compile is behind an off-by-default cargo feature, and it needs a nub-launcher for the target sitting beside the binary — a released nub carries neither, and without the launcher the build stops with a 404 fetching it.

git clone git@github.com:nubjs/nub.git && cd nub
git checkout compile-spike

scripts/rust-build.sh build -p nub-cli --profile fast --features compile
( cd crates/nub-launcher && cargo build --release )

NUB_TARGET=$(scripts/rust-build.sh --print-target)/fast
cp crates/nub-launcher/target/release/nub-launcher \
   "$NUB_TARGET/nub-launcher-$(node -p '(process.platform==="win32"?"win32":process.platform)+"-"+process.arch')"
export NUB="$NUB_TARGET/nub"

2. This tree

git clone git@github.com:nubjs/nopencode.git && cd nopencode
git checkout nub-compile
nub install                       # bun install also works

cd packages/opencode
curl -sSL https://models.dev/api.json -o /tmp/oc-models.json
mkdir -p dist-nub

node script/nub-solid-transform.mjs ./src ../tui/src   # in place — see below
node script/build-nub.mjs                              # stages assets, then compiles

# undo the transform — only the transformed files, so any uncommitted
# change to the build scripts survives
git -C ../.. diff --name-only | grep -E '\.(tsx|jsx)$' | tr '\n' '\0' \
  | (cd ../.. && xargs -0 git checkout --)

The Solid transform rewrites .tsx in place, exactly as the Bun build's onLoad plugin rewrites it in memory. Its output is never committed — undo it after building, or build in a throwaway checkout.

OPENCODE_MODELS_JSON, OUT and NO_MINIFY=1 are honoured by script/build-nub.mjs. It resolves packages by walking the workspace's own node_modules rather than reading a store path, so it builds the same from a bun install tree, a nub install tree or an npm one.

Installing with nub install

Measured on a pristine checkout: nub install exits 0, node script/build-nub.mjs exits 0, and the binary's TUI renders. The suite lands at 5942 / 102 / 48 against 5956 / 97 / 48 on a bun install tree of the same commit — 21 of the 23 targets identical, including core at 1066 / 8 on both.

Two differences are worth knowing about, and neither touches the compiled CLI.

A missing peer. @effect/platform-node declares ioredis as a non-optional peer and imports it at module scope from NodeRedis.js. bun install supplies a missing non-optional peer and nub install does not, so the same source links under one and fails under the other — 48 test files died on it, and every module-not-found in that run named that one package. packages/nub-test/ioredis-absent.ts fills the hole with a stub whose constructors throw, wired into the resolver hook's existing ERR_MODULE_NOT_FOUND arm so it is reached only after node's own resolution has failed. A tree carrying the real package is untouched. Nothing in opencode constructs a Redis client; the compiled binary makes the same call with --external ioredis.

Electron. packages/desktop runs 58 / 2 on a nub tree against 70 / 1 on a bun tree, because electron's binary extraction does not complete: dist holds one entry rather than four, and the path.txt the extraction writes is absent. It is not a stripped lifecycle script — electron 42's published manifest declares no scripts at all, on either tree.

3. Run it

./dist-nub/opencode --version     # 0.0.0-nub
./dist-nub/opencode               # the TUI
./dist-nub/opencode run "…"       # non-interactive, needs a signed-in provider

The binary is self-contained — no Node, no node_modules, nothing to install. On first run it unpacks itself under ${XDG_CACHE_HOME:-$HOME/.cache}/nub/compile-app/<hash>, which takes a second or two; later runs are immediate. That cache grows by one full extraction per rebuild and nothing evicts it, so rm -rf "${XDG_CACHE_HOME:-$HOME/.cache}/nub/compile-app" when it gets large.

Installing with nub

nub install completes, applies all 16 patches, and produces a tree the build compiles from. Getting there took four fixes, and three of them were defects in this repo that bun's leniency had been hiding rather than anything nub lacked.

What refused Why Fix
File '@tsconfig/bun/tsconfig.json' not found nub reads the project's tsconfig before installing, and an extends that points into node_modules cannot resolve before the first install has run tsconfig.base.json in the repo, extended by relative path
ERR_NUB_UNUSED_PATCH: @ff-labs/fff-bun@0.9.3 the dependency is declared at 0.9.4, so the patch key stopped matching. bun ships it unpatched and says nothing — verified in the install tree: createRequire still present, the patch's FFF_LIBC absent re-keyed to 0.9.4, where the patched region is byte-identical
ERR_NUB_UNUSED_PATCH: @standard-community/standard-openapi@0.2.9 a non-optional peer of hono-openapi that bun auto-installs and nub does not, so the patch matched no installed package declared explicitly beside hono-openapi
failed to apply patch … could not apply hunk 10 for @silvia-odwyer/photon-node@0.3.4 the last hunk's three context lines each carried one extra leading space, describing content the file does not have. git apply --check rejects it too; bun trims and applies it anyway context corrected. The patched file is byte-identical to the copy bun produces

Two differences between the two package managers are worth carrying rather than fixing:

  • nub does not auto-install non-optional peer dependencies; bun does. Both @standard-community/standard-openapi and ioredis reached the graph only that way. The first is genuinely used, so it is now declared; the second is reached only through @effect/platform-node's NodeRedis.js, which nothing here imports, so the build passes --external ioredis rather than installing a redis client into the binary.
  • Native build scripts need a modern node-gyp on PATH. tree-sitter-powershell fails under node-gyp 3.8.0, which is Python-2 only. Both package managers fail identically on a machine carrying an old global copy, so this is not a nub difference.

Cross-compiling

PLATFORM moves the whole build — the target triple, the staged OpenTUI native library, and the libc defines. It needs the other platforms' packages present, which is what opencode's own build does before its 12-target matrix:

bun install --os="*" --cpu="*" @opentui/core@0.4.5
bun install --os="*" --cpu="*" @parcel/watcher@2.5.1

PLATFORM=linux-x64 OUT=$PWD/dist-nub/opencode-linux-x64 node script/build-nub.mjs
SMOL=1 PLATFORM=linux-x64 OUT=$PWD/dist-nub/opencode-smol-linux node script/build-nub.mjs

nub compile needs a launcher for the target beside the nub binary. Cross-build one with the zig linker — 49 s for linux-x64 from an arm64 Mac:

rustup target add x86_64-unknown-linux-gnu
( cd crates/nub-launcher && cargo zigbuild --release --target x86_64-unknown-linux-gnu )
cp crates/nub-launcher/target/x86_64-unknown-linux-gnu/release/nub-launcher "$NUB_TARGET/nub-launcher-linux-x64"

A suggestion for nub compile: a source-transform seam

This is the one thing the port needed that nub compile cannot express, and it is not opencode-specific — any app whose source is a compile-to-runtime dialect (Solid, Svelte, Vue SFC) has the same shape.

Bun's build takes plugins: [createSolidTransformPlugin()]: an onLoad hook that rewrites .tsx in memory. nub compile has no hook, so this port runs the identical Babel pass over the working tree in place and reverts it afterwards. It works and the output is byte-identical, but mutating a source tree as a build step is a bad seam — and it bit me, since git checkout -- . also discards any uncommitted edit to the build scripts themselves.

A mirrored workspace is not a workaround. Measured, on this repo:

  • Symlinking each package's node_modules into a mirror builds cleanly and produces a broken TUI — the workspace links resolve back out to the untransformed originals. Silent wrong answer, the worst outcome.
  • Copying those node_modules instead keeps workspace links inside the mirror, but then tsconfig resolution fails (@tsconfig/node22 not found).

An isolated (bun/pnpm) layout is too entangled to relocate. Two options, cheapest first:

  1. An overlay directory. --overlay <dir>: during load, a file present at the same relative path under the overlay is read instead of the real one. The user runs whatever transform they like into the overlay; nothing mutates their tree. This is a load-hook redirect and needs no JS bridge.
  2. A real transform hook. --transform <module>, with nub hosting the user's module in the Node it already ships and calling it per module, mirroring esbuild/Rolldown onLoad. More faithful to Bun, considerably more machinery — an async IPC hop in the middle of the bundle.

Option 1 solves the demonstrated problem; option 2 solves the general one.

How each Bun build feature maps

Bun.build nub compile
conditions: ["bun", "node"] the default set — bun is deliberately not added, which is what selects opencode's existing node siblings for #sqlite, #pty, #fff and both opentui packages
plugins: [createSolidTransformPlugin()] script/nub-solid-transform.mjs, run before the build
external: ["node-gyp"] --external node-gyp
define: { … } --define, and --define-file for the models.dev payload
files: { … } in-memory modules real files, reached with --alias
entrypoints: [main, tuiWorker, …] one entry; the TUI worker is found from a literal new Worker(new URL(…, import.meta.url))
compile.execArgv --node-options
minify, splitting, format: "esm" defaults

Source changes, and why each one is needed

File Change
packages/opencode/src/nub/bun-compat.ts new — installs the Bun global over Node builtins. Only the five members the shipped graph calls: stringWidth, file, write, stdin, hash. Imported first from both compiled roots.
packages/opencode/src/nub/web-ui-empty.ts new — stands in for opencode-web-ui.gen.ts, the module script/build.ts generates and injects as a virtual file. Equivalent to upstream's own --skip-embed-web-ui.
packages/tui/src/editor-zed.ts bun:sqlite → node:sqlite: DatabaseSync, prepare(), readOnly.
packages/tui/src/terminal-win32.ts bun:ffi → node:ffi, loaded lazily and only on Windows. Same four kernel32 calls; node:ffi spells a signature { arguments, return } and hands back functions, and a buffer becomes a pointer through getRawPointer. The build bakes --experimental-ffi in (below).
packages/tui/src/{component/dialog-status.tsx, component/prompt/autocomplete.tsx}, packages/opencode/src/cli/cmd/run/footer.prompt.tsx fileURLToPath / pathToFileURL from "bun" → "node:url"
packages/opencode/src/cli/cmd/tui.ts the worker specifier moves inline into the new Worker(...) call — a specifier that reaches the constructor through a variable is invisible to the build, which would ship the worker entry untranspiled, as data. The worker's type import also becomes import type: the inline import { type rpc } form leaves a real import, so the worker body runs on the main thread and throws onmessage is not defined.
packages/opencode/src/index.ts, src/cli/tui/worker.ts import the Bun-global shim first
packages/opencode/package.json string-width (what Bun.stringWidth becomes); babel-preset-solid + @babel/preset-typescript for the transform
packages/core/src/database/migration.gen.ts 38 dynamic imports behind a top-level await Promise.all become static imports
packages/core/src/global.ts awaited mkdirs become mkdirSync
patches/@opentui%2Fcore@0.4.5.patch opentui's FFI backend and native-library path both resolve lazily and synchronously instead of at top level
packages/opencode/script/stage-otui-assets.mjs new — stages the 13 files OpenTUI locates at run time into one OTUI_ASSET_ROOT-keyed directory
packages/opencode/src/nub/otui-asset-root.ts new — points OTUI_ASSET_ROOT at that directory inside the binary, before OpenTUI loads
packages/opencode/src/nub/runtime-plugin-support-noop.ts new — stands in for the Bun-only TUI plugin host, whose Node arm throws on import

Everything else opencode already had: @opentui/core, @opentui/solid, #sqlite, #pty and #fff all ship working node conditions, so choosing the node condition is the whole fix for them.

Top-level await is the thing to avoid

Every hard failure in this port came from the same place, and it is worth understanding before changing anything here.

A bundler cannot keep ESM's evaluation semantics for an async module graph. Rolldown (and esbuild, which ships a byte-identical helper) turns each module into a lazy initializer and emits await init_dependency() at the head of each one. Real ESM evaluates a cycle as a single strongly connected component and never has a module wait on itself; the linearized form does exactly that. So one top-level await anywhere marks every importer async, and the first import cycle among them hangs the program before main with no error at all — just exit 13 and silence.

Four fixes here are that, and nothing else:

Where What
packages/core/src/database/migration.gen.ts 38 dynamic imports behind await Promise.all become static imports. Also drops 38 lazily-loaded chunks.
packages/core/src/global.ts seven awaited mkdirs become mkdirSync. 52 modules import this one.
patches/@opentui%2Fcore@0.4.5.patch two awaits: the FFI backend, and targetLibPath = await resolveNativeLibraryPath(). Both now resolve lazily and synchronously. The second one is what made the entire TUI subgraph async.

OpenTUI's runtime-resolved assets

OpenTUI finds its native library with await import("@opentui/core-<platform>-<arch>") — a specifier built from process.platform/process.arch, invisible to any bundler, and it locates the tree-sitter parser worker and grammar files the same way. OTUI_ASSET_ROOT is the package's own escape hatch: an absolute directory it consults first, keyed <package>/<file>.

script/stage-otui-assets.mjs builds that directory, --include embeds it, and src/nub/otui-asset-root.ts points the env var at it before anything touches OpenTUI. It is all or nothing — with the root set, a missing asset throws rather than falling back — so the staging script verifies all 13 assets are present rather than letting it fail inside a rendered frame.

Two things this replaced: a require() of the platform package fails, because its exports map declares only import and types; and --unbundled @opentui/core-<platform>-<arch> silently shipped nothing, because the package lives in Bun's isolated store and is not resolvable from the entry's directory.

The Bun-only plugin host

@opentui/solid/runtime-plugin-support/configure registers a bun.plugin hook so a TUI plugin loaded at run time resolves @opentui/solid and solid-js to the host's module instances. Its Node arm throws at module scope, so even importing it is fatal. The nub build aliases it to src/nub/runtime-plugin-support-noop.ts; opencode's source is untouched and the Bun build keeps the real thing.

Consequence: the TUI runs, and third-party TUI plugins that import the Solid/OpenTUI runtime do not get the host's instances. Every built-in plugin is bundled and unaffected.

Node flags

A compiled binary already runs Node with --experimental-ffi (nub injects it on 26.1+, with --disable-warning=ExperimentalWarning), so node:ffi needs nothing from this build — verified by printing process.execArgv from a binary compiled with no --node-options. @opentui/core's own node:ffi backend gets the flag the same way.

--node-options is the compile.execArgv equivalent for anything else. --use-system-ca, which the Bun build bakes in, is not passed yet; Node has the flag since v23.8.

Running the test suite on Node

The suite is written against bun:test. packages/nub-test runs it on stock Node instead, and bun test is gone — every package's test script is a node --test invocation, and the shim's bun export condition, which had kept Bun's runner working as a control, went with it. nub run test:all runs every package in series and tallies.

Every package, on one machine, measured the same way on both sides: Bun at the commit this branch starts from, running each package's own pre-migration test script; Node at this branch's head, through nub run test:all.

package Bun Node
opencode 3184 / 5 / 17 skipped 3099 / 58 / 17 skipped
core 1080 / 0 1066 / 8
app (test:unit) 693 / 1 684 / 2
llm 298 / 0 / 30 skipped 298 / 0 / 30 skipped
codemode 263 / 0 263 / 0
tui 168 / 5 / 1 skipped 188 / 2 / 1 skipped
session-ui 76 / 0 76 / 0
desktop 70 / 1 70 / 1
httpapi-codegen 66 / 0 65 / 1
app (test:browser) 41 / 0 41 / 0
http-recorder 33 / 0 33 / 0
enterprise 1 / 17 0 / 17
client 15 / 1 15 / 1
schema 13 / 2 13 / 2
console-core 14 / 0 14 / 0
ui 9 / 0 9 / 0
console-app 5 / 2 5 / 2
effect-drizzle-sqlite 7 / 0 7 / 0
stats-core 7 / 0 7 / 0
sdk-next 1 / 4 1 / 4
cli 3 / 0 3 / 0
protocol 2 / 0 2 / 0
sdk 1 / 0 1 / 0
total 6050 / 38 / 48 skipped 5960 / 98 / 48 skipped

Read the Bun column as the target rather than as a pass mark: 38 of its own tests fail, and cli, enterprise, protocol and stats-core had no test script at all before this branch, so their files were unrunnable rather than passing. tui is the one package ahead of Bun.

Seventeen of the twenty-three rows match Bun exactly and one is ahead of it. The gap is concentrated in opencode and core:

  • opencode is a long tail, not one cause. The remaining failures are spread across the MCP, LSP, provider, subprocess and tool suites: a handful of CLI-subprocess runs that exit 1, three recorded-interaction replays that consume fewer interactions than they recorded, and singleton assertion mismatches. No single fix moves more than a few.
  • packages/enterprise fails on both runners — Bun 1/17, Node 0/17 — and had no test script at all before this branch.
  • core's residue is eight tests in five files: a migration guard, a filesystem read, three .npmrc registry cases, one npm reify, and two file-lock tests that turn on process contention.
  • import-boundaries.test.ts in packages/client and packages/sdk-next spawns [process.execPath, "build", …], which is bun build under Bun and node build here. Both packages fail the same number of tests on both runners, so the totals line up, but under Node those failures are the spawn rather than the boundary the test is about.
  • A partial mock.module factory, described above, accounts for one of packages/app's two.
  • packages/tui is the one package ahead of Bun. Its three failures are ERR_WORKER_INVALID_EXEC_ARGV: node --test hands a child a long execArgv, and a new Worker(…) that inherits it is rejected. Bun's runner passes nothing comparable.

Three flags every script carries:

  • --test-force-exit. Several suites finish every test and then sit forever on a handle nothing closes; bun exits regardless. It is post-run rather than a hang — core/test/npm.test.ts prints all three of its suite summaries and then never returns.
  • --test-timeout=30000. bun's own default is a 5-second per-test timeout, so the Bun numbers above were already bounded. Node's default is Infinity, which turns one wedged test into a suite that never returns.
  • --test-reporter=dot, the nearest thing to bun's --only-failures.

bun test also reads bunfig.toml, and three packages declare a [test] preload there. packages/app's happydom.ts becomes a second --import; packages/core and packages/opencode each preload a test/preload.ts and get the same treatment. Those two matter more than they look: both set OPENCODE_DB=":memory:", without which Database.path() falls through to ~/.local/share/opencode/opencode-<channel>.db and every test in the process shares one on-disk database with every test before it. packages/opencode's also redirects the XDG directories at a per-pid temporary directory, points OPENCODE_MODELS_PATH at a fixture, clears the provider API-key variables and calls initProjectors().

packages/tui and packages/cli preload @opentui/solid/preload, which is the one bunfig entry with no Node equivalent: under the node export condition it resolves to a module whose entire body throws is Bun-only. Its Bun arm installs the Solid transform plugin, which is what NUB_TEST_SOLID already does in the resolver hooks, so neither script imports it.

tui additionally stages the OpenTUI assets first and points OTUI_ASSET_ROOT at them; without that, OpenTUI resolves its native library by importing @opentui/core-<platform>-<arch>, whose exports map declares no main, and every renderer test dies with No "exports" main defined. The compiled binary solves the same problem the same way. packages/app splits into two runs because its halves need different export conditions — --conditions=solid for src, --conditions=browser for test-browser — and happy-dom moves from bun's --preload to a second --import.

What the shim has to bridge, beyond renaming beforeAll to before:

  • Extensionless and NodeNext imports. Bun resolves ./agent to ./agent.ts, and ./plugin.js to plugin.ts. Node does neither.
  • tsconfig paths. Six packages alias @/* or ~/* to their own src. Bun applies the map; Node has no notion of one and reports @/config as a missing package, since it is a well-formed scoped name. This was the single largest gap — most of packages/opencode and packages/app could not load a file without it.
  • Vite's import.meta.env, which Bun provides as an alias for process.env, and .css side-effect imports.
  • Non-erasable TypeScript. Node strips types but refuses parameter properties and enums, so the load hook compiles with esbuild.
  • Symlink canonicalisation. Node keys its module cache on the resolved URL, so two spellings of one file are two modules. Under the workspace store one driver was instantiated 252 times and the resolver never terminated.
  • SolidJS JSX, a compile-to-renderer-ops pass rather than a jsx() factory rewrite — no esbuild setting produces it. Two renderers need two outputs, so NUB_TEST_SOLID names one: universal for OpenTUI, dom for the browser. Only dom compiles node_modules, because a DOM Solid library ships JSX source under the solid export condition precisely so its consumer compiles it — under --conditions=solid, @kobalte/core, @solidjs/router and solid-sonner all arrive as .jsx.
  • solid-js resolving to its SSR build, for the root package and for the store and web subpaths alike. Both runtimes resolve it identically; OpenTUI's Bun plugin swaps in the client build, and the shim redirects to it in resolve so app code and the renderer share one module instance.
  • mock.module resolution. node:test resolves a bare specifier against the immediate caller, which through the shim's wrapper is the shim itself — so mocking @opentui/core from a tui test failed naming a dependency of packages/nub-test. The shim resolves first and hands node an absolute URL. That has to go through import.meta.resolve, since the registered hooks apply to it and createRequire would return solid-js's CJS entry and mock a second copy — a silent no-op rather than an error.
  • Bun's own APIs — the Bun global, bun:sqlite, the $ shell, with { type: "file" } assets, and snapshots read from Bun's committed .snap files.
  • @opentui/solid/runtime-plugin-support/configure, whose node arm is a thrown error rather than an implementation, so importing it killed seven files outright. The resolver aliases it to a no-op that returns false — the same answer script/build-nub.mjs arranges for the compiled binary.
  • spyOn on a module namespace. export * as Npm from "./npm" yields an object that is non-extensible with non-configurable properties, so defineProperty fails where Bun relaxes the rule for mocking. NUB_TEST_SPYABLE names the modules whose re-export the load hook rewrites into a Proxy over an ordinary object: reads fall through to the namespace, writes shadow it. It is opt-in and named rather than universal because 389 modules here use that idiom and exactly two are ever spied on.

A member that is present but unimplemented throws, because a silently missing API turns a real assertion into a passing no-op — the one failure mode a migration like this must not have. That covers what is in the shim, not everything Bun has: the polyfill is an ordinary object, so a member nobody added reads as undefined.

One difference the shim does not bridge, and deliberately: a PARTIAL mock.module factory. Bun replaces the module and leaves a name the factory omitted as undefined; Node builds a synthetic module with exactly the factory's keys, so a third module that statically imports the omitted name fails to link. Filling the gaps would mean discovering the real module's export list synchronously, which mock.module has no way to do — and guessing it wrong would mock the wrong thing silently. One test file relies on the permissive reading.

The dependencies this added

expect@29.7.0 and pretty-format@29.7.0, both Jest's, as dependencies of the shim. pretty-format is expect's own transitive dependency at the same version, so naming it adds nothing to the install; it is what renders an object snapshot, and Bun writes Jest's snapshot format verbatim. Everything else the shim needs was already in the tree.

It is there because of what the suite asserts with, counted across all test files: toEqual 3404 times, toMatchObject 678, and the asymmetric matchers expect.objectContaining 126, expect.any 37, expect.stringContaining 23, expect.arrayContaining 18, expect.stringMatching 8. A hand-written deep-equality engine that is subtly wrong does not fail — it passes, on both sides of a comparison it should have rejected, which is the failure mode this whole migration exists to avoid. node:assert has no asymmetric-matcher equivalent to build on.

The Bun-only matchers Jest lacks (toBeTrue, toStartWith, toBeFunction, and their siblings) are ~30 lines of expect.extend in the shim rather than another package.

What still touches Bun

Counted against the merge of nub-compile and dev:

before after
files importing bun:test 396 0
package.json scripts invoking bun or bunx 63 24
packages depending on @types/bun 28 20
packages depending on @tsconfig/bun 17 0
other bun:* imports 3 3

What is left, and why each stays:

  • The Bun global, through packages/opencode/src/nub/bun-compat.ts. 32 call sites in the compiled graph outside the shim itself, 25 of them Bun.stringWidth. Rewriting each to a direct import would churn upstream source for no behavioural gain — the polyfill is the sanctioned mechanism.
  • sqlite.bun.ts, pty.bun.ts, fff.bun.ts — the bun arms of opencode's own conditional exports. The nub build selects the node siblings; upstream keeps both.
  • 24 package.json scripts, and they are the ones whose body calls a Bun API: Bun.build in the per-package build scripts, Bun.spawn / Bun.Glob in the repo tooling. Those ARE the Bun build path, which script/build-nub.mjs replaces rather than reimplements, so pointing them at nub would break them for nothing. bun sst shell stays too, since it wraps a second command in an SST environment.
  • @types/bun in 20 packages that still name Bun somewhere. One of those names is type-only: packages/opencode/src/session/message-v2.ts imports type { SystemError } from "bun", which is erased at build time and reaches no runtime.
  • 17 test files importing { $ } from "bun" for the shell API. Those run on Node: the shim's resolve hook points the bun specifier at its own bun-module.ts.
  • bun.lock and packageManager: bun@1.3.14. nub install installs from the tree as it stands and leaves bun.lock byte-unchanged, writing no lockfile of its own, so the two package managers share the one file.