feat: replace zip core bundle with streaming tar.zst - #36
Conversation
Benchmark:
|
| 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.)
…tripwire, scrub comments
… kit check, reuse TextDecoder
Summary
Replaces the ZIP core bundle entirely with a single solid
tar.zstbundle that thebrowser 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-wasmarchitecture:ZIP cannot), so the smaller download hides behind the WASM compile instead of blocking
boot — ~3× faster cold boot on a real network (Cloudflare).
instant the runtime holds only a partial 512-byte header, the current entry's bytes, and
one decoded chunk.
DecompressionStream("zstd"), so asmall
zstddecWASM decoder is bundled into the worker). PharData/pharis not used.What changed
Port kit (new, generic + validated)
lib/streaming-tar-extract.js—StreamingTarParser,createDecodedTarStream,extractTarStreamToPhp,sanitizeTarPath. Writes viaphp._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:zlibzstd level 19 + LDM + windowLog 27 (needs Node ≥ 22.15).
Build / manifest
scripts/build-nextcloud-bundle.sh: thezip -qrstep is replaced by the tar packer.It packs the inner
nextcloud/stage dir so tar entries are root-relative (no wrapperto strip). Output is now
nextcloud-core-<MAJOR>.tar.zst.fileCountcomes from thepacker 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 afile-count parity tripwire against the manifest. Removed the ZIP write +
ZipArchiveextract +
fetchArrayBufferdead code.src/runtime/install-script.js: removedbuildCoreExtractScript(core ZIP extractor).The app/blueprint ZIP paths (
buildZipExtractScript,buildUnzipScript) andfflateare untouched.
Chunking (nextcloud-only — the bundle exceeds Cloudflare's 25 MiB/file cap even as tar.zst)
scripts/chunk-bundles.mjsis generalized: it splits the oversizedbundle.pathregardless of format and, in the parts rewrite, preserves
format/codec/container(no hard-coded
"zip-parts") while addingparts/partSize/totalSize/sha256. Theloader's
fetchPartsWithCachereassembles byte-identical bytes;mountReadonlyCorethendecodes them as tar.zst. Still wired in
pages.yml.CI / deps / docs
zstddec@^0.2.0added (bundled into the worker by esbuild).pages.ymlbuild,ci.ymle2e) for native zstd.docs/streaming-tar-zst-core-bundle.md(+ mkdocs nav).tests/streaming-tar-extract.test.mjs; removed the obsoletebuildCoreExtractScripttest.Verification (run locally)
node --checkon the full CI syntax gate (incl. newlib/streaming-tar-extract.js) — OK.make test— 88 pass / 0 fail (26 suites), including 13 streaming-tar tests.make lint— exit 0 (2 pre-existing style warnings, unchanged behavior).npm run build-worker— worker bundles cleanly;zstddec+ the streaming extractor areinlined into the single
dist/php-worker.bundle.js(no orphan dynamic-import chunks).mkdocs build --strict— OK.redundant content):
build-tar-zst-bundle.mjs→createDecodedTarStream("zstd")(exercisesthe bundled
zstddec, since Node/browsers lack native zstdDecompressionStream) →StreamingTarParser→ full content + file-count parity.pathdropped, original deleted, parts reassemble byte-identically (SHA-256 verified).A full
make bundle(needs the Nextcloud release tarball + PHP) was not run locally; CIand the Cloudflare PR preview perform the real multi-branch build. Cloudflare/browser
benchmarks are handled separately after the preview deploys.