Skip to content

feat: replace zip core bundle with streaming tar.zst - #36

Merged
erseco merged 5 commits into
mainfrom
feat/streaming-tar-zst-core-bundle
Jul 5, 2026
Merged

feat: replace zip core bundle with streaming tar.zst#36
erseco merged 5 commits into
mainfrom
feat/streaming-tar-zst-core-bundle

Conversation

@erseco

@erseco erseco commented Jul 4, 2026

Copy link
Copy Markdown
Member

Summary

Replaces the ZIP core bundle entirely with a single solid tar.zst bundle that the
browser runtime extracts by streaming zstd decode + incremental TAR parsing, writing
each file straight into the PHP-WASM MEMFS as it decodes. There is no ZIP fallback — the
old path is removed to keep things simple.

Why

Ported from the sibling moodle-playground experiment (its ADRs 0018 & 0019), which
measured, for the same shell/remote/sw/worker + @php-wasm architecture:

  • ≈ −50 % download vs the ZIP (a solid tar exploits cross-file redundancy the per-file
    ZIP cannot), so the smaller download hides behind the WASM compile instead of blocking
    boot — ~3× faster cold boot on a real network (Cloudflare).
  • Bounded peak memory: the ~250 MB-class uncompressed tar is never materialized — at any
    instant the runtime holds only a partial 512-byte header, the current entry's bytes, and
    one decoded chunk.
  • Works on Chrome and Firefox (no browser exposes DecompressionStream("zstd"), so a
    small zstddec WASM decoder is bundled into the worker). PharData/phar is not used.

What changed

Port kit (new, generic + validated)

  • lib/streaming-tar-extract.jsStreamingTarParser, createDecodedTarStream,
    extractTarStreamToPhp, sanitizeTarPath. Writes via php._php.mkdirTree/writeFile.
  • scripts/lib/tar-ustar.mjs — deterministic USTAR + GNU-longlink writer/reader (no PAX).
  • scripts/build-tar-zst-bundle.mjs — walks a staged dir → deterministic tar → node:zlib
    zstd level 19 + LDM + windowLog 27 (needs Node ≥ 22.15).

Build / manifest

  • scripts/build-nextcloud-bundle.sh: the zip -qr step is replaced by the tar packer.
    It packs the inner nextcloud/ stage dir so tar entries are root-relative (no wrapper
    to strip). Output is now nextcloud-core-<MAJOR>.tar.zst. fileCount comes from the
    packer so it stays in lock-step with the runtime parity check.
  • scripts/generate-manifest.mjs: bundle descriptor is now
    { format: "tar.zst", container: "tar", codec: "zstd", … }.

Runtime

  • src/runtime/vfs.js (mountReadonlyCore): streams the tar.zst into MEMFS and asserts a
    file-count parity tripwire against the manifest. Removed the ZIP write + ZipArchive
    extract + fetchArrayBuffer dead code.
  • src/runtime/install-script.js: removed buildCoreExtractScript (core ZIP extractor).
    The app/blueprint ZIP paths (buildZipExtractScript, buildUnzipScript) and fflate
    are untouched.

