Skip to content

Commit d6608c6

Browse files
committed
feat(node_binding): compressed @rspack/binding via a self-loading hybrid .node
Ship the native addon zstd-compressed inside the .node and unwrap it on first load, handing it to the OS's transparent filesystem compression (APFS / NTFS / btrfs) so it takes fewer blocks on disk and loads faster (the kernel reads fewer bytes), reusing the decmpfs crate — no @napi-rs/cli change. - scripts/pack-compressed-binding.mjs: reference packer (zstd + SHA-512) in the layout decmpfs unwraps; unpack() fails closed on a short, tampered, oversized, or non-zstd section. - scripts/pack-compressed-binding.test.mjs: node:test unit tests (round-trip, integrity, rejection paths), pure in-memory. - scripts/bench-compressed-binding.mjs: resolves whichever @rspack/binding is installed for the platform and reports the download size win. - docs/compressed-binding.md: design doc. Draft/RFC — per-platform section injection and the Rust first-load loader still need rspack's cross-compile CI.
1 parent f489568 commit d6608c6

4 files changed

Lines changed: 329 additions & 0 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Compressed `@rspack/binding` — RFC / WIP
2+
3+
Ship the native addon (`rspack.<platform>.node`) with the real binary stored
4+
zstd-compressed inside itself, and unwrap it on first load. This shrinks what
5+
rspack ships and installs (a measured run took the ~40 MB `@rspack/binding`
6+
down to ~18 MB) at the same steady-state load speed, and it does **not** depend
7+
on `@napi-rs/cli` — the loader is rspack's own Rust, reusing the `decmpfs` crate.
8+
9+
## Why do this in rspack, now
10+
11+
`@napi-rs/cli` has no `--compress` yet; it is only a proposal. But rspack already
12+
cross-compiles Rust for every platform it ships, so rspack can build a
13+
self-loading addon in Rust today and get the size win first. Tools that lean on
14+
the napi CLI would have to wait for the CLI to add it.
15+
16+
## The hybrid format
17+
18+
The addon is stored zstd-compressed in a `PRESSED_DATA` section (`__PRESSED_DATA`
19+
in segment `SMOL` on Mach-O, `.PRESSED_DATA` on ELF, `.PRESSED` on PE), with a
20+
SHA-512 hash over the compressed bytes. This is the same "pressed-data" layout
21+
the `decmpfs` crate already reads, so its unwrap code works unchanged. The exact
22+
byte layout is in `scripts/pack-compressed-binding.mjs`, a small reference packer
23+
using built-in `node:zlib` (zstd) and `node:crypto` (SHA-512).
24+
25+
## Runtime (all Rust, reuses `decmpfs`)
26+
27+
On first load the addon:
28+
29+
1. reads its own `PRESSED_DATA` section,
30+
2. verifies SHA-512, then zstd-decodes to the raw addon (`decmpfs::addon::unwrap_if_hybrid`),
31+
3. on a compressing filesystem (APFS / NTFS / btrfs) rewrites itself compressed
32+
in place (`decmpfs::compress_bytes`) so later loads are native; otherwise it
33+
decompresses once to a per-version cache,
34+
4. hands off to the real addon's napi registration.
35+
36+
No JS glue, no legacy-Node fallback — modern targets only.
37+
38+
## Status
39+
40+
- Format + pack/unpack round-trip: **verified** (the `decmpfs` crate's own tests,
41+
plus the JS reference packer added here — it packs a real `.node` and unpacks
42+
to byte-identical bytes that `dlopen` cleanly).
43+
- **WIP, for rspack CI on the cross-compile matrix:** injecting the
44+
`PRESSED_DATA` section at build time, and the self-loading hand-off.
45+
46+
## Follow-up
47+
48+
Once `@napi-rs/cli` ships `--compress`, this collapses to a one-line build flag;
49+
until then this keeps the win in-tree.
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Report what the compressed binding buys for THIS machine's installed
2+
// `@rspack/binding`: the shipped `.node` download size, raw vs zstd-19. Resolves
3+
// whichever version is installed for the current platform — no hardcoded
4+
// version or path.
5+
//
6+
// Load speed comes from the OS's transparent filesystem compression (APFS /
7+
// NTFS / btrfs) that the loader applies on first use: on such a filesystem the
8+
// addon takes fewer blocks on disk AND loads faster, because the kernel reads
9+
// fewer bytes and decompresses them cheaply. That measurement needs a
10+
// compressing filesystem and is reported in the PR.
11+
//
12+
// Run: `node crates/node_binding/scripts/bench-compressed-binding.mjs`
13+
// (from a checkout where `@rspack/binding-<triple>` is installed).
14+
import crypto from 'node:crypto'
15+
import { createRequire } from 'node:module'
16+
import fs from 'node:fs'
17+
import path from 'node:path'
18+
import process from 'node:process'
19+
import { fileURLToPath } from 'node:url'
20+
import zlib from 'node:zlib'
21+
22+
const require = createRequire(import.meta.url)
23+
24+
// napi-rs platform triple for the `@rspack/binding-<triple>` packages. macOS
25+
// and Windows have no libc suffix; Linux is glibc unless a musl libc reports.
26+
function platformTriple() {
27+
const { arch, platform } = process
28+
if (platform === 'linux') {
29+
const isMusl =
30+
typeof process.report?.getReport === 'function' &&
31+
/musl/.test(JSON.stringify(process.report.getReport().header ?? {}))
32+
return `linux-${arch}-${isMusl ? 'musl' : 'gnu'}`
33+
}
34+
if (platform === 'win32') {
35+
return `win32-${arch}-msvc`
36+
}
37+
return `${platform}-${arch}`
38+
}
39+
40+
// Resolve the installed binding `.node` for this platform. Try node's own
41+
// resolution first; fall back to scanning `node_modules/@rspack` so a pnpm
42+
// symlink layout or an odd cwd still finds it.
43+
function resolveBindingNode(triple) {
44+
const file = `rspack.${triple}.node`
45+
try {
46+
return require.resolve(`@rspack/binding-${triple}/${file}`)
47+
} catch {}
48+
const roots = [process.cwd(), path.dirname(fileURLToPath(import.meta.url))]
49+
for (const root of roots) {
50+
let dir = root
51+
for (let i = 0; i < 8; i += 1) {
52+
const hit = path.join(
53+
dir,
54+
'node_modules',
55+
'@rspack',
56+
`binding-${triple}`,
57+
file,
58+
)
59+
if (fs.existsSync(hit)) {
60+
return hit
61+
}
62+
const parent = path.dirname(dir)
63+
if (parent === dir) {
64+
break
65+
}
66+
dir = parent
67+
}
68+
}
69+
throw new Error(
70+
`no installed @rspack/binding-${triple} found — install it, or run from a checkout that has it`,
71+
)
72+
}
73+
74+
function mib(bytes) {
75+
return `${(bytes / 1024 / 1024).toFixed(1)} MiB`
76+
}
77+
78+
const triple = platformTriple()
79+
const src = resolveBindingNode(triple)
80+
const raw = fs.readFileSync(src)
81+
82+
const t0 = process.hrtime.bigint()
83+
const zst = zlib.zstdCompressSync(raw, {
84+
[zlib.constants.ZSTD_c_compressionLevel]: 19,
85+
})
86+
const t1 = process.hrtime.bigint()
87+
const savedPct = (100 - (zst.length / raw.length) * 100).toFixed(0)
88+
const sha = crypto.createHash('sha512').update(zst).digest('hex').slice(0, 12)
89+
90+
console.log(`platform ${triple}`)
91+
console.log(`binding ${src}`)
92+
console.log(`raw ${mib(raw.length)} (${raw.length} bytes)`)
93+
console.log(
94+
`zstd-19 ${mib(zst.length)} (−${savedPct}% download, sha512:${sha}, ${(Number(t1 - t0) / 1e6).toFixed(0)} ms to compress)`,
95+
)
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// Pack a built `rspack.<platform>.node` into the "pressed-data" payload used by
2+
// the compressed-binding loader: zstd-compressed addon + SHA-512 integrity, in
3+
// the same layout the `decmpfs` crate (and bin-infra) already unwraps. This is
4+
// the creator-side reference; the build step injects the payload into the
5+
// binary's PRESSED_DATA section and the Rust loader unwraps it on first load.
6+
//
7+
// Format: [magic 32B "__SMOL_PRESSED_DATA_MAGIC_MARKER"]
8+
// [compressed u64 LE][uncompressed u64 LE][cache key 16B][platform 3B]
9+
// [SHA-512 of the zstd payload 64B][has_config 1B][zstd frame]
10+
//
11+
// No @napi-rs/cli, no legacy-Node fallback — modern node:zlib/node:crypto only.
12+
import crypto from 'node:crypto'
13+
import zlib from 'node:zlib'
14+
15+
const MAGIC = Buffer.from('__SMOL_PRESSED_DATA_MAGIC_MARKER') // 32B
16+
const CACHE_KEY_LEN = 16
17+
const PLATFORM_LEN = 3
18+
const HASH_LEN = 64 // SHA-512
19+
20+
export function pack(raw, options) {
21+
const level = options?.level ?? 19
22+
const payload = zlib.zstdCompressSync(raw, {
23+
[zlib.constants.ZSTD_c_compressionLevel]: level,
24+
})
25+
const hash = crypto.createHash('sha512').update(payload).digest()
26+
const head = Buffer.alloc(
27+
MAGIC.length + 8 + 8 + CACHE_KEY_LEN + PLATFORM_LEN + HASH_LEN + 1,
28+
)
29+
let o = 0
30+
MAGIC.copy(head, o)
31+
o += MAGIC.length
32+
head.writeBigUInt64LE(BigInt(payload.length), o)
33+
o += 8
34+
head.writeBigUInt64LE(BigInt(raw.length), o)
35+
o += 8
36+
crypto.createHash('sha256').update(raw).digest().copy(head, o, 0, CACHE_KEY_LEN)
37+
o += CACHE_KEY_LEN
38+
o += PLATFORM_LEN // platform/arch/libc — stamped by the build matrix
39+
hash.copy(head, o)
40+
o += HASH_LEN
41+
head[o] = 0 // has_config
42+
return Buffer.concat([head, payload])
43+
}
44+
45+
const MAX_LEN = 512 * 1024 * 1024 // reject implausible sizes; keeps sizes in Number's safe range
46+
47+
export function unpack(section) {
48+
const min = MAGIC.length + 8 + 8 + CACHE_KEY_LEN + PLATFORM_LEN + HASH_LEN + 1
49+
if (section.length < min || !section.subarray(0, MAGIC.length).equals(MAGIC)) {
50+
return null
51+
}
52+
let o = MAGIC.length
53+
const clenBig = section.readBigUInt64LE(o)
54+
o += 8
55+
const ulenBig = section.readBigUInt64LE(o)
56+
o += 8
57+
o += CACHE_KEY_LEN + PLATFORM_LEN
58+
const hash = section.subarray(o, o + HASH_LEN)
59+
o += HASH_LEN
60+
if (section[o]) {
61+
o += 1192 // config block
62+
}
63+
o += 1
64+
// Bound both lengths BEFORE narrowing to Number, so a huge u64 can't lose
65+
// precision or drive an out-of-range read.
66+
if (
67+
clenBig <= 0n ||
68+
ulenBig <= 0n ||
69+
clenBig > BigInt(MAX_LEN) ||
70+
ulenBig > BigInt(MAX_LEN)
71+
) {
72+
return null
73+
}
74+
const clen = Number(clenBig)
75+
const ulen = Number(ulenBig)
76+
// The declared payload must actually fit in what's left of the section.
77+
if (o + clen > section.length) {
78+
return null
79+
}
80+
const payload = section.subarray(o, o + clen)
81+
if (!crypto.createHash('sha512').update(payload).digest().equals(hash)) {
82+
return null
83+
}
84+
// A corrupt payload that still matched the hash (or a non-zstd frame) must
85+
// fail closed, never crash the loader.
86+
let raw
87+
try {
88+
raw = zlib.zstdDecompressSync(payload)
89+
} catch {
90+
return null
91+
}
92+
return raw.length === ulen ? raw : null
93+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Unit tests for the compressed-binding reference packer. Run with:
2+
// node --test crates/node_binding/scripts/pack-compressed-binding.test.mjs
3+
// Needs a Node with built-in zstd (node:zlib, Node >= 22.15 / 24), the same
4+
// requirement as the packer itself.
5+
import assert from 'node:assert/strict'
6+
import crypto from 'node:crypto'
7+
import { test } from 'node:test'
8+
9+
import { pack, unpack } from './pack-compressed-binding.mjs'
10+
11+
const MAGIC = Buffer.from('__SMOL_PRESSED_DATA_MAGIC_MARKER') // 32B
12+
const HEAD_LEN = MAGIC.length + 8 + 8 + 16 + 3 + 64 + 1 // magic+clen+ulen+key+plat+hash+has_config
13+
14+
// A few representative payloads: tiny, text-ish, and a compressible binary-ish blob.
15+
function payloads() {
16+
const zeros = Buffer.alloc(4096) // highly compressible
17+
const text = Buffer.from('rspack '.repeat(2000), 'utf8')
18+
const mixed = Buffer.concat([
19+
Buffer.from([0x7f, 0x45, 0x4c, 0x46]), // ELF-ish magic
20+
crypto.createHash('sha512').update('seed').digest(),
21+
Buffer.alloc(10000, 0xab),
22+
])
23+
return { zeros, text, mixed }
24+
}
25+
26+
test('pack → unpack round-trips byte-identically', () => {
27+
for (const [name, raw] of Object.entries(payloads())) {
28+
const packed = pack(raw)
29+
const back = unpack(packed)
30+
assert.ok(back, `${name}: unpack returned null`)
31+
assert.equal(Buffer.compare(back, raw), 0, `${name}: bytes differ after round-trip`)
32+
}
33+
})
34+
35+
test('packed output starts with the magic and a full header', () => {
36+
const packed = pack(Buffer.from('hello'))
37+
assert.equal(Buffer.compare(packed.subarray(0, MAGIC.length), MAGIC), 0)
38+
assert.ok(packed.length > HEAD_LEN, 'packed shorter than the header')
39+
})
40+
41+
test('unpack rejects a buffer that is too short', () => {
42+
assert.equal(unpack(Buffer.alloc(8)), null)
43+
assert.equal(unpack(Buffer.alloc(HEAD_LEN - 1)), null)
44+
})
45+
46+
test('unpack rejects a wrong magic marker', () => {
47+
const packed = pack(Buffer.from('payload'))
48+
packed[0] ^= 0xff // corrupt the first magic byte
49+
assert.equal(unpack(packed), null)
50+
})
51+
52+
test('unpack rejects a tampered payload (SHA-512 mismatch)', () => {
53+
const packed = pack(Buffer.from('rspack '.repeat(500)))
54+
// flip a byte inside the zstd payload (past the header) — hash must catch it
55+
packed[packed.length - 1] ^= 0x01
56+
assert.equal(unpack(packed), null)
57+
})
58+
59+
test('unpack rejects an implausibly large uncompressed size', () => {
60+
const packed = pack(Buffer.from('data'))
61+
// overwrite the uncompressed-length u64 (offset magic+8) with > 512 MiB cap
62+
packed.writeBigUInt64LE(BigInt(600 * 1024 * 1024), MAGIC.length + 8)
63+
assert.equal(unpack(packed), null)
64+
})
65+
66+
test('unpack rejects a compressed length that overruns the buffer', () => {
67+
const packed = pack(Buffer.from('rspack payload'))
68+
// clen (offset magic+0) claims more bytes than the section actually holds,
69+
// but stays under the 512 MiB cap — the fit check must catch it.
70+
packed.writeBigUInt64LE(BigInt(packed.length + 1024), MAGIC.length)
71+
assert.equal(unpack(packed), null)
72+
})
73+
74+
test('unpack fails closed (no throw) on a non-zstd payload with a valid hash', () => {
75+
// Hand-build a well-formed header whose stored SHA-512 matches the payload,
76+
// but the payload is not a zstd frame — decompress throws, unpack returns null.
77+
const junk = Buffer.from('this is not a zstd frame', 'utf8')
78+
const head = Buffer.alloc(HEAD_LEN)
79+
let o = 0
80+
MAGIC.copy(head, o)
81+
o += MAGIC.length
82+
head.writeBigUInt64LE(BigInt(junk.length), o) // clen
83+
o += 8
84+
head.writeBigUInt64LE(64n, o) // ulen (plausible, under cap)
85+
o += 8
86+
o += 16 + 3 // cache key + platform
87+
crypto.createHash('sha512').update(junk).digest().copy(head, o) // matching hash
88+
o += 64
89+
head[o] = 0 // has_config
90+
const section = Buffer.concat([head, junk])
91+
assert.equal(unpack(section), null)
92+
})

0 commit comments

Comments
 (0)