diff --git a/packages/playground/client/README.md b/packages/playground/client/README.md index c22ec6c0fc3..2611a73ce7c 100644 --- a/packages/playground/client/README.md +++ b/packages/playground/client/README.md @@ -22,6 +22,37 @@ console.log(response.text); Loading the client from `https://playground.wordpress.net/client/index.js` keeps it in sync with the remote Playground runtime. The client and the iframe communicate over an internal protocol, and backwards compatibility is guaranteed by serving a matching client and remote from the same deployment. +## Saved-site export API + +`startPlaygroundAPI()` exposes an API for working with Playgrounds without booting WordPress, PHP, workers, or a service worker. Currently, it only supports exporting saved OPFS sites. + +To export a saved OPFS site: + +- The API endpoint must share the origin and browser storage partition used to save the site. +- In WebKit, saving and exporting must use the same top-level origin, including its scheme, host, and port. A site saved while Playground is the top-level page is not visible to an API iframe embedded under a different top-level origin. + +The `/api.html` entry point is part of the full Playground website deployment. It is not included in the `@wp-playground/remote` npm package. + +```js +import { startPlaygroundAPI } from 'https://playground.wordpress.net/client/index.js'; + +const iframe = document.createElement('iframe'); +iframe.hidden = true; +iframe.sandbox.add('allow-scripts'); +iframe.sandbox.add('allow-same-origin'); +document.body.appendChild(iframe); + +const api = await startPlaygroundAPI({ + iframe, + apiUrl: 'https://playground.wordpress.net/api.html', +}); +const zip = await api.exportSavedSiteAsZip('my-site', { + excludePatterns: ['/*', '!/wp-content/', '!/wp-content/**'], +}); +``` + +The optional `excludePatterns` use gitignore semantics: matching paths are excluded, later patterns take precedence, and a leading `!` re-includes a path. When `excludePatterns` is omitted, the ZIP contains the complete saved site. + ## npm package The npm package exists for projects that want to install `@wp-playground/client` through a package manager, bundle it with their application, or use its TypeScript declarations locally. diff --git a/packages/playground/client/src/index.spec.ts b/packages/playground/client/src/index.spec.ts index 0c13a481a05..141ff24e47d 100644 --- a/packages/playground/client/src/index.spec.ts +++ b/packages/playground/client/src/index.spec.ts @@ -31,7 +31,7 @@ vi.mock('./blueprints-v2-handler', () => ({ BlueprintsV2Handler: mocks.BlueprintsV2Handler, })); -import { startPlaygroundWeb } from './index'; +import { startPlaygroundAPI, startPlaygroundWeb } from './index'; describe('startPlaygroundWeb', () => { afterEach(() => { @@ -121,6 +121,20 @@ describe('startPlaygroundWeb', () => { }); }); +describe('startPlaygroundAPI', () => { + it.each([ + ['a different endpoint', 'http://localhost/remote.html'], + ['an untrusted origin', 'https://example.com/api.html'], + ])('rejects %s', async (_description, apiUrl) => { + await expect( + startPlaygroundAPI({ + iframe: createIframe(), + apiUrl, + }) + ).rejects.toThrow('Invalid API URL'); + }); +}); + function createIframe() { return { src: '', diff --git a/packages/playground/client/src/index.ts b/packages/playground/client/src/index.ts index 8491092df7c..3b92f1a663f 100644 --- a/packages/playground/client/src/index.ts +++ b/packages/playground/client/src/index.ts @@ -46,7 +46,7 @@ import type { PlaygroundClient, SiteThumbnail, } from '@wp-playground/remote'; -import type { PathAlias } from '@php-wasm/universal'; +import { consumeAPI, type PathAlias } from '@php-wasm/universal'; import type { PHPWebExtension } from '@php-wasm/web'; import { additionalRemoteOrigins } from './additional-remote-origins'; // eslint-disable-next-line @nx/enforce-module-boundaries @@ -147,6 +147,27 @@ export interface StartPlaygroundWebOptions extends Omit< onBlueprintValidated?: (blueprint: BlueprintDeclaration) => void; } +// Redefined here to avoid an import from the private Playground website package +export interface ExportSavedSiteAsZipOptions { + /** + * Gitignore-style exclusion patterns applied relative to the saved site root. + * Patterns starting with `!` re-include paths. + */ + excludePatterns?: readonly string[]; +} + +export interface PlaygroundAPIClient { + exportSavedSiteAsZip( + slug: string, + options?: ExportSavedSiteAsZipOptions + ): Promise; +} + +export interface StartPlaygroundAPIOptions { + iframe: HTMLIFrameElement; + apiUrl: string; +} + /** * Loads playground in iframe and returns a PlaygroundClient instance. * @@ -181,10 +202,7 @@ export async function startPlaygroundWeb( }); progressTracker.setCaption('Preparing WordPress'); - await new Promise((resolve) => { - iframe.src = remoteUrl; - iframe.addEventListener('load', resolve, false); - }); + await loadIframe(iframe, remoteUrl); const handler = useBlueprintV2Handler ? new BlueprintsV2Handler(options) @@ -196,6 +214,38 @@ export async function startPlaygroundWeb( return playground; } +/** + * Loads the lightweight Playground API endpoint without booting WordPress. + * + * The API endpoint and saved OPFS site must share an origin and storage partition. + */ +export async function startPlaygroundAPI( + options: StartPlaygroundAPIOptions +): Promise { + const { iframe, apiUrl } = options; + assertLikelyCompatibleAPIOrigin(apiUrl); + allowStorageAccessByUserActivation(iframe); + const resolvedAPIUrl = new URL(apiUrl, remoteOrigin).toString(); + + await loadIframe(iframe, resolvedAPIUrl); + + const api = consumeAPI( + iframe.contentWindow!, + iframe.ownerDocument!.defaultView! + ); + await api.isConnected(); + await api.isReady(); + + return api; +} + +function loadIframe(iframe: HTMLIFrameElement, url: string): Promise { + return new Promise((resolve) => { + iframe.addEventListener('load', () => resolve(), { once: true }); + iframe.src = url; + }); +} + async function shouldUseBlueprintV2Handler( blueprint: StartPlaygroundWebOptions['blueprint'] ) { @@ -265,16 +315,28 @@ const remoteOrigin = * @param remoteHtmlUrl The URL for remote.html */ function assertLikelyCompatibleRemoteOrigin(remoteHtmlUrl: string) { - const url = new URL(remoteHtmlUrl, remoteOrigin); + assertLikelyCompatibleRemotePath(remoteHtmlUrl, '/remote.html'); +} + +function assertLikelyCompatibleAPIOrigin(apiUrl: string) { + assertLikelyCompatibleRemotePath(apiUrl, '/api.html'); +} + +function assertLikelyCompatibleRemotePath( + urlString: string, + expectedPath: '/remote.html' | '/api.html' +) { + const url = new URL(urlString, remoteOrigin); + const endpointName = expectedPath === '/api.html' ? 'API' : 'remote'; const validRemote = validRemoteOrigins.includes(url.origin) && - url.pathname === '/remote.html'; + url.pathname === expectedPath; if (!validRemote) { throw new Error( - `Invalid remote URL: ${url}. ` + - 'Expected remote URL to have a path of "/remote.html" based ' + + `Invalid ${endpointName} URL: ${url}. ` + + `Expected ${endpointName} URL to have a path of "${expectedPath}" based ` + `on one of the following origins:\n ${validRemoteOrigins.join( '\n' )}` diff --git a/packages/playground/website/api.html b/packages/playground/website/api.html new file mode 100644 index 00000000000..fd5f00508ff --- /dev/null +++ b/packages/playground/website/api.html @@ -0,0 +1,24 @@ + + + + WordPress Playground API + + + + + + + diff --git a/packages/playground/website/src/lib/boot-playground-api.spec.ts b/packages/playground/website/src/lib/boot-playground-api.spec.ts new file mode 100644 index 00000000000..9d7e37ee583 --- /dev/null +++ b/packages/playground/website/src/lib/boot-playground-api.spec.ts @@ -0,0 +1,35 @@ +import { bootPlaygroundAPI } from './boot-playground-api'; + +const mocks = vi.hoisted(() => ({ + setAPIReady: vi.fn(), +})); + +vi.mock('@php-wasm/universal', () => ({ + exposeAPI: vi.fn((api: object) => [ + mocks.setAPIReady, + vi.fn(), + { + ...api, + isConnected: vi.fn(async () => undefined), + isReady: vi.fn(async () => undefined), + }, + ]), +})); + +vi.mock('./state/opfs/opfs-site-storage', () => ({ + opfsSiteStorage: undefined, +})); + +describe('bootPlaygroundAPI', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('reports when OPFS site storage is unavailable', async () => { + const api = bootPlaygroundAPI(); + + await expect(api.exportSavedSiteAsZip('my-site')).rejects.toThrow( + 'OPFS site storage is unavailable in this context.' + ); + }); +}); diff --git a/packages/playground/website/src/lib/boot-playground-api.ts b/packages/playground/website/src/lib/boot-playground-api.ts new file mode 100644 index 00000000000..308fcc45566 --- /dev/null +++ b/packages/playground/website/src/lib/boot-playground-api.ts @@ -0,0 +1,18 @@ +import { exposeAPI } from '@php-wasm/universal'; +import type { PlaygroundAPIClient } from '@wp-playground/client'; +import { opfsSiteStorage } from './state/opfs/opfs-site-storage'; + +export function bootPlaygroundAPI() { + const [setAPIReady, , api] = exposeAPI({ + async exportSavedSiteAsZip(slug, options) { + if (!opfsSiteStorage) { + throw new Error( + 'OPFS site storage is unavailable in this context.' + ); + } + return await opfsSiteStorage.exportSavedSiteAsZip(slug, options); + }, + }); + setAPIReady(); + return api; +} diff --git a/packages/playground/website/src/lib/state/opfs/opfs-site-storage.spec.ts b/packages/playground/website/src/lib/state/opfs/opfs-site-storage.spec.ts index 59195e91133..c57ef71c048 100644 --- a/packages/playground/website/src/lib/state/opfs/opfs-site-storage.spec.ts +++ b/packages/playground/website/src/lib/state/opfs/opfs-site-storage.spec.ts @@ -253,7 +253,6 @@ describe('opfsSiteStorage', () => { create: true, }); wpAdmin.setFile('index.php', 'exclude admin'); - const wpAdminEntries = vi.spyOn(wpAdmin, 'entries'); const wpContent = await siteDirectory.getDirectoryHandle('wp-content', { create: true, }); @@ -265,7 +264,6 @@ describe('opfsSiteStorage', () => { create: true, }); cache.setFile('cached.html', 'exclude cache'); - const cacheEntries = vi.spyOn(cache, 'entries'); const zipFile = await storage.exportSavedSiteAsZip('patterns', { excludePatterns: [ @@ -280,14 +278,12 @@ describe('opfsSiteStorage', () => { expect(archive.files.has('wp-runtime.json')).toBe(false); expect(archive.files.has('wp-admin/index.php')).toBe(false); - expect(wpAdminEntries).not.toHaveBeenCalled(); expect(archive.directories.has('wp-content/')).toBe(true); expect(archive.files.get('wp-content/plugins/hello.php')).toBe( 'include plugin' ); expect(archive.directories.has('wp-content/cache/')).toBe(false); expect(archive.files.has('wp-content/cache/cached.html')).toBe(false); - expect(cacheEntries).not.toHaveBeenCalled(); }); it('does not export directories without saved Playground metadata', async () => { diff --git a/packages/playground/website/vite.config.ts b/packages/playground/website/vite.config.ts index 2448f282dd0..4d92a5e862f 100644 --- a/packages/playground/website/vite.config.ts +++ b/packages/playground/website/vite.config.ts @@ -262,6 +262,17 @@ export default defineConfig(({ command, mode }) => { { name: 'configure-server', configureServer(server: ViteDevServer) { + // Production serves api.html from the origin root. Preserve that + // URL in development even though the website uses a Vite base. + server.middlewares.use((req, _res, next) => { + if ( + req.url === '/api.html' || + req.url?.startsWith('/api.html?') + ) { + req.url = `/website-server${req.url}`; + } + next(); + }); if (process.env['PLAYGROUND_PR_PREVIEW_MOCKS'] === 'true') { registerPrPreviewMockMiddleware(server); } @@ -378,6 +389,7 @@ export default defineConfig(({ command, mode }) => { sourcemap: true, rollupOptions: { input: { + api: fileURLToPath(new URL('./api.html', import.meta.url)), index: fileURLToPath( new URL('./index.html', import.meta.url) ), @@ -454,6 +466,25 @@ export default defineConfig(({ command, mode }) => { if (id.includes('blueprint-editor')) { return 'optional/blueprint-editor'; } + + // Vite builds api.html and the website in one Rollup graph. In Rollup 4, + // a manual chunk also claims its static dependencies by default. + // The Blueprint editor and api.html both depend on OPFS storage, so without + // this rule the editor chunk claims that shared code. As a result, api.html + // must preload the large editor and CodeMirror chunks just to use OPFS. + // + // The proper fix is to enable `onlyExplicitManualChunks`, which makes manual + // chunks claim only the modules explicitly assigned to them. With our current + // imports, that produces circular chunks between the application and CodeMirror + // code, making their execution order unsafe. We must untangle those imports + // before enabling the option globally. + // + // Until then, assign the API entry to its own manual chunk. This keeps its static + // dependency graph, including OPFS storage, out of the optional editor chunk. + // See https://rollupjs.org/configuration-options/#output-onlyexplicitmanualchunks + if (id.endsWith('/src/lib/boot-playground-api.ts')) { + return 'opfs-site-storage'; + } }, assetFileNames: (chunkInfo) => { // Split Extensions or associated shared files into separate chunks