feat: replace WordPress core bundle with streaming tar.zst - #3
Closed
erseco wants to merge 12 commits into
Closed
Conversation
Re-container each minified wp-<version>.zip into a solid, deterministic
USTAR + GNU-longlink tar compressed with zstd (level 19 + long-distance
matching, windowLog 25). Extend the auto-generated module descriptor with
format/container/codec/sha256/fileCount, and teach vite to resolve the new
.tar.zst assets. The zip stays only as a transient build source; the shipped
core bundle is the tar.zst.
- build/lib/tar-ustar.mjs: deterministic USTAR + GNU longlink writer/reader
- build/lib/generate-module-details.mjs: shared descriptor code-generator
- build/build-tar-zst.mjs: re-container CLI (--all/--version/--verify)
- build/build.js: delegate tar.zst + descriptor generation to the above
- get-wordpress-module{,-details}.ts: tar.zst descriptor + loader
Add a bounded-memory streaming extractor (zstddec decode -> incremental USTAR/GNU-longlink parser -> php.writeFile) and route to it from unzipWordPress() by sniffing the bundle's magic bytes (zstd vs ZIP). The full uncompressed tar is never materialized; a file-count parity check against the descriptor guards truncated/corrupt downloads. ZIP handling is unchanged for the CLI, wordpress.org releases, custom URLs, nightly/trunk and plugin/theme installs. - streaming-tar-extract.ts: createDecodedTarStream, StreamingTarParser, extractTarStreamToPhp, sanitizeTarPath (path-traversal/symlink safe) - boot.ts/legacy-boot.ts: thread wordPressBundleFileCount for parity - playground-worker-endpoint: pass the minified bundle's fileCount - remote vite + offline precache: recognize *.tar.zst - deps: add zstddec (WASM zstd decoder; no browser ships DecompressionStream zstd)
Add streaming-tar-extract.spec.ts (26 cases): path sanitization, normal/nested/ empty-dir entries, GNU longlink + USTAR prefix long paths, headers and bodies split across chunk boundaries, EOF/padding, truncation detection, symlink and traversal rejection, bounded maxBuffered, and a real zstddec round-trip with content + file-count parity. Update the descriptor test for the tar.zst shape. The existing boot suites (version-detect/database/...) now boot WordPress from tar.zst via getWordPressModule().
Add the architecture doc and record measured local results (size −15…−40 %, extraction ~2.5–2.8× faster in Node PHP-WASM, peak JS buffer bounded by the embedded wordpress-static.zip) plus the committed benchmark-tar-zst.mjs. Document that the PHP-WASM runtime is not a tar.zst candidate (single .wasm stream-compiled via instantiateStreaming).
The nightly bundle-build job needs native node:zlib zstd (Node >= 22.15) for the tar.zst re-container step; bump it to Node 24 (major-and-beta already is). Add a node --check step for the new build scripts.
Vite emits the tar.zst core bundle as wp-<v>.tar-<hash>.zst (extension .zst), so the offline-precache exclusion must match .zst, not .tar.zst, to keep the large core bundles cached on-demand instead of eagerly precached.
Add benchmark-tar-zst-browser.mjs (Playwright: in-browser tar.zst extraction across Chromium/Firefox/WebKit + Chromium CDP network-throttled download of zip vs tar.zst) and record the measured results: - extraction: Chromium 26 ms / Firefox 116 ms / WebKit 25 ms (WP 6.9), median 5 - throttled download saving vs ZIP: -0.79 s @ 40 Mbps, -3.93 s @ 8 Mbps (WP 6.9) The ~4 s download saving on a real 8 Mbps link is the headline slow-network win.
The core now boots from wp-<version>.tar.zst; the committed wp-<version>.zip bundles were only the re-container build source and are no longer referenced by any runtime or vite import. The Docker rebuild (build.js) regenerates the transient zip and re-containers it, so nothing depends on the committed zips. Also update wordpress-zip-assets.spec.ts (the view-transitions CSS regression guard) to read the tar.zst bundles (zstd decode + USTAR parse) instead of ZIP. Net binary change: -200 MiB zip, +150 MiB tar.zst.
…n sidebar The repo has no top-level docs/ convention (docs live under packages/docs/site). Move the streaming tar.zst spec/benchmarks out of the top-level docs/ into the architecture section as a design-notes page, add it and the core-bundle overview to sidebars.js (both were orphaned; check-orphan-pages now passes), and update the cross-references in the overview doc and build scripts.
- streaming-tar-extract.ts: @ts-ignore the zstddec/stream dynamic import; esbuild/ vite bundle the subpath fine but some tsc project contexts (classic resolution) can't resolve its type declarations (broke nx affected typecheck + dts build). - Tests avoid node:zlib zstd (Node >= 22.15), which the CI unit job (Node 20) lacks: guard the zstd round-trip with describe.skipIf, and decode the committed bundles in wordpress-zip-assets.spec via zstddec (WASM, any Node) instead of zstdDecompressSync. Verified: nx affected --target=typecheck (25 projects) passes; streaming spec 26/26; wordpress-builds 2/2; nx build playground-wordpress dts clean.
…st-core-bundle # Conflicts: # packages/playground/wordpress-builds/build/build.js # packages/playground/wordpress-builds/src/test/get-wordpress-module-details.spec.ts # packages/playground/wordpress-builds/src/wordpress/get-wordpress-module-details.ts # packages/playground/wordpress-builds/src/wordpress/get-wordpress-module.ts # packages/playground/wordpress-builds/src/wordpress/wp-6.4.tar.zst # packages/playground/wordpress-builds/src/wordpress/wp-6.5.tar.zst # packages/playground/wordpress-builds/src/wordpress/wp-6.6.tar.zst # packages/playground/wordpress-builds/src/wordpress/wp-6.7.tar.zst # packages/playground/wordpress-builds/src/wordpress/wp-6.8.tar.zst # packages/playground/wordpress-builds/src/wordpress/wp-6.9.tar.zst # packages/playground/wordpress-builds/src/wordpress/wp-7.0.tar.zst # packages/playground/wordpress-builds/src/wordpress/wp-beta.tar.zst
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Replace the WordPress core boot bundle (
wp-<version>.zip, extracted with PHPZipArchive) with a single solidtar.zstarchive extracted by streaming zstd decodeplus incremental USTAR/GNU-longlink parsing into PHP-WASM MEMFS. The full uncompressed tar is
never materialized.
The core bundle no longer uses PHP
ZipArchivefor extraction. Plugin/theme ZIP installation,the
unzipBlueprint step, wordpress.org full releases (CLI + custom-version boots), nightly/trunk GitHub
master.zip, the SQLite integration plugin, and thewordpress-static.zipstatic-asset backfill are all unchanged —
unzipWordPress()sniffs the bundle's magicbytes (zstd
28 B5 2F FDvs ZIPPK\x03\x04) and routes accordingly.Design notes + methodology + full benchmark data:
packages/docs/site/docs/developers/23-architecture/20-wordpress-core-bundle-tar-zst-design.md(rendered at
/developers/architecture/wordpress-core-bundle-tar-zst-design).Why
cannot: −15…−16 % for modern versions, −40 % for 6.3.
ZipArchive(measured in NodePHP-WASM; the PoC measured the same on Chrome/Firefox/Safari).
Prior art — already merged in sibling Playground forks
The same "streaming
tar.zstcore bundle" change is already merged in four siblingWebAssembly-PHP Playground projects on the same
@php-wasmstack (where the payoff is larger,since those are mostly-uncompressed PHP trees):
So the mechanism (deterministic USTAR+GNU-longlink,
zstddecstreaming decode, incrementalparser, MEMFS writes, file-count parity) is battle-tested across four codebases; this PR adapts
it to WordPress, where the gains are smaller but still real.
Benchmark summary
All numbers below are measured locally (Apple Silicon macOS, Node v26.4.0, cold, median of
5). Full methodology + data: the tar.zst design notes under
packages/docs/.Size + extraction (PHP-WASM MEMFS via
@php-wasm/node, PHP 8.3)ZipArchive)Real browser measurements (Playwright: Chromium / Firefox / WebKit 26.5)
In-browser
tar.zstextraction (zstddec streaming decode →StreamingTarParser→ JSwrites; the tar.zst path is pure JS), median of 5:
Firefox's JS decode is ~4.5× slower than Chromium/WebKit — the same engine gap the PoC measured
for PHP
ZipArchive(Firefox 701 ms), so Firefox benefits most.Real network-throttled download (Chromium DevTools
Network.emulateNetworkConditions) —the slow-link benefit the smaller bundle buys:
On a real 8 Mbps link the smaller bundle alone saves ~4 s of download per cold boot, on
top of the faster extraction — the download saving dominates on slow links.
PoC browser extraction for 6.9 into a real PHP-WASM MEMFS (measured): Chrome 142→60 ms (~2.3×),
Firefox 701→262 ms (~2.6×), Safari/WebKit 142→55 ms (~2.5×).
windowLog 25(32 MiB) was chosen: same compression as 27 for these ≤40 MiB bundles, but abounded multi-segment sliding window instead of the single-segment whole-content buffer that
windowLog ≥ 26forces on the decoder.windowLog 24costs +1.7 MiB (+8 %) for 6.9.Still pending preview: the composite per-engine end-to-end app-ready time (download +
extract + WASM compile + WP install for the whole site) — both variable components (download,
extraction) are measured above; the composite awaits the Cloudflare Pages build.
PHP runtime packaging investigation
Traced end-to-end (not a guess). The PHP-WASM runtime is not a
tar.zstcandidate and isleft unchanged:
.wasmfetched viaWebAssembly.instantiateStreaming(fetch(url))— the glue'simport '…/php_8_3.wasm'isturned into a hashed asset URL by esbuild/vite's
fileloader; the module stream-compilesduring download.
.datapreload package for the runtime (findoverweb-builds/node-buildsfor*.datais empty; the PHP compile usesEXPORT_NAMEwith no--preload-file). ICU (icu.dat, ~30 MiB) is loaded only whenintlis requested and stagedmanually into MEMFS.
remote/.htaccess) just sets the wasm MIMEtype. Transparent HTTP compression is left to the server.
Verdict: wrapping the
.wasmintar.zstwould defeatinstantiateStreaming(streamcompile), touches 8 web + 8 node version packages and the Emscripten build, and yields little
over an already-stream-compiled binary — not local, not low-risk, not worth it here.
Recorded as Future work: rely on server-side
Content-Encoding: br/zstdfor the.wasm.How
packages/playground/wordpress-builds/build/):lib/tar-ustar.mjs— deterministic USTAR + GNU././@LongLink(never PAX), byte-wise sort,mtime=0, files-only.build-tar-zst.mjs— re-container each committedwp-<v>.zip→wp-<v>.tar.zst(
node:zlibzstd level 19 + LDM,windowLog 25); prints{fileCount, size, sha256};--all/--version/--verify/--descriptor-only.lib/generate-module-details.mjs— shared descriptor code-generator.build.jsnow delegates the tar.zst + descriptor step (transient ZIP → tar.zst → descriptor).get-wordpress-module-details.ts, auto-generated):{ size, url }→{ format, container, codec, url, size, sha256, fileCount }. Remote nightly/trunk stayformat: 'zip'(GitHubmaster.zip).packages/playground/wordpress/src/streaming-tar-extract.ts):createDecodedTarStream,StreamingTarParser,extractTarStreamToPhp,sanitizeTarPath.zstddec(WASM) for zstd; nativeDecompressionStreamfor gzip/deflate/brotli. File-countparity vs the descriptor (fail loud).
assetsIncludeaccepts*.tar.zst; the offline precache manifest excludesthe hashed core-bundle
.zstassets (cached on-demand). SW cache invalidation is unchanged(
buildVersion= git HEAD).zstddec@^0.2.0.fflateand ZIP paths are untouched.node:zlibzstd);node --checkfor the newbuild scripts.
Security
Streaming extractor fails closed and is TAR-slip safe (unit-tested):
..traversal (throws).\→/before validation (Windows-style traversal)..segments.descriptor.fileCount.Verification
Commands run (locally, Node v26.4.0):
Results:
build-tar-zst.mjs --all/--verify: 9 bundles generated; everysha256/sizematches thedescriptor. OK.
2.5–2.8× faster.
node --checkon all new.mjs/.js: OK.tsc(lib+spec, wordpress & wordpress-builds):exit 0.
nx typecheck playground-remote: success.nx test playground-wordpress:streaming-tar-extract.spec.ts26/26; the existing bootsuites (
version-detectfor WP 6.3–7.0/beta/trunk,error-logging, most ofdatabase) bootWordPress from streaming
tar.zstand pass. One pre-existing failure indatabase.spec.tsafterAll(rmdirSync({recursive:true})removed in Node ≥ 24) — unrelatedto this change, present on
trunk.nx test playground-wordpress-builds: pass (descriptor test updated for the tar.zst shape).nx build playground-wordpress-builds: built, emitswp-<v>.tar-<hash>.zstassets + dts.benchmark-tar-zst-browser.mjs): tar.zst extraction Chromium 26 ms /Firefox 116 ms / WebKit 25 ms (6.9); throttled download saves −0.79 s @ 40 Mbps and
−3.93 s @ 8 Mbps vs ZIP (6.9). See the Benchmark summary.
Notes for review
erseco/wordpress-playground. Opened as draft for preview review.src/wordpress/wp-<v>.zipcore bundles are removed (the core boots fromtar.zst;they were only the re-container build source, regenerated by the Docker rebuild). Net binary
change ≈ −50 MiB (−200 MiB zip, +150 MiB tar.zst).
boot) — its variable components (download −15 %, extraction) are already measured in real
browsers above; the composite awaits the Cloudflare Pages build.
wordpress-static.zipstatic-asset backfill totar.zst; native tar.zst emission from the Docker build; server-sideContent-Encodingforthe
.wasmruntime.WordPress/wordpress-playground.