Chunking (nextcloud-only — the bundle exceeds Cloudflare's 25 MiB/file cap even as tar.zst)

  • scripts/chunk-bundles.mjs is generalized: it splits the oversized bundle.path
    regardless of format and, in the parts rewrite, preserves format/codec/container
    (no hard-coded "zip-parts") while adding parts/partSize/totalSize/sha256. The
    loader's fetchPartsWithCache reassembles byte-identical bytes; mountReadonlyCore then
    decodes them as tar.zst. Still wired in pages.yml.

CI / deps / docs

  • zstddec@^0.2.0 added (bundled into the worker by esbuild).
  • Bundle-building jobs bumped Node 20 → 24 (pages.yml build, ci.yml e2e) for native zstd.
  • New decision note docs/streaming-tar-zst-core-bundle.md (+ mkdocs nav).
  • Tests: added tests/streaming-tar-extract.test.mjs; removed the obsolete
    buildCoreExtractScript test.

Verification (run locally)

  • node --check on the full CI syntax gate (incl. new lib/streaming-tar-extract.js) — OK.
  • make test88 pass / 0 fail (26 suites), including 13 streaming-tar tests.
  • make lintexit 0 (2 pre-existing style warnings, unchanged behavior).
  • npm run build-worker — worker bundles cleanly; zstddec + the streaming extractor are
    inlined into the single dist/php-worker.bundle.js (no orphan dynamic-import chunks).
  • mkdocs build --strictOK.
  • Build-helper dry-run on a synthetic tree (root file, nested dir, >100-char path,
    redundant content): build-tar-zst-bundle.mjscreateDecodedTarStream("zstd") (exercises
    the bundled zstddec, since Node/browsers lack native zstd DecompressionStream) →
    StreamingTarParserfull content + file-count parity.
  • Chunker dry-run: a 26 MiB tar.zst split into 2 parts — format/codec/container preserved,
    path dropped, original deleted, parts reassemble byte-identically (SHA-256 verified).

A full make bundle (needs the Nextcloud release tarball + PHP) was not run locally; CI
and the Cloudflare PR preview perform the real multi-branch build. Cloudflare/browser
benchmarks are handled separately after the preview deploys.

@erseco

erseco commented Jul 4, 2026

Copy link
Copy Markdown
Member Author

Benchmark: main (zip) vs this PR (streaming tar.zst) on the Cloudflare preview

Measured the deployed production main (https://nextcloud-playground.pages.dev) against this
PR preview (https://feat-streaming-tar-zst-core.nextcloud-playground.pages.dev) across all three
browser engines. The PR is smaller and boots faster in every browser — even though this is the
biggest, hardest case (~15.5k files, and the extraction runs in JS rather than native ZipArchive).

Bundle size (deployed manifests, exact)

main (zip) PR (tar.zst) Δ
Core bundle 167.6 MiB (7 chunks) 123.0 MiB (6 chunks) −27 % (−44 MiB)

Nextcloud ships a lot of already-compressed assets (images, pre-minified JS, fonts) that don't
recompress much, so the reduction is smaller than the pure-PHP siblings (omeka −53 %,
facturascripts −64 %) — but it's still 44 MiB less on the wire, and one fewer hosted chunk. The
chunker was generalized to split the tar.zst into 24 MiB parts (format/codec preserved); the loader
reassembles them byte-identically and streams-decodes.

Time to app-ready (cold boot, real deploy)

Wall-clock from navigation until the app frame renders content, fresh context (empty cache).

Browser main (zip) PR (tar.zst) Improvement
Chrome (Chromium) 14 134 ms 12 683 ms −10 %
Firefox 35 123 ms 26 558 ms −24 %
Safari (WebKit 26.5) 19 500 ms 16 059 ms −18 %

Nextcloud boots are slow in absolute terms because the app itself is huge (that's inherent, not the
format) — the point is the relative improvement: the smaller download outweighs the JS
extraction cost in every browser, so cold boot is faster across the board.

Memory (the key property for a 15.5k-file bundle)

This matters most here. The runtime never materializes the ~400 MB+ uncompressed tar: the
zstddec streaming generator yields ~128 KB chunks and the incremental USTAR parser writes each file
into MEMFS as it arrives, so the peak JS working buffer stays at ~one file (a few MiB) instead of the
whole tree. This is the same streaming code validated in the moodle-playground sibling (ADR
0018/0019), bounded to ~6.6 MiB across Chrome, Firefox and Safari.

Verdict

Smaller download (−27 %, −44 MiB, one fewer chunk), faster cold boot on Chrome + Firefox + Safari,
bounded memory even for the largest tree, and a simpler single-format codebase (no zip fallback).
Recommend adopting. (Left as Draft pending your review of the preview.)

@erseco
erseco marked this pull request as ready for review July 5, 2026 00:27
@erseco
erseco merged commit 9e2e14a into main Jul 5, 2026
5 checks passed
@erseco
erseco deleted the feat/streaming-tar-zst-core-bundle branch July 5, 2026 00:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant