feat(build): add experimental Bun standalone executable by drawliin · Pull Request #1959 · vltpkg/vltpkg · GitHub
Skip to content

feat(build): add experimental Bun standalone executable - #1959

Open
drawliin wants to merge 1 commit into
vltpkg:mainfrom
drawliin:feat/bun-standalone
Open

drawliin wants to merge 1 commit into
vltpkg:mainfrom
drawliin:feat/bun-standalone

Conversation

@drawliin

@drawliin drawliin commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Fix #1878
Part of #1522

Adds an experimental Bun 1.4+ build that embeds the CLI bundle, assets, and background helpers in one executable.. also fixes bundled WASM copying and resolution for PNG rendering.

Build from infra/build:

node src/compile.ts ../../.build-bun/vlt

Validation

  • 180 assertions pass; affected modules have 100% coverage.
  • Formatting, lint, and type checks pass.
  • macOS ARM64: installs, queries, scripts, PNG rendering, and helpers work without Node or Bun on PATH.
  • Linux x64 and Windows x64 cross-compilation succeeds; runtime testing remains.

Platform-specific packaging and installation/fallback behavior are follow-up work under #1522.

Summary by CodeRabbit

  • New Features
    • Added an experimental workflow for building a standalone executable that bundles the app and its helper scripts. Additional build arguments can be passed through.
  • Documentation
    • Documented requirements and platform testing considerations for standalone builds.
  • Bug Fixes
    • Improved Mermaid PNG rendering to locate its WASM asset when running from a standalone executable.

Embed the existing CLI bundle and assets, dispatch background helpers through the binary, and resolve bundled WASM by relative path.

Refs vltpkg#1878
@vercel

vercel Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

@coderabbitai

coderabbitai Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 9fea4961-bec5-45d5-ad69-b0a0aa3de1df
📥 Commits

Reviewing files that changed from the base of the PR and between 9a8805f and 3e203cb.

📒 Files selected for processing (6)
  • infra/build/README.md
  • infra/build/src/bundle.ts
  • infra/build/src/compile.ts
  • infra/build/test/compile.ts
  • src/cli-sdk/src/render-mermaid.ts
  • src/cli-sdk/test/render-mermaid.ts

Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 3 remain after this review.


📝 Walkthrough

Walkthrough

The build tooling adds an experimental Bun workflow that compiles the CLI and helper scripts into a standalone executable. The bundle reports code-split script paths and resolves the Resvg WASM asset. Mermaid rendering accepts an environment-provided WASM path.

Changes

Standalone Bun executable

Layer / File(s) Summary
Bundle outputs and Resvg WASM path
infra/build/src/bundle.ts, src/cli-sdk/src/render-mermaid.ts, src/cli-sdk/test/render-mermaid.ts
The bundle result now includes unique code-split script paths. The build resolves the Resvg WASM asset directly, and renderMermaidToPng uses __VLT_INTERNAL_RESVG_WASM when set, with the package path as fallback. The renderer test sets the environment variable before a PNG render.
Compile and document the executable
infra/build/src/compile.ts, infra/build/test/compile.ts, infra/build/README.md
The compile script stages a launcher and helper scripts, then invokes bun build --compile with the staged payload and forwarded arguments. It removes the temporary directory after success or failure. Tests cover successful compilation and bundle or compiler failures. The README documents the experimental workflow and its runtime requirements.

Priority: ⬇️ Low

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant CompileScript as compile.ts
  participant BundleFunction as bundle()
  participant BunCompiler as bun build --compile
  CompileScript->>BundleFunction: Bundle vlt and helper scripts
  BundleFunction-->>CompileScript: Return outdir and scripts
  CompileScript->>BunCompiler: Pass staged payload and forwarded arguments
  BunCompiler-->>CompileScript: Produce executable
Loading

Merge Risk: ⚪ Minimal · up to 3e203

The experimental executable is mergeable after normal checks; runtime behavior on cross-compiled targets remains untested.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: adding an experimental Bun standalone executable build.
Linked Issues check ✅ Passed Issue [#1878] requests an exploration of shipping the CLI as one Bun executable. infra/build/src/compile.ts bundles the vlt CLI and helper scripts, embeds the payload with bun build --compile, a…
Out of Scope Changes check ✅ Passed The changed files support the Bun standalone build requested by [#1878]. The compiler tests validate the new build workflow. The Resvg WASM path and asset-copy changes support PNG rendering in the sta…
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 5…
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Warning

Some tools did not complete. Review the errors below.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed: dependency version conflict. Check your lock file or package.json.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

🐰 Bencher Report

Projectvltpkg
Branchfeat/bun-standalone
Testbedubuntu-latest

🚨 1 Alert

BenchmarkMeasure
Units
ViewBenchmark Result
(Result Δ%)
Upper Boundary
(Limit %)
vlt install: vue & lockfileLatency
seconds (s)
📈 plot
🚷 threshold
🚨 alert (🔔)
6.58 s
(+26.92%)Baseline: 5.19 s
6.48 s
(101.54%)

Click to view all benchmark results
BenchmarkLatencyBenchmark Result
milliseconds (ms)
(Result Δ%)
Upper Boundary
milliseconds (ms)
(Limit %)
vlt install: vue & cache-lockfile📈 view plot
🚷 view threshold
783.24 ms
(+5.93%)Baseline: 739.38 ms
924.23 ms
(84.75%)
vlt install: vue & cache-lockfile-node-modules📈 view plot
🚷 view threshold
396.63 ms
(+12.59%)Baseline: 352.29 ms
440.36 ms
(90.07%)
vlt install: vue & clean📈 view plot
🚷 view threshold
15,108.61 ms
(-1.76%)Baseline: 15,379.89 ms
19,224.86 ms
(78.59%)
vlt install: vue & lockfile📈 view plot
🚷 view threshold
🚨 view alert (🔔)
6,583.24 ms
(+26.92%)Baseline: 5,186.86 ms
6,483.57 ms
(101.54%)

🐰 View full continuous benchmarking report in Bencher

@ruyadorno

Copy link
Copy Markdown
Member

sweet! thanks for the contribution @drawliin! we'll run some tests on our end, this is def exciting!

@lukekarrys mentioned and I believe it's a good idea, to potentially publish this first as a @vltpkg/bun-bin so that we could test it more (and have external folks trying it) ahead of adopting the single binary distribution, so bear with me but we may take a little more time before landing this one.

@drawliin

drawliin commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @ruyadorno !!

i have few ideas for further improvements: package this as @vltpkg/bun-bin, add smoke tests for the compiled CLI and background processes, verify it works on Linux/Windows, and explore platform detection and installation/fallback behavior under #1522

Let me know if you want me to tackle any of those while you’re testing internally.. i can split them into separate PRs

@ruy-bot

ruy-bot Bot commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

Perf review: Bun single-file executable vs the Node-driven JS bundle

A/B of this PR's bun build --compile artifact against the regular Node + JS bundle,
both built from 3e203cb (= v1.3.7 + this PR's 6 files), on one quiet box:
linux-x64, 4× Xeon @2.80 GHz, 15 GB RAM, kernel 6.12.107, Node 24.21.0, Bun 1.4.2,
hyperfine 1.19. Install numbers come from the vltpkg/benchmarks harness (0625fbf)
with BENCH_INCLUDE=vlt and VLT_REGISTRIES=npm=https://registry.npmjs.org/.

Four arms, so the runtime switch can be separated from the packaging switch:

arm what it is
node-pub node .build/vlt.js with the exact bundle settings vlt-build-prepack publishes (minify:false, sourcemap:false, code-split) — today's shipped artifact
node-min same, but the minified/no-sourcemap bundle the SEA embeds — isolates minification
bun-js bun .build/vlt.js on the same minified bundle, read from disk — isolates the runtime
bun-sea this PR: node src/compile.ts, bundle + assets + helpers embedded in one 88.6 MB executable

Both Node arms get a warm NODE_COMPILE_CACHE outside the directory the harness wipes,
i.e. the Node side is measured at its best; Bun has no equivalent cache.

Verdict: the executable is functional on linux-x64 and faster than the Node bundle in
all 24 fixture × variation install cells (in both rounds) and in every start-up
measurement — but it loses badly on a cold page cache, and three Bun runtime behaviours
(one of them silent output truncation) need fixing before it could be the artifact users
get.

node + published bundle bun SEA (this PR) Δ
vlt --version, warm 100.8 ms 73.9 ms −26.7 %
vlt --version, cold node compile cache 129.9 ms 74.1 ms −43.0 %
vlt --version, cold page cache 128.1 ms 250.1 ms +95.2 % ⚠
no-op install (4 fixtures) 209–376 ms 162–279 ms −22…−26 %
cache install 690–2227 ms 603–1921 ms −11…−14 %
clean install (cold, network) 5477–10543 ms 4866–9064 ms −11…−14 %
peak RSS, large cache+lockfile install 185 MB 132 MB −29 %
artifact on disk / gzipped 12.5 MB / 2.9 MB 88.6 MB / 39.0 MB +7×/+13×

Start-up and small commands (warm, 40 runs each; median (mean ± σ) ms)

command node + published bundle node + minified bun + bundle on disk bun SEA (this PR) Δ SEA vs node
vlt --version 100.8 (101.4 ± 4.4) 102.6 (102.7 ± 4.3) 75.7 (76.1 ± 1.7) 73.9 (73.8 ± 1.1) −26.7 %
vlt --help 107.7 (108.8 ± 4.0) 107.2 (107.0 ± 5.0) 79.3 (79.6 ± 1.5) 79.1 (79.6 ± 1.8) −26.6 %
vlt pkg get name 131.0 (131.7 ± 3.9) 123.1 (124.9 ± 5.3) 97.3 (98.8 ± 5.4) 97.5 (98.1 ± 2.1) −25.6 %
vlt query ':root > *' --view=json (next fixture: 356-package graph, 11 results) 730.7 (735.5 ± 21.5) 723.4 (730.7 ± 20.2) 518.0 (518.6 ± 14.6) 533.1 (535.4 ± 10.8) −27.0 %

Cold module compile cache — a fresh CI container, or the first run after an upgrade.
Node's compile cache is wiped before every run; Bun has none to wipe, so it is unaffected:
node-pub 129.9 ms, node-min 122.1 ms, bun-sea 74.1 ms → −43.0 %.

Node version is not the story: node 24.21 99.9 ms, node 26.11 97.2 ms,
bun SEA 75.8 ms (still −22 % against the newer Node).

Where that start-up win actually comes from — an empty program on each runtime:

node 24 node 26 bun bun SEA
empty program 32.3 ms 32.8 ms 3.9 ms 3.8 ms

Subtracting the floor from vlt --version leaves 68 ms of CLI module init on Node and
70 ms on Bun. So for a trivial command the entire −27 % is the interpreter boot, not
faster CLI code; lazier module init is still the bigger lever and it would pay off on
both runtimes. Once there is real work to do the runtime does start to matter on its own
merits: subtracting the floor, query --view=json is 698 ms on Node vs 529 ms on Bun
(−24 % on the work itself), which is the same order as the install deltas below.

The detached helpers re-exec the whole 88.6 MB executable rather than running a 260 KB
script, and that is cheaper, not dearer: cache-unzip with nothing to do takes
56.8 ms under node vs 41.5 ms as a SEA re-exec (−27 %).

Installs — 96 cells, 4 fixtures × 6 variations × 2 rounds, 0 failures

Δ is the SEA against node-pub on pooled medians; ✓ marks the 22 of 24 cells where both
rounds moved the same way and |Δ| ≥ max(2 %, 2·SE). The two without a ✓ (cache+lockfile
on next and large) still favour the SEA in both rounds — they just carry single-run
registry/IO outliers large enough to widen the error bar.

variation next vue astro large
no-op (cache+lockfile+node_modules) −22.9 % ✓ −22.8 % ✓ −22.4 % ✓ −25.8 % ✓
cache+lockfile −6.1 % −9.4 % ✓ −8.2 % ✓ −5.9 %
cache −14.1 % ✓ −11.1 % ✓ −12.5 % ✓ −13.8 % ✓
ci −6.7 % ✓ −9.9 % ✓ −9.2 % ✓ −5.1 % ✓
lockfile −9.7 % ✓ −11.6 % ✓ −12.2 % ✓ −7.6 % ✓
clean −10.9 % ✓ −11.4 % ✓ −11.2 % ✓ −14.0 % ✓

Absolute medians for the two ends of that range:

cell node bun SEA Δ ms
large no-op 376 ms 279 ms −97
large cache 2227 ms 1921 ms −307
large clean 10 543 ms 9064 ms −1480
next clean 8018 ms 7143 ms −875

The shape is informative: the gain is biggest where the install is dominated by our own
JS
(no-op: lockfile parse, graph load, node_modules verification → −22…−26 %) and
smallest where it is dominated by syscalls and the network (cache+lockfile reify,
ci → −5…−10 %). Every fixture produced a byte-identical vlt-lock.json and the same
node_modules file count on both arms, and all 96 cells exited 0.

The one real regression: cold page cache

posix_fadvise(DONTNEED) on each arm's image before every run (verified: a full read of
the node binary goes 26 ms warm → 329 ms cold, the SEA 22 ms → 450 ms):

vlt --version, cold page cache node + bundle bun SEA
median 128.1 ms 250.1 ms (mean 263.2 ± 41.7)

The SEA is ~2× slower on its first run after an install, a reboot, or in a container
whose layer cache has just been pulled. The mechanism is that it touches most of its image:
re-reading the binary straight after one --version run costs 192 ms versus 450 ms fully
cold, i.e. roughly 55–60 % of the 88.6 MB got paged in — whereas one node --version
leaves the 126 MB node binary essentially untouched (322 ms vs 329 ms cold).

Not much of that size is ours: an empty bun build --compile output is already
81.3 MB, so the Bun runtime is 92 % of the artifact and vlt's payload is the other
7.3 MB. Shrinking our bundle will not move this; it is a property of Bun SEAs.

artifact on disk gzip -9
published-style bundle dir (what npm i -g vlt ships today) 12.5 MB 2.9 MB
minified bundle dir 7.3 MB –
bun SEA 88.6 MB 39.0 MB
(for reference: the node 24 binary a user already has) 126.6 MB –

So a user who already has Node downloads 2.9 MB today and would download 39 MB per
platform instead — and #1522's platform-specific packaging multiplies that across targets.

Memory

Peak RSS is consistently lower, which is the nicer half of the runtime swap:

node bun SEA Δ
vlt --version 67–68 MB 44 MB −35 %
vlt pkg get name 73–74 MB 49 MB −34 %
vlt query ':root > *' 168–169 MB 135–139 MB −18 %
large cache+lockfile install 183–187 MB 131–136 MB −29 %

Distribution claim checks out

With PATH=/nonexistent and a scrubbed environment the SEA still runs and completes a
cache+lockfile install of the large fixture (rc=0, 18 368 files) — no Node, no Bun, no
coreutils needed. One visible consequence: lifecycle scripts see
npm_config_user_agent=vlt/1.3.7 Bun/1.4.2 instead of vlt/1.3.7 Node.js/24
(@vltpkg/user-agent already handles this deliberately; flagging it only because any
package whose install script sniffs that string will now see Bun).

Three runtime-behaviour differences found while validating linux-x64

The PR notes Linux x64 "runtime testing remains", so I ran the functional pass first:
18/18 checks pass on linux-x64 for both arms — clean install, no-op, cache+lockfile,
query/ls in json/human/mermaid/svg/png (the resvg-wasm fix works), run/exec,
non-zero exit propagation, detached helpers. vlt-lock.json is byte-identical between
arms and node_modules has the same 18 368 files. So the executable is functional.

Three differences are worth fixing before this becomes a shipped artifact.

1. stdout is silently truncated at the pipe buffer (data loss, exit code 0).
cli-sdk/src/output.ts guards the drain on stream.writableLength:

export const flushStream = stream =>
  new Promise(res => {
    if (!stream.writableLength) return res()   // <- always true under Bun
    stream.write('', () => res())
  })

Under Bun process.stdout.writableLength is 0 even immediately after a 200 KB write,
so flushStream resolves at once and process.exit() discards everything still queued —
exactly the failure the comment above flushAndExit was written to prevent:

$ vlt query ':root > *' --view=json | wc -c
74887     # node-pub
65536     # bun-sea   <- cut at the 64 KiB pipe buffer, rc=0
$ vlt query ':root > *' --view=json > out.json   # redirect to a file: 74876, fine

Minimal repro — write 200 000 bytes to a pipe, then exit, and count what arrives
($ prog | wc -c):

                                      node      bun
  plain process.exit()               131072    131072   <- why flushStream exists
  writableLength-guarded flush       200000    131072   <- today's code
  unguarded stream.write('', cb)     200000    131072
  stream.end(cb)                     200000    200000
  process.exitCode = code            200000    200000   <- simplest fix

So flushAndExit wants to set process.exitCode and return (or stream.end(cb)),
rather than call process.exit() after a writableLength-guarded drain. vlt … | jq
and any CI job that captures output over 64 KiB loses the tail today.

2. ANSI escapes leak onto a non-TTY stderr. vlt writes stderr via console.error,
which Bun colours unconditionally; Node defers to supports-color:

$ vlt not-a-command 2> err.txt ; wc -c err.txt
448 err.txt   # node-pub, 0 escape sequences
487 err.txt   # bun-sea, 4 lines wrapped in ESC[0m ESC[31m … ESC[0m

3. process.title is a no-op in Bun, so the background helpers lose their names.
The four code-split helpers set process.title = 'vlt-cache-unzip' etc. Under the SEA
the child shows up as the executable:

node-pub:  3626563 vlt-cache-unzip  vlt-cache-unzip
bun-sea:   3626034 vlt-bun          …/raw/vlt-bun /$bunfs/root/payload/cache-unzip-src-unzip.js …

Anything that keys on the title breaks — including vltpkg/benchmarks'
wait_vlt_children, which greps ^vlt-(cache|security)-. Benchmarking the SEA with the
stock helper leaks a detached unzip child into the next timed run and into
clean_vlt_cache's cache removal. I had to widen the pattern to match the helper
script path instead to get trustworthy install numbers:

-  while pgrep -u "$(id -u)" -f '^vlt-(cache|security)-|^[^ ]*node [^ ]*/(cache-unzip-src-unzip|registry-client-src-revalidate)\.js( |$)'
+  while pgrep -u "$(id -u)" -f '^vlt-(cache|security)-|/(cache-unzip-src-unzip|registry-client-src-revalidate|rollback-remove-src-remove|security-archive-src-update-expired)\.js( |$)'

Where the win comes from, and what the build leaves on the table

bun-js ≈ bun-sea on every command (within 3 %), and node-pub ≈ node-min.
So the gain is the Bun runtime, not the single-file packaging, and not minification.
Packaging it as one executable is a distribution win (no Node needed — confirmed above),
and once the image is in the page cache it costs nothing measurable at runtime. That is a
good result for the PR; the cold-page-cache penalty above is the price of the packaging,
not of the runtime.

It does, however, leave the one big Bun startup lever unused. compile.ts embeds the
bundle as an --asset directory and reaches it with a runtime import() of a
/$bunfs/… file URL, so the CLI's 4.75 MB of JS (98 chunks) is outside the compiled
module graph:
Bun re-parses it from source on every run, with no bytecode and no compile cache (Node at
least has module.enableCompileCache()). Passing --bytecode to try to fix that fails
outright today, because the generated launcher is ESM with top-level await:

$ node src/compile.ts out --bytecode
error: "await" can only be used inside an "async" function
   at /tmp/vlt-compile-…/launcher.js:8:9     (and :10:9)

A CJS launcher would compile, but would not help on its own: bytecode only covers the
entry graph, and the payload is an asset. Getting it would mean making payload/vlt.js
the compile entry (and giving the helper dispatch another way to resolve
import.meta.dirname). Worth filing against #1522 rather than blocking here.

Two smaller build notes:

  • compile.ts hard-codes minify: true, but minification is worth ~0 at runtime on both
    runtimes (node-pub vs node-min, and bun-js runs the minified bundle). It only
    costs you readable stack traces in the one artifact users cannot easily re-run under a
    debugger. Consider dropping it, or pairing it with sourcemap.
  • Everything here is the native linux-x64 build (node src/compile.ts out, no
    --target), so the PR's open "Linux x64 … runtime testing remains" item is covered for
    that case. Windows and the cross-compiled targets are still untested.
Method, and what I would not read too much into
  • Arms built from the PR head 3e203cb9 in a detached worktree; node-pub reproduces
    vlt-build-prepack's settings exactly (bundle({ sourcemap: false }), minify default
    false), node-min/bun-sea use compile.ts's (minify: true, sourcemap: false).
  • Install matrix: vltpkg/benchmarks @0625fbf, BENCH_INCLUDE=vlt, one flock'd harness
    run at a time, loop round → variation → fixture → arm with the arm order reversed on
    round 2, so arm order cannot alias with drift. Headline variations get 10 runs/round
    (1 warmup), lockfile/clean 5 (3 on large). Medians are pooled over both rounds;
    Δ mean ± SE is the propagated relative standard error. A ✓ means both rounds moved the
    same way and |Δ| ≥ max(2 %, 2·SE). babylon was skipped (too slow for the box).
  • clean-helpers.sh's wait_vlt_children was widened as shown above; without it the SEA's
    detached unzip child is invisible to the harness.
  • Both Node arms keep a warm NODE_COMPILE_CACHE in a directory clean_all does not wipe.
    Note this is not what the public benchmark measures: there the CLI's own compile cache
    lives under ~/.cache/vlt, which clean_all deletes, so published vlt numbers include
    a cold compile every run, so the cold-compile-cache row above is the right one to
    compare against a CI container.
  • Caveats: single 4-CPU box, so install deltas are CPU-bound and will compress on bigger
    machines; clean/lockfile go to registry.npmjs.org and carry network noise; Node is
    24.21.0 (26.11.0 cross-checked on startup, same ratio); nothing here says anything about
    macOS or Windows.
  • Raw data, driver, summarizer and per-cell logs: bench5/results/matrix-2026-10-09/
    (96 cells) and bench5/results/{micro,post}/ on the benchmarking box.

This branch was successfully deployed

1 active deployment
Preview — 3e203cb9 Deployed Oct 7, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Explore compiling a single executable binary using Bun

2 participants