Replies: 4 comments 2 replies
|
My first reaction was that this work should be done by the registry, but considering that the npm registry is basically no longer being maintained, I think it might also be a good choice to push this forward from the NAPI-RS side. |
|
@jdalton After some investigation, I've found no blocking issues on my end. Please create a PR so we can move this forward together 🚢 |
|
Awesome, thank you for digging into it @Brooooooklyn! Will do, asap. |
|
Great idea. If package managers could support this at their level, this would benefit the entire ecosystem, including all existing packages, rather than requiring each package to solve the problem independently. |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
napi build --compressships any native addon as a single, self-loading.nodethat's smaller on disk at the same runtime speed. As an example: a vite 8.1.3 install pulls in two big napi natives — lightningcss and the rolldown binding — and--compresstakes their footprint from ~25 MB to ~8 MB on disk (darwin-arm64).Measured against vite 8.1.3's resolved natives (zstd, level 16):
Output is byte-identical and runtime speed is unchanged — it's the same binary once loaded. PoC + tests on the
feat/compress-native-addonsbranch; numbers reproduce on Node 26.3.1 withbenchmark.mts.FAQ
What it does, and what the first load costs (nothing on a compressing filesystem)
Each compressed addon is a single self-loading file —
[stub][zstd payload][footer]— that keeps its original name (no.zst/.br/.jsonsidecars, so a package's file list andoptionalDependenciesdon't change). Nodedlopens the small stub; the stub reads its own 24-byte footer (payload length + an fnv1a64 content hash + a magic marker), decodes the appended payload, checks the decoded bytes are a real Mach-O/ELF/PE, and loads them — forwardingnapi_register_module_v1so the consumer sees the original exports unchanged. A bad hash or a non-binary payload fails loud, so a corrupt or tampered addon never gets loaded.On a filesystem with built-in compression (APFS, NTFS, btrfs) the stub rewrites itself compressed in place on that first load; afterwards the OS serves it decompressed on read and Node loads it directly at native speed, never unpacked again. Everywhere else it decodes into an ephemeral cache (below). Either way the decode happens once. On a compressing filesystem the first load is the same or faster than the raw file — the kernel reads fewer physical bytes — so compression adds no load penalty; the dominant ~150 ms is just the OS validating a freshly-written binary, the same cost any
.nodepays. The only added cost is on the ext4/xfs fallback: a one-time decode into the cache, then +0.3 ms per load (shown in the chart above).Which compression each OS uses
The self-rewrite hands the file to the operating system's own per-file compression, so the kernel decompresses it on read with no work from the addon. Each OS exposes different codecs, and since a
.nodeis written once and read on load, the choice favors decode speed over the last few percent of ratio.libcompression, so nothing extra ships with the addon.All three are transparent: the file keeps its original logical size, a read returns the exact original bytes, and steady-state load speed is unchanged.
Does it survive moving, copying, and linking into
node_modules?The compression is an attribute of the file on the filesystem, not baked into the bytes — a normal read always returns the exact, full binary, so loading is never affected. What's preserved is the on-disk saving, and it follows the same paths a package manager already uses to populate
node_modules:clonefile(copy-on-write) or a hardlink — both share the compressed inode, no rewrite.reflink(copy-on-write) or a hardlink — both share the compressed extents.A move/rename within the volume is metadata-only and keeps it. So the addon is written compressed once in the store, and every project that links it shares that one compressed copy.
Leaving a compressing filesystem — zipping it, copying onto ext4/exFAT, sending it over a network — just hands back the normal full file; smaller-on-disk is a local property, never a portable blob, and the file is never left corrupt. For that off-disk hop the
--compressform is the carrier: it stays small as a file anywhere, then becomes OS-compressed again once it lands on a compressing filesystem.Why not just shrink the Rust binary?
These binaries are already stripped + fat LTO, so there's no free size left at the Rust level. The
opt-level=zbuild setting gets lightningcss to 3.4 MB but runs ~3× slower (transform()minifying a 1.16 MB stylesheet: ~60 → ~19 ops/sec, best-of-3). The bytes are just code at that point. Storing them compressed and unpacking once is the only thing that shrinks the published size without making it slower — the code that runs is byte-for-byte identical.Cache location & env vars
The size win is in the published package, the npm download, and node_modules — they hold the small file. On a compressing filesystem there's no second copy at all (the addon rewrites itself in place). Where the OS can't compress, the loader decodes into a separate, optional layer that's ephemeral by default: the same OS temp dir Node's own V8 compile cache uses — the one tools like vite already turn on via
module.enableCompileCache()(that defaults to<os.tmpdir()>/node-compile-cache; ours sits beside it as<os.tmpdir()>/napi-rs-native). It's content-addressed, so it's a hit until temp is cleared (e.g. a reboot), then re-decoded once. That keeps disk honest — a persistent home-dir cache would leave a machine holding the small blob plus the expanded copy, i.e. more than shipping the raw.node.NAPI_RS_NATIVE_CACHEpicks the trade:=<path>: persist there — a CI cache volume, a tmpfs, a shared mount.=node_modules: the nearestnode_modules/.cache(per-package).=workspace: the workspace root'snode_modules/.cache, shared by every package — root found by its manifest (pnpm-workspace.yaml,package.json"workspaces",lerna.json,rush.json, …), falling back to the topmostnode_modules.=0: no cache — decode to a per-process temp, unlinked right after load, so disk never holds more than the blob.CI, depot.dev, and the native path on Linux
On CI the compressed form is a win before you tune anything: a vite 8.1.3 install pulls ~16.9 MB less native down the wire, so fresh installs are smaller and the package store holds less.
The native (FS-compressed) path needs a compressing filesystem on the path the addon lives on. macOS (APFS) and Windows (NTFS) get it for free — including GitHub Actions macOS/Windows runners and most cloud Windows/macOS instances. Linux is the gap: GitHub Actions Ubuntu and the usual AWS / Azure / GCP images run ext4/xfs, which don't compress, so they take the ephemeral-cache fallback. To opt in on Linux, mount a btrfs volume in the job and install/build onto it:
depot.dev accelerates runners and caching but (to my knowledge) doesn't provide a compressing filesystem, so its Linux runners are still ext4/xfs → cache fallback; the upside there is its persistent cache makes the one-time decode cheap to restore across runs.
If you'd rather just persist the decoded cache, it's a plain dir — point
NAPI_RS_NATIVE_CACHEat a path you cache. Noteactions/cachehas no default paths (you name them), andsetup-node's built-in cache covers the package store, notnode_modulesor temp — so it won't persist unless you add it. The payoff is small, though: restoring it saves only the ~10 ms decode (the restored file still gets OS-validated), so for most CI it's fine to just let it re-decode.Bonus: the JS side adds up too
Separate from the native, a lot of toolchains ship their JS
distunminified for readable stack traces. That's reclaimable, and it isn't all-or-nothing — vite 8.1.3 has 1.93 MB of bundled JS:Strip whitespace + comments globally (names intact), or fully minify only the bundled-dependency chunks (nobody steps into chokidar/postcss internals) while leaving your own entry points readable. Combined with
--compress, a full vite install goes 29.0 MB → ~11.2 MB (−61%).Happy to open a PR — the branch carries it end to end:
--compressin the build matrix (set once vianapi.compress/napi.compressLevelin config), plus abuild-stubsCI workflow for the per-target stubs.All reactions