Architecture
binpatch is a small library by design. This page documents the design
decisions and trade-offs so contributors don’t accidentally regress them.
High-level shape
Section titled “High-level shape”flowchart LR Code["your code"] -->|"call"| RnA["resolveAndApply"] RnA --> SS["SourceStrategy<br/>.resolveChain()<br/>(pluggable)"] RnA --> F1["fetch patches"] F1 --> F2["apply each hop"] F2 --> F3["verify SHA-256"] F3 --> F4["emit ProgressEvents"] F4 -->|"return result"| CoderesolveAndApply is the single entry point for the discovery + apply
flow. The pure apply functions (applyPatchChainInMemory, etc.) are
exposed for callers who want to do their own discovery.
Layer boundaries
Section titled “Layer boundaries”The library is split into four concerns:
- Apply (
src/bspatch.ts, ~770 lines) — TRDIFF10 parse + apply. Pure logic, no I/O.applyPatchChainInMemoryis the only entry point most consumers need. - Cache (
src/patch-cache.ts, ~430 lines) — on-disk patch cache keyed by(fromVersion, toVersion). Cache directory is injected viamakeCache(cacheDir)— the library never reads config files itself. - Discover (
src/discover.ts,src/sources/*) —SourceStrategyabstraction withghcrSourceandgithubReleaseSourcebuilt-ins. Pluggable for custom registries. - Events (
src/events.ts, ~50 lines) —ProgressEventstream emitted duringresolveAndApply. Library never renders progress — consumers plug in their own indicator.
A small index.ts barrel exports the public surface. The contract.ts
file holds shared types (constants, chain limits, etc.).
Apply: why SWAR?
Section titled “Apply: why SWAR?”The dominant cost in applyPatch is the diff-add loop: XOR the diff
block into the destination window. We use SWAR (SIMD-within-a-register)
to process 4 bytes at a time on 32-bit words:
// 32-bit SWAR: 4 bytes added in one cycleconst mask = 0x7f7f7f7f;const sign = 0x80808080;((a & mask) + (b & mask)) ^ ((a ^ b) & sign);For a 100 MB binary where ~99% of diff blocks are zero-dominated and bsdiff has already crushed them into the high 8-12 KB of the patch, this 4-byte-per-cycle approach takes ~145 ms vs ~280 ms for the naive byte loop on the same machine (~1.9× faster). Real numbers from the Apply → Performance benchmark.
We do not use BigUint64Array for SWAR. The 64-bit carry rule is
not actually broken — the 0x7f mask strips each byte’s high bit
before the add, so the masked add carries within each byte only,
and the trick is correct at any lane width. (Verified across
1.7M+ random pairs and worst-case carry patterns.) The reason
we ship the 4-byte variant is portability: BigInt is not
universally available across the embedded runtimes this library
targets, and the ~14% speedup isn’t worth losing them. A byteOffset % 4
alignment guard falls back to the byte loop when the buffer is not
4-byte aligned — keeps the SWAR fast path safe.
Apply: why not Courgette-style executable-aware diffing?
Section titled “Apply: why not Courgette-style executable-aware diffing?”Courgette was a Chromium component that normalized pointer relocations in machine code before diffing, giving Chrome substantial patch-size wins on full Chromium updates. It has since been retired; the live successor is Zucchini. We do not use either because:
- The bsdiff seek mechanism already collapses most pointer churn into small seek distances. For our use case the bulk of the binary is a JS snapshot, which has little of the native-code relocation churn that Courgette/Zucchini target — so the normalization step has little to bite on.
- Courgette/Zucchini require understanding the executable format in detail (PE/ELF/Mach-O + relocations). Supporting all three platforms we ship for (linux/darwin/windows) would multiply maintenance cost.
- For our use case the bottleneck is full-download fallback latency, not patch size. Patch download is small; the cost is the cold path when no chain is available.
If you ship embedded native code with heavy relocation churn, or your
binary is small enough that even the bsdiff patch is the bottleneck,
consider Zucchini
or bsdiff-mantissa. For
binaries where the bulk is a JS snapshot (Node SEA, Bun --compile,
Deno compile), bsdiff + SWAR is the right trade.
Apply: why no native code?
Section titled “Apply: why no native code?”We considered bundling a native mmap-based reader for the old binary
to avoid loading it fully into RAM. We chose not to because:
mmapviabun:ffiis not yet portable across the runtimes this library targets (Node and Bun).mmap-ioand similar native addons break esbuild + Node SEA bundling, which several shipped consumers rely on.- For a 100 MB binary, an in-memory
Uint8Arrayis fine: it’s about 1% of a typical CI runner’s RAM budget.
If your binary is much larger (>1 GB), the public
applyPatchChainInMemory is still the right entry point — it reads
the on-disk base via positional reads (pread) on demand, so a 4 GB
binary uses ~1 MiB of read-ahead buffer rather than 4 GiB of RAM.
You only need a custom reader if you want to apply from a stream
(network-mounted storage, an archive, etc.); that’s an internal
seam today, not a public export.
Discover: why pluggable discovery?
Section titled “Discover: why pluggable discovery?”Discovery is the most consumer-specific part of the upgrade flow: nightly vs stable, GHCR vs custom OCI, GitHub Releases vs S3, etc. Pushing that into the library would force every consumer to either accept a default they can’t change or reimplement it themselves.
SourceStrategy.resolveChain(currentVersion, targetVersion, signal?, report?)
returns a PatchChain (or null). The library handles the chain
validation, ordering, and per-hop download. Consumers ship a 30-line
strategy object and get the rest.
Discover: why both GHCR and GitHub Releases?
Section titled “Discover: why both GHCR and GitHub Releases?”Same library, two channels:
- Nightly / canary: stored in GHCR (or any OCI registry). OCI
gives us content-addressable storage, multi-arch, and the
:nightly→:nightly-<version>zero-copy tag pattern. Standard tooling (oras, docker, crane) can interact with it. - Stable / released: stored in GitHub Releases. Native UI for
signed releases, asset downloads via
gh release download. Stable channels typically have fewer versions and don’t benefit from OCI machinery.
The two channels share the wire format and the apply core — they only differ in discovery.
Telemetry: events, not spans
Section titled “Telemetry: events, not spans”resolveAndApply emits ProgressEvents ({ type: "phase", phase },
{ type: "bytes", phase, written, total }, { type: "done", phase }).
It does not emit OpenTelemetry / Sentry / StatsD spans. That’s
deliberate: span shape varies wildly between consumers (Sentry’s
per-HTTP withTracingSpan, OTel’s startActiveSpan, StatsD’s
timing distribution). Consumers wire the events into whatever their
tracing system is.
For per-HTTP-step granularity, an InstrumentHook lets you wrap
each OCI request in your own span without leaking fetch details into
the library. See Instrumentation.
- Security → — verification, OOM guard, timeouts
- Applying patches → — apply API surface
- Discovering chains → —
SourceStrategyinterface