Expose saved Playground export API - #4095
Conversation
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds a lightweight hosted /api.html entrypoint and public startPlaygroundAPI() client to export saved OPFS sites as ZIPs (with ordered gitignore-style exclusions) without booting the full WordPress runtime.
Changes:
- Introduces
/api.htmlandbootPlaygroundAPI()to expose a minimal export API backed by OPFS site storage. - Extends OPFS ZIP export to support ordered
excludePatterns(gitignore semantics) and preserves empty-directory metadata (0755). - Updates build/offline/service-worker/caching rules to treat
api.htmland its entry chunk as network-first and not eagerly cached.
Reviewed changes
Copilot reviewed 16 out of 17 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/vite-extensions/vite-list-assets-required-for-offline-mode.ts | Excludes api.html and its entry chunk from eager offline caching. |
| packages/playground/website/vite.config.ts | Adds api.html as a build input, dev URL rewrite, and chunking logic for API boot. |
| packages/playground/website/src/lib/state/opfs/opfs-site-storage.ts | Adds exclusion patterns to ZIP export, sets ZIP dir attributes, and lazy-loads blueprint/metadata serialization. |
| packages/playground/website/src/lib/state/opfs/opfs-site-storage.spec.ts | Tests directory mode metadata and ordered exclusion patterns in ZIP export. |
| packages/playground/website/src/lib/state/opfs/opfs-site-metadata.ts | Extracts metadata serialization (keeps blueprints dependency out of API chunk). |
| packages/playground/website/src/lib/boot-playground-api.ts | Exposes export API via exposeAPI() backed by OPFS storage. |
| packages/playground/website/src/lib/boot-playground-api.spec.ts | Verifies API exposure and option forwarding. |
| packages/playground/website/package.json | Adds ignore dependency used for gitignore-style pattern matching. |
| packages/playground/website/api.html | Adds lightweight hosted API iframe document. |
| packages/playground/website/.htaccess | Ensures api.html is served with no-store/must-revalidate headers. |
| packages/playground/website-deployment/tests.php | Adds deployment test asserting api.html has no-store cache headers. |
| packages/playground/website-deployment/custom-redirects-lib.php | Adds api.html to the no-store cache header rules. |
| packages/playground/remote/service-worker.ts | Applies network-first strategy to /api.html in addition to entry documents. |
| packages/playground/client/src/index.ts | Adds startPlaygroundAPI(), API interfaces, and URL validation for /api.html. |
| packages/playground/client/src/index.spec.ts | Adds tests for startPlaygroundAPI() behavior and URL validation. |
| packages/playground/client/README.md | Documents saved-site export API usage and constraints. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
@ashfame sorry I won't have time to properly review this in the upcoming days. Have you considered adding these export features to the sites API? |
|
No worries! Excellent point though. I asked 5.6-Sol to compare the pros and cons, and here is what it had to say: The short version is that exporting a saved site does make sense as a Sites API capability, but the Sites API should complement rather than replace
The resulting shape would be: There are a few reasons the existing Sites API cannot directly replace
Based on that comparison, I think this PR should stay focused on the pieces needed by external lightweight consumers:
If we decide to connect this capability to the Sites API later, a focused follow-up could take this path:
So yes: I agree this belongs in the Sites API conceptually. This PR can establish the shared exporter and lightweight external transport while leaving a clear incremental path for exposing the same capability through |
zaerl
left a comment
There was a problem hiding this comment.
It's working the way it has been designed to. I don't know if other folks want to add their opinion, but for me it's an ok.
| assert_equal( | ||
| true, | ||
| in_array( 'Cache-Control: max-age=0, no-cache, no-store, must-revalidate', $api_headers, true ), | ||
| 'Playground API entry point should not be edge cached' |
| import type { SiteMetadata } from '../redux/slice-sites'; | ||
| import type { OriginalUrlParams } from '../original-url-params'; | ||
|
|
||
| export async function metadataToStoredFormat( |
| @@ -340,31 +346,13 @@ function getSiteMetadataPath(siteDirName: string) { | |||
|
|
|||
| async function metadataToStoredFormat( | |||
There was a problem hiding this comment.
Wait, we're moving the body of this method to another file and then importing that other file? That smells bad
| } | ||
| // Load even this constant lazily because only site reset needs Blueprint bundle support. | ||
| const { BUNDLE_DIR_NAME } = | ||
| await import('./opfs-blueprint-bundle-storage'); |
There was a problem hiding this comment.
I don't understand this comment or why would we avoid a top-level import
| // This allows the site to access bundled resources, not just the JSON declaration. | ||
| if (siteInfo.metadata.originalBlueprintSource?.type === 'opfs-site') { | ||
| try { | ||
| // Load Blueprint bundle support only when reading a Blueprint-backed site's metadata. |
There was a problem hiding this comment.
@ashfame What's wrong with a top-level imports? Or is this PR trying to do multiple things, like 1. Add api.html, 2. add lazy loading or libraries, 3. ...? In which case let's do a PR stack with one idea per PR.
|
This PR seems to contain at least two different ideas: It adds API.html, and it adds some lazy loading. There may be more. Let's do one PR per idea. The API.html change seems mostly fine, but I'm not convinced about the lazy loading. |
…port-api # Conflicts: # packages/playground/client/src/index.ts # packages/playground/website/vite.config.ts
|
@adamziel I will be closing this PR as I have split it into 4 diff PRs: #4217 #4218 #4219 #4220 PR 4218 was about lazy loading (specifically to keep api.html light and not arbitrary optimization) and upon measuring the impact, there were no gains and in fact, it was slightly worse. So that one is closed. Your comment about moving a function to a new file of its own has also been addressed. @brandonpayton has majorly reviewed the PRs with me together, and after taking another look today, we plan to merge them. So, that Calypso can also switch to using this new api.html for exporting playground site. |
What
This adds a lightweight hosted
/api.htmliframe endpoint and publicstartPlaygroundAPI()client. The endpoint forwards saved-site ZIP requests toopfsSiteStorage.exportSavedSiteAsZip()introduced in #3959, preserving its canonical and legacy slug lookup, metadata state checks, deletion-race handling, empty-directory preservation, and Blob return value.The export API now accepts ordered gitignore-style
excludePatternsthrough the establishedignorepackage. Matching paths are excluded, later rules win, and!rules can re-include paths. Ignored directory subtrees are skipped; as with gitignore, a parent directory must be re-included before one of its descendants can be re-included. Explicit ZIP directories use mode0755.Why
api.htmlremote.htmlalways boots the full WordPress/PHP worker and service-worker runtime. Adding an API mode there would couple unrelated lifecycles or duplicate the website storage layer. A website-ownedapi.htmlcan call #3959 directly and leaves room for future lightweight methods without booting WordPress.This first version is part of the hosted/full Playground website deployment; it is not packaged in
@wp-playground/remote. Supporting the client and remote npm self-hosting surface would require a separate shared-package design instead of pulling private website state into remote.Caching
api.htmlis omitted from the eager offline manifest and handled network-first by an existing Playground service worker, likeremote.html, so a cached document does not retain references to removed hashed assets. Hosted Apache and WordPress.net deployment rules also send no-store/must-revalidate for API-only consumers that do not yet have a controlling service worker. The shared OPFS storage chunk remains in the offline manifest because the main website also requires it.Browser storage
The endpoint must run on the same origin and in the same browser storage partition as the Playground that saved the site. WebKit keys File System storage by both the embedded origin and the full top-level origin, including its scheme, host, and port. Therefore, the same top-level site is not sufficient: a site saved while Playground is top-level is not visible to an API iframe embedded under another top-level origin. The supported iframe flow creates the save and performs the export while both frames are embedded under the same top-level origin. A top-level or popup flow for first-party saves is outside this focused change.
Compatibility
No breaking changes.
Screen Recording
opfs_bridge.mp4
Having followed the initial testing setup (described below), this is a screen recording of getting OPFS files out without booting Playground.
Testing instructions
Use a packed
@wp-playground/clienttarball fromdist, notnpm link.npm linkcan hide packaging mistakes because it does not behave like the published artifact. The hosted/api.htmlentry point comes from the website dev server and is intentionally not part of@wp-playground/remote.These steps use
http://127.0.0.1:5400for Playground andhttp://127.0.0.1:5173for the consumer. The different ports exercise cross-origin iframe communication. Create the saved fixture in the Playground iframe embedded by the consumer, not in a top-level Playground tab, so both save and export use the same browser storage partition. Do not substitutelocalhostfor only one of them becauselocalhostand127.0.0.1have separate OPFS storage.Start Playground and verify the endpoint
From this PR branch, start the website dev server:
Verify the lightweight endpoint is available:
The request should return
200.Build and pack the public client
In a separate terminal from the repository root:
nvm use rm -rf dist/packages/playground/client npm exec nx build playground-client rm -rf /tmp/wp-playground-api-packs mkdir -p /tmp/wp-playground-api-packs npm pack dist/packages/playground/client --pack-destination /tmp/wp-playground-api-packsCreate a throwaway consumer
Create
index.html:Create
src/main.ts:Export and inspect the ZIP
Start the consumer:
cd /tmp/playground-api-consumer npm run devOpen
http://127.0.0.1:5173.Wait for the embedded Playground to boot, then open DevTools. In the Console execution-context selector, choose the embedded
http://127.0.0.1:5400/website-server/document, not the:5173parent,remote.html, or the WordPress iframe. Confirm thattypeof window.playgroundSitesreturns"object", then run:Copy the printed slug, click Remove fixture Playground, enter the slug, and click Export filtered ZIP. Removing the fixture iframe first ensures the subsequent Network-panel check observes only the lightweight API path. The status should report a non-zero byte count and the browser should download
<slug>.zip.Inspect the archive with your ZIP tool or run:
Verify all of the following:
wp-content/export-test/included.txtis present and containsincluded.wp-content/cache/andwp-content/cache/excluded.txtare absent.wp-runtime.jsonare absent, demonstrating ordered exclusion and re-inclusion rules.drwxr-xr-x(0755) and can be browsed normally after extraction.Enter a nonexistent slug and export again. The consumer should report
No exportable saved OPFS site foundinstead of downloading an archive.Finally, open the consumer page's Network panel, clear it, and repeat a successful export. Confirm that
/api.htmland its lightweight API/storage modules load, while/remote.html, PHP/WASM binaries, WordPress builds, workers, the Blueprint editor, CodeMirror, TLS, firewall, and relay assets do not.Follow-up to #4038. Builds on #3959.