diff --git a/docs-site/.vitepress/config.ts b/docs-site/.vitepress/config.ts index 8187ea8a..805151ff 100644 --- a/docs-site/.vitepress/config.ts +++ b/docs-site/.vitepress/config.ts @@ -77,6 +77,7 @@ export default withMermaid({ { text: 'What is pkg?', link: '/guide/' }, { text: 'Getting started', link: '/guide/getting-started' }, { text: 'SEA vs Standard', link: '/guide/sea-vs-standard' }, + { text: 'pkg vs Bun vs Deno', link: '/guide/vs-bun-deno' }, { text: 'Migration from vercel/pkg', link: '/guide/migration' }, ], }, diff --git a/docs-site/guide/vs-bun-deno.md b/docs-site/guide/vs-bun-deno.md new file mode 100644 index 00000000..e741abda --- /dev/null +++ b/docs-site/guide/vs-bun-deno.md @@ -0,0 +1,242 @@ +--- +title: pkg vs Bun vs Deno +description: In-depth comparison of @yao-pkg/pkg, Bun's single-file executable, and deno compile — native addons, VFS, cross-compile, bytecode, binary size, and a real Claude Code case study. +--- + +# pkg vs Bun vs Deno + +Three tools can turn JavaScript into a standalone executable today: `@yao-pkg/pkg`, `bun build --compile`, and `deno compile`. They solve the same problem three different ways — stock Node + VFS, JavaScriptCore + embedded FS, V8 + eszip. This page lays out the trade-offs, then runs the same project through all three to show what actually happens. + +::: tip TL;DR + +- **Bun** is the fastest to build and produces the smallest binary, but it targets Node-API addons indirectly (`node-pre-gyp` packages need manual intervention) and runs on JavaScriptCore, not V8. +- **Deno** auto-detects `node_modules`, cross-compiles cleanly, and embeds assets via `--include`, but native addons need `--self-extracting` and a handful of Node built-ins are still stubbed. +- **pkg (SEA)** runs on **stock Node.js** — same V8, same npm, same addon ABI. It ships the largest binary but is the only one with a path to zero-patch, official Node.js support via [`node:vfs`](https://github.com/nodejs/node/pull/61478). +- **pkg (Standard)** is the only option that can strip source code to V8 bytecode. + ::: + +## Feature matrix + +| Dimension | `@yao-pkg/pkg` (Standard) | `@yao-pkg/pkg` (SEA) | `bun build --compile` | `deno compile` | +| ------------------------------------ | ---------------------------------------------------------------------- | ------------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| **Runtime** | Patched Node.js (V8) | **Stock Node.js** (V8) | Bun (JavaScriptCore) | Deno (V8) | +| **Bundle format** | Custom VFS, compressed stripes | Single archive + `@roberts_lando/vfs` | `$bunfs/` embedded FS | `eszip` in-memory VFS | +| **Cross-compile** | ⚠️ Node 22 regression, OK on 20/24 | ✅ all targets | ✅ all targets (icon/metadata local-only) | ✅ 5 targets, any host | +| **Targets** | linux/mac/win × x64/arm64 (+ `linux-arm64-musl` via build-from-source) | Same as Standard | linux/mac/win × x64/arm64 + **linux musl** + baseline/modern CPU variants | linux/mac/win × x64/arm64 (no musl, no win-arm64) | +| **Native `.node` addons** | ✅ extracted to `~/.cache/pkg//` | ✅ same cache strategy | ⚠️ direct `require("./x.node")` only — `node-pre-gyp` breaks bundling | ⚠️ requires `--self-extracting` for runtime `dlopen` | +| **Bytecode / ahead-of-time compile** | ✅ **V8 bytecode, source-strippable** | ❌ source in plaintext | ✅ `--bytecode` (JSC, CJS default, ESM since v1.3.9) | ❌ V8 code-cache only | +| **Asset embedding** | `pkg.assets` glob in `package.json` | `pkg.assets` glob | Extra entrypoints + `--asset-naming="[name].[ext]"` or `import ... with { type: "file" }` | `--include path/` | +| **npm compatibility** | Full (it _is_ Node) | Full (it _is_ Node) | High (bundler-hostile patterns break) | Partial — 22/44 built-ins fully, some stubbed (`node:cluster`, `node:sea`, `node:wasi`, …) | +| **ESM + top-level await** | ⚠️ ESM → CJS transform can fail on TLA + re-exports | ✅ native ESM | ✅ native ESM | ✅ native ESM (primary) | +| **Compression** | ✅ Brotli / GZip per stripe | ❌ | ❌ | ❌ | +| **Windows icon / metadata** | ✅ (via `rcedit` at build) | ✅ | ✅ `--windows-icon` + rich metadata _(disabled when cross-compiling)_ | ✅ `--icon` (cross-compile OK), no version/product fields | +| **Security updates** | Wait for `pkg-fetch` to rebase patches and republish | **Same day Node.js releases** | Tied to Bun release cadence | Tied to Deno release cadence | +| **License model** | MIT — runs stock V8 | MIT — runs stock V8 | MIT | MIT | + +## Architectural difference in one picture + +```mermaid +flowchart LR + subgraph pkg ["@yao-pkg/pkg --sea"] + A1[Stock Node.js binary] --> A2[SEA blob
archive + manifest] + A2 --> A3["@roberts_lando/vfs
polyfills fs + Module"] + end + + subgraph bun ["bun --compile"] + B1[Bun runtime
JavaScriptCore] --> B2["$bunfs/ embedded FS
bundled JS/TS + assets"] + end + + subgraph deno ["deno compile"] + D1[Deno runtime
V8] --> D2[eszip trailer
module graph + --include] + end +``` + +All three produce one binary, all three expose embedded files to `fs.readFileSync`, all three launch in under a second. What differs is **whose runtime you are shipping**. pkg ships the _same Node.js_ you tested against. Bun and Deno ship their own runtime with their own compat layer. + +## Native addon support — the real differentiator + +Almost all non-trivial Node projects load something through `dlopen`: `better-sqlite3`, `sharp`, `bcrypt`, `canvas`, Prisma's query engine, etc. How each tool handles this is the most common reason a migration stalls. + +| Scenario | pkg | Bun | Deno | +| ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| Direct `require("./addon.node")` | ✅ VFS extracts to `~/.cache/pkg//` on first load, SHA-verified, persists | ✅ documented | ✅ with `--self-extracting` | +| `node-pre-gyp` / `node-gyp-build` indirection (`sharp`, `better-sqlite3`, `bcrypt`) | ✅ walker detects + bundles | ⚠️ [docs](https://bun.sh/docs/bundler/executables#embedding-nodejs-addons) say "you'll need to make sure the `.node` file is directly required or it won't bundle correctly" | ⚠️ needs `--self-extracting` to land the `.node` on disk before `dlopen` | +| ABI compatibility | Exactly matches the Node version in the binary | JSC's N-API compatibility layer — works for most but not all addons | Node-API compat layer in Deno 2+ | + +For anything that uses `node-pre-gyp` today (which is a large fraction of native addons), Bun requires you to pin the specific `.node` file and `require` it directly. pkg's walker and Deno's extraction do this transparently. + +## Claude Code case study + +We took the last pure-JS release of `@anthropic-ai/claude-code` (v1.0.100) — an ESM-only, 9 MB minified single-file CLI that uses **top-level `await`**, loads `yoga.wasm` at startup via `require.resolve`, ships `ripgrep` as a vendored binary, and has `sharp` as an optional native dep. Perfect stress test. + +All numbers on Linux x64, Node 22.22.1, Bun 1.3.12, Deno 2.7.12: + +| Command | Outcome | Binary | First `--version` | +| -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | ----------------- | +| `pkg cli.js -t node22-linux-x64` (Standard) | ❌ `Failed to generate V8 bytecode` — top-level await + ESM re-exports block pkg's CJS transform. Warning suggests `--fallback-to-source` or `--sea` | — | — | +| `pkg cli.js --fallback-to-source -t node22-linux-x64` (Standard) | ❌ Source-as-fallback path still loads as CJS; `ERR_MODULE_NOT_FOUND` at runtime | 83 MB | — | +| `pkg . --sea -t node22-linux-x64` (SEA, no `pkg.assets`) | ❌ Runs, but `Cannot find module './yoga.wasm'` — walker can't see dynamic resolves | 134 MB | — | +| `pkg . --sea` with `"pkg": {"assets": ["yoga.wasm","vendor/**/*"]}` | ✅ | **194 MB** | **979 ms** | +| `bun build --compile cli.js` | ❌ Runtime: `Cannot find module './yoga.wasm' from '/$bunfs/root/cc-bun'` | 108 MB | — | +| `bun build --compile cli.js yoga.wasm --asset-naming="[name].[ext]"` | ✅ | **108 MB** | **797 ms** | +| `bun build --compile --bytecode --format=esm …` (+ asset flag) | ✅ | 190 MB | 828 ms | +| `bun build --compile --bytecode --minify …` | ❌ `Expected ";" but found ")"` — parser regression on the already-minified ESM | — | — | +| `deno compile --allow-all --include yoga.wasm cli.js` | ✅ auto-included the entire `node_modules/` | **184 MB** | **1256 ms** | + +### What this reveals + +- **Nothing in the matrix "just works."** Every tool needs a hint about `yoga.wasm` — the symptoms just differ. pkg silently succeeds with a broken binary if you forget `pkg.assets`; Bun silently succeeds but renames the asset unless you pass `--asset-naming`; Deno auto-hoovers `node_modules` (which is convenient but ballooned the binary to 184 MB). +- **Bun produced the smallest working binary (108 MB) and fastest cold start (797 ms)** — at the cost of running claude-code on JavaScriptCore, not the V8 it was tested against. +- **Bun's `--bytecode` nearly doubled the binary (190 MB) without improving startup** for this async-heavy CLI. The docs warn that bytecode output can be ~8× larger per module and skips async/generator/eval, which matches what we saw. +- **pkg Standard mode is the wrong choice for ESM-first apps with top-level await.** Use `--sea`. This is explicitly why SEA mode exists and is the [recommended default going forward](/guide/sea-vs-standard#when-to-pick-which). +- **Deno's startup penalty (~1.3 s vs ~0.8 s)** is explained by the permission system init + Node compat layer boot; the runtime still parses source at launch (no ahead-of-time V8 snapshot of user code). + +### Hello-world baseline for reference + +Same build machine, same Linux x64 target, trivial `console.log` script: + +| Tool | Binary | Best of 5 startup | +| --------------- | ------ | ----------------- | +| `pkg` Standard | 71 MB | 40 ms | +| `pkg` SEA | 119 MB | 60 ms | +| Bun `--compile` | 96 MB | 10 ms | +| Deno `compile` | 93 MB | 50 ms | + +## Cross-compilation + +All three can build Linux / macOS / Windows × x64 / arm64 from any host. The edges differ: + +- **Bun** supports the widest target list — including `bun-linux-x64-musl`, `bun-linux-arm64-musl`, and CPU-tier splits (`baseline` for pre-2013 chips, `modern` for Haswell+). Windows icon / version / metadata flags are [disabled when cross-compiling](https://bun.sh/docs/bundler/executables). +- **Deno** supports 5 targets (`x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu`, `x86_64-apple-darwin`, `aarch64-apple-darwin`, `x86_64-pc-windows-msvc`) — no musl target, no Windows arm64. +- **pkg** cross-compiles cleanly in SEA mode on Node 20 and Node 24. Node 22 Standard mode has a [known regression](/guide/targets#cross-compilation-support). + +## Bytecode / source protection + +The only tool that can **strip source code from the binary** is `@yao-pkg/pkg` in Standard mode. The fabricator compiles JS to V8 bytecode, stores it as `STORE_BLOB` with `sourceless: true`, and throws the source away — a byte dump of the executable contains bytecode only. See [Bytecode](/guide/bytecode). + +Everything else stores source: + +- **pkg SEA** stores JS source in the archive (SEA has no `sourceless` equivalent yet). +- **Bun `--bytecode`** emits `.jsc` alongside `.js` — the `.js` fallback stays because bytecode can't represent async/generator/eval bodies. +- **Deno** compiles at runtime; source is embedded in the eszip. + +If source protection is a hard requirement, pkg Standard is the only option. For everything else, obfuscate first and accept that "compiled" ≠ "secret." + +## When to pick which + +- **You need source protection** → `pkg` Standard (V8 bytecode with source stripping). +- **You're on stock Node.js and want security fixes the day Node releases them** → `pkg --sea`. +- **You want the smallest binary and fastest cold start, your stack is all-ESM, no `node-pre-gyp` addons, and JavaScriptCore's npm compat matrix covers your deps** → `bun build --compile`. +- **You're writing a Deno-first app that also uses some `npm:` specifiers** → `deno compile`. +- **You have `sharp` / `better-sqlite3` / `bcrypt` / anything via `node-pre-gyp`** → `pkg` (either mode) — no flag gymnastics, no first-run extraction tax. +- **You need musl / Alpine** → `pkg` (via [`linux-arm64-musl` build from source](/guide/targets)) or `bun` (`bun-linux-x64-musl`, `bun-linux-arm64-musl`). + +## Final verdict + +There is no single winner. Each tool owns a different lane. + +### Lane-by-lane + +- **Bun wins on raw numbers.** 108 MB binary, 797 ms cold start for claude-code, and the widest target list (musl + baseline/modern CPU variants). Best default for **greenfield CLIs with no `node-pre-gyp` native addons** when size and cold start are the KPIs. The trade is a runtime swap (JavaScriptCore instead of V8) and a handful of bundler-hostile footguns. + +- **Deno wins on DX for Deno-first apps.** `--include` and automatic `node_modules` detection made it the lowest-effort build in the matrix. Pick it if you already run `deno` day-to-day — not as a drop-in for an existing Node project. Startup is the slowest here (1.26 s for this workload) and a handful of Node built-ins are stubbed (`node:cluster`, `node:sea`, `node:wasi`). + +- **`pkg --sea` is the safest default for shipping a real Node.js app.** It runs the **stock Node binary you tested against** — same V8, same N-API ABI, same npm semantics. Native addons with `node-pre-gyp` (`sharp`, `better-sqlite3`, `bcrypt`) bundle transparently. The cost is the largest binary and no compression. When Node ships a CVE fix, your next build has it the same day. + +- **`pkg` Standard is the only answer to one specific question:** _can the shipped binary contain no recoverable source code?_ V8 bytecode with `sourceless: true` is the only mechanism any tool here offers. Nothing else is in the running if IP protection is a hard requirement. + +### If we had to name one default + +For a production Node.js app in 2026: **`pkg --sea`**. The reason isn't performance or size — Bun wins those. The reason is **you are not swapping runtimes**. You are wrapping the Node.js you already tested against. "Ships stock Node.js" is a boring feature, and boring is exactly what you want in production. + +## Be critical — what these claims don't prove + +This section is here because picking a bundler on marketing copy is how people ship surprises. Some of the claims above deserve scrutiny. + +### "V8 beats JavaScriptCore" + +We never make that claim, and you should not read it into "pkg ships V8." **JavaScriptCore is a peer-grade engine** — it ships in Safari, powers billions of devices, and routinely wins specific benchmarks against V8. Bun's own perf work shows JSC holding its own or ahead on many JS-heavy workloads. + +The real argument for V8 here is **not "faster," it's "identical to what you tested"**: + +- Every npm package was tested under V8/Node. Running it on JSC means trusting Bun's Node-API and built-in compat layers. Those layers are large, well-maintained, and broken in a small number of places you will only discover at runtime. +- Obscure V8-specific behaviors (stack trace formatting, `Error.prepareStackTrace`, `%Opt` natives, `--max-old-space-size` semantics, async-hook timings) sometimes leak into production code, especially in observability and framework internals. Those bugs surface on the engine swap, not before. + +This is a **compatibility risk axis**, not an engine-quality axis. On a greenfield codebase where you control every dep, the argument evaporates. + +### "Bytecode protects your source" + +Overstated. V8 bytecode is not encryption — it's a serialization format with public decompilers ([`bytenode`](https://github.com/bytenode/bytenode), `v8-decompile-bytecode`, several research tools). It raises the bar past "`strings(1)` and read the secret," but a determined reverse engineer will get readable JS back. Treat `pkg` Standard's source-stripping as a speed bump, not a vault. If you need real IP protection, you need obfuscation + licensing + server-side secrets — none of these tools solve that. + +### "pkg Standard ships stock Node.js" + +No — **only `pkg --sea` ships stock Node.js.** Standard mode ships a **patched** Node.js distributed through `pkg-fetch` (~600–850 lines of patches per release). That is precisely why SEA mode exists and why the project is moving toward it. Do not conflate the two. + +### "The claude-code numbers prove Bun is faster / smaller" + +n=1 on a single package on a single Linux x64 host. The conclusion scales narrowly: + +- The Bun number is flattering partly because claude-code's `cli.js` is already a single pre-bundled file — Bun's bundler has almost nothing to do. A project with a sprawling dep graph and lots of dynamic `require` will look different. +- The Deno 1.26 s cold start includes permission-system init and the Node compat boot; for a long-running process (server, daemon) it's amortized to zero. Cold start matters for CLIs, not for servers. +- pkg SEA's 194 MB is largely the uncompressed SEA archive plus the Node binary. Standard mode with Brotli would be smaller, but Standard couldn't build this package at all — so the comparison is apples-to-oranges. +- Bun's `--bytecode` doubled the binary _on this workload_ because it's async-heavy ESM with lots of generator / `eval` sites that fall back to shipping source alongside bytecode. A synchronous CJS-heavy app would see a different (smaller) delta. + +Treat the matrix as "here is what these tools actually do, and here is one realistic data point." Not as a benchmark. + +### "You get Node security fixes the day they ship" + +True for `pkg --sea` — it downloads the released Node binary from the official Node.js project. **False for `pkg` Standard** — you wait for `pkg-fetch` to rebase the patch stack. **Also false for Bun and Deno** — you wait for the Bun/Deno maintainers to pick up the V8 / JSC fix and cut a release. Bun and Deno generally move fast, but they are not on Node's release cadence. + +### "Bun and Deno can't bundle `sharp` / `better-sqlite3`" + +Softer than the matrix implies. Both can — you just have more steps. Bun wants the `.node` file required directly; Deno wants `--self-extracting` so the addon lands on disk before `dlopen`. For a handful of addons you control, both work. The advantage pkg holds is that the walker does this detection automatically for the long tail of `node-pre-gyp` packages without per-addon configuration. + +### TL;DR of the caveats + +These tools are closer than the headline table suggests. Pick on **what you'll regret later**, not on benchmark deltas: + +- Regret running on a non-V8 engine? → pkg +- Regret your binary being 80 MB heavier than it needed to be? → Bun +- Regret that new Node CVEs take a week to land? → pkg SEA +- Regret that your source is shippable with `strings(1)`? → pkg Standard (with obfuscation as belt-and-braces) + +## Reproducing the numbers + +```sh +# Install toolchain +npm install -g @yao-pkg/pkg +curl -fsSL https://bun.sh/install | bash +curl -fsSL https://github.com/denoland/deno/releases/latest/download/deno-x86_64-unknown-linux-gnu.zip \ + -o /tmp/deno.zip && unzip /tmp/deno.zip -d ~/.deno/bin/ + +# Test project +mkdir cc-test && cd cc-test && npm init -y +npm install --ignore-scripts @anthropic-ai/claude-code@1.0.100 + +# Build (pkg SEA — needs pkg.assets config; see Detecting assets) +node -e 'const p=require("./node_modules/@anthropic-ai/claude-code/package.json"); \ + p.pkg={assets:["yoga.wasm","vendor/**/*"]}; \ + require("fs").writeFileSync("node_modules/@anthropic-ai/claude-code/package.json", JSON.stringify(p,null,2))' +pkg ./node_modules/@anthropic-ai/claude-code --sea -t node22-linux-x64 -o cc-pkg + +# Build (bun) +bun build --compile --asset-naming="[name].[ext]" \ + node_modules/@anthropic-ai/claude-code/cli.js \ + node_modules/@anthropic-ai/claude-code/yoga.wasm \ + --outfile cc-bun + +# Build (deno) +deno compile --allow-all \ + --include node_modules/@anthropic-ai/claude-code/yoga.wasm \ + --output cc-deno \ + node_modules/@anthropic-ai/claude-code/cli.js +``` + +## Further reading + +- [pkg Architecture](/architecture) — Standard vs SEA internals +- [pkg SEA vs Standard](/guide/sea-vs-standard) — when to pick which pkg mode +- [Detecting assets](/guide/detecting-assets) — why the walker misses dynamic paths and how `pkg.assets` fixes it +- [Bun — Single-file executable](https://bun.sh/docs/bundler/executables) +- [Deno — `deno compile`](https://docs.deno.com/runtime/reference/cli/compile/) +- [`node:vfs` — the future of SEA asset embedding](https://github.com/nodejs/node/pull/61478)