Skip to content

Expose saved Playground export API - #4095

Closed
ashfame wants to merge 6 commits into
trunkfrom
agent/expose-opfs-export-api
Closed

Expose saved Playground export API#4095
ashfame wants to merge 6 commits into
trunkfrom
agent/expose-opfs-export-api

Conversation

@ashfame

@ashfame ashfame commented Jul 16, 2026

Copy link
Copy Markdown
Member

What

This adds a lightweight hosted /api.html iframe endpoint and public startPlaygroundAPI() client. The endpoint forwards saved-site ZIP requests to opfsSiteStorage.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 excludePatterns through the established ignore package. 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 mode 0755.

Why api.html

remote.html always 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-owned api.html can 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.html is omitted from the eager offline manifest and handled network-first by an existing Playground service worker, like remote.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/client tarball from dist, not npm link. npm link can hide packaging mistakes because it does not behave like the published artifact. The hosted /api.html entry point comes from the website dev server and is intentionally not part of @wp-playground/remote.

These steps use http://127.0.0.1:5400 for Playground and http://127.0.0.1:5173 for 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 substitute localhost for only one of them because localhost and 127.0.0.1 have separate OPFS storage.

Start Playground and verify the endpoint

From this PR branch, start the website dev server:

nvm use
npm run dev

Verify the lightweight endpoint is available:

curl -I http://127.0.0.1:5400/api.html

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-packs

Create a throwaway consumer

rm -rf /tmp/playground-api-consumer
mkdir /tmp/playground-api-consumer
cd /tmp/playground-api-consumer
npm init -y
npm pkg set type=module
npm pkg set scripts.dev="vite --host 127.0.0.1 --port 5173"
npm install -D vite typescript
npm install /tmp/wp-playground-api-packs/wp-playground-client-*.tgz
mkdir -p src

Create index.html:

<!doctype html>
<html>
	<body>
		<section id="fixture-setup">
			<p>Create the saved fixture in this embedded Playground before exporting it.</p>
			<iframe
				id="fixture-playground"
				title="Fixture Playground"
				src="http://127.0.0.1:5400/website-server/?storage=temp"
				width="1200"
				height="800"
			></iframe>
			<button id="remove-fixture" type="button">Remove fixture Playground</button>
		</section>
		<form id="export-form">
			<input id="slug" placeholder="saved site slug" />
			<button>Export filtered ZIP</button>
		</form>
		<pre id="status"></pre>
		<script type="module" src="/src/main.ts"></script>
	</body>
</html>

Create src/main.ts:

import { startPlaygroundAPI } from '@wp-playground/client';

const form = document.querySelector<HTMLFormElement>('#export-form')!;
const slugInput = document.querySelector<HTMLInputElement>('#slug')!;
const status = document.querySelector<HTMLPreElement>('#status')!;
const fixtureSetup = document.querySelector<HTMLElement>('#fixture-setup')!;
const removeFixture = document.querySelector<HTMLButtonElement>('#remove-fixture')!;

removeFixture.addEventListener('click', () => fixtureSetup.remove());

form.addEventListener('submit', async (event) => {
	event.preventDefault();
	status.textContent = 'Connecting to the Playground API...';

	const iframe = document.createElement('iframe');
	iframe.hidden = true;
	iframe.sandbox.add('allow-scripts');
	iframe.sandbox.add('allow-same-origin');
	document.body.appendChild(iframe);

	try {
		const slug = slugInput.value.trim();
		const api = await startPlaygroundAPI({
			iframe,
			apiUrl: 'http://127.0.0.1:5400/api.html',
		});
		const zip = await api.exportSavedSiteAsZip(slug, {
			excludePatterns: [
				'/*',
				'!/wp-content/',
				'!/wp-content/**',
				'/wp-content/cache/',
			],
		});
		if (!zip) {
			throw new Error(`No exportable saved OPFS site found for ${slug}`);
		}

		const url = URL.createObjectURL(zip);
		const link = document.createElement('a');
		link.href = url;
		link.download = `${slug}.zip`;
		link.click();
		URL.revokeObjectURL(url);
		status.textContent = `Exported ${zip.size} bytes.`;
	} catch (error) {
		console.error(error);
		status.textContent = error instanceof Error ? error.message : String(error);
	} finally {
		iframe.remove();
	}
});

Export and inspect the ZIP

Start the consumer:

cd /tmp/playground-api-consumer
npm run dev

Open 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 :5173 parent, remote.html, or the WordPress iframe. Confirm that typeof window.playgroundSites returns "object", then run:

void (async () => {
	await window.playgroundSites.isReady();

	const client = window.playgroundSites.getClient();
	await client.mkdirTree('/wordpress/wp-content/export-test');
	await client.writeFile(
		'/wordpress/wp-content/export-test/included.txt',
		'included'
	);
	await client.mkdirTree('/wordpress/wp-content/cache');
	await client.writeFile(
		'/wordpress/wp-content/cache/excluded.txt',
		'excluded'
	);

	const saved = await window.playgroundSites.saveInBrowser('API export test');
	console.log(`Saved site slug: ${saved.slug}`);
})();

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:

unzip -l ~/Downloads/<slug>.zip
unzip -p ~/Downloads/<slug>.zip wp-content/export-test/included.txt
zipinfo -l ~/Downloads/<slug>.zip

Verify all of the following:

  • wp-content/export-test/included.txt is present and contains included.
  • wp-content/cache/ and wp-content/cache/excluded.txt are absent.
  • Top-level files such as wp-runtime.json are absent, demonstrating ordered exclusion and re-inclusion rules.
  • Directory entries are reported as 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 found instead of downloading an archive.

Finally, open the consumer page's Network panel, clear it, and repeat a successful export. Confirm that /api.html and 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.

Comment thread packages/playground/client/src/index.ts Outdated
Comment thread packages/playground/website/src/lib/state/opfs/opfs-site-storage.ts
@ashfame
ashfame marked this pull request as ready for review July 17, 2026 09:26
@ashfame
ashfame requested review from a team, bgrgicak and Copilot July 17, 2026 09:26

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.html and bootPlaygroundAPI() 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.html and 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.

Comment thread packages/playground/client/src/index.ts
Comment thread packages/playground/website/src/lib/boot-playground-api.ts
Comment thread packages/playground/website/vite.config.ts
Comment thread packages/playground/website/src/lib/state/opfs/opfs-site-storage.ts Outdated
@bgrgicak

Copy link
Copy Markdown
Collaborator

@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?
This way we could use the export feature for both the website and MCP.

@bgrgicak
bgrgicak requested review from zaerl and removed request for bgrgicak July 17, 2026 09:52
@ashfame

ashfame commented Jul 17, 2026

Copy link
Copy Markdown
Member Author

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 api.html. The cleanest architecture is to keep one OPFS export implementation and expose it through separate adapters for the Sites API, lightweight iframe consumers, and eventually MCP.

Approach Advantages Drawbacks
api.html only Lightweight; works across origins through postMessage; can return a Blob; can export an inactive saved OPFS site without booting WordPress Creates a parallel public API surface; the website's Sites API and MCP do not automatically benefit
Sites API only Correct domain ownership; discoverable through window.playgroundSites; consistent with the website's existing site-management operations; has access to site metadata and storage type Requires the full website and Redux lifecycle; is not available through @wp-playground/client or /remote.html; cannot be accessed by a cross-origin embedding parent; does not solve MCP's binary transport problem
Both, backed by one implementation Preserves the lightweight external API while making export available through the canonical site-management facade; avoids duplicating OPFS lookup, validation, ZIP creation, and race handling Leaves two thin adapters and requires a separate MCP transport follow-up

The resulting shape would be:

opfsSiteStorage.exportSavedSiteAsZip()
├─ window.playgroundSites.exportSavedSiteAsZip()
├─ api.html → startPlaygroundAPI()
└─ future MCP binary/artifact adapter

There are a few reasons the existing Sites API cannot directly replace api.html:

  • The Sites API introduced in [Website] Centralized site management API and expose as window.playgroundSites #3401 is a website-owned Redux facade. createSitesAPI() closes over the website store and dispatch, and window.playgroundSites is assigned only after the website has loaded its OPFS site state.
  • The Sites API deliberately ships on the top-level Playground website, not in @wp-playground/client or /remote.html. A page embedding Playground across origins cannot directly read window.playgroundSites from the iframe.
  • Loading the complete website merely to access the Sites API would lose the main property this PR preserves from [Reference] Original OPFS bridge export implementation #4038: exporting a saved OPFS site through a lightweight iframe without booting WordPress, PHP, workers, or the full application lifecycle.
  • MCP currently consumes only a narrow structural subset of the Sites API: list, getClient, rename, and saveInBrowser. Adding a method to PlaygroundSitesAPI would not expose it to MCP automatically.
  • The current browser-to-MCP bridge serializes responses with JSON.stringify(). A Blob would arrive as {}, while base64 or a JSON-encoded byte array would add substantial size and memory overhead. Proper MCP support therefore needs binary WebSocket frames, chunking, or an authenticated localhost upload that writes an artifact and returns its path.
  • The Sites API represents temporary, OPFS, and local-filesystem sites, while the implementation in this PR deliberately exports only complete saved OPFS sites. A Sites API wrapper should make that restriction explicit and reject unsupported storage backends rather than imply that every site can be exported identically.
  • This ZIP is a raw saved-OPFS snapshot, including Playground storage metadata unless excluded. It is not automatically the same portable archive produced by zipWpContent(), so a generic exportAsZip() name could imply broader import/export compatibility than we currently provide.

Based on that comparison, I think this PR should stay focused on the pieces needed by external lightweight consumers:

  • Keep opfsSiteStorage.exportSavedSiteAsZip() as the single implementation of lookup, completeness validation, deletion-race handling, exclusions, and ZIP creation.
  • Keep api.html and startPlaygroundAPI() as the transport that makes that implementation available to a parent page without booting a Playground runtime.
  • Keep remote.html out of this path because it still boots the WordPress/PHP runtime and represents a different lifecycle.

If we decide to connect this capability to the Sites API later, a focused follow-up could take this path:

  1. Add exportSavedSiteAsZip(slug, options) to createSitesAPI() as a thin delegate to the existing OPFS exporter. site-management-api-middleware.ts already imports opfsSiteStorage, so this would not introduce another website-layer dependency or duplicate the export logic.
  2. Validate that the requested site uses OPFS and return a clear error for temporary or local-filesystem sites.
  3. Add the method to the Sites API documentation and cover the wrapper's storage/error semantics with focused tests.
  4. Optionally organize the lightweight hosted API as a sites capability, such as api.sites.exportSavedSiteAsZip(), if we want api.html to host additional domain APIs in the future without accumulating unrelated flat methods.
  5. Handle MCP exposure separately by introducing a browser-level export command and an artifact-oriented binary transfer. The MCP tool should ideally return a host file or resource reference rather than placing ZIP bytes in the model context.

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 window.playgroundSites, and eventually through MCP once a safe binary/artifact transport is defined. Does that separation sound reasonable to you?

@ashfame
ashfame requested a review from adamziel July 17, 2026 19:19

@zaerl zaerl left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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'

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice

import type { SiteMetadata } from '../redux/slice-sites';
import type { OriginalUrlParams } from '../original-url-params';

export async function metadataToStoredFormat(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is it?

@@ -340,31 +346,13 @@ function getSiteMetadataPath(siteDirName: string) {

async function metadataToStoredFormat(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@adamziel adamziel Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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.

@adamziel

Copy link
Copy Markdown
Collaborator

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
@ashfame

ashfame commented Jul 31, 2026

Copy link
Copy Markdown
Member Author

@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.

@ashfame ashfame closed this Jul 31, 2026
@ashfame
ashfame deleted the agent/expose-opfs-export-api branch July 31, 2026 22:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants