Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions packages/playground/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
ashfame marked this conversation as resolved.

## 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.
Expand Down
16 changes: 15 additions & 1 deletion packages/playground/client/src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ vi.mock('./blueprints-v2-handler', () => ({
BlueprintsV2Handler: mocks.BlueprintsV2Handler,
}));

import { startPlaygroundWeb } from './index';
import { startPlaygroundAPI, startPlaygroundWeb } from './index';

describe('startPlaygroundWeb', () => {
afterEach(() => {
Expand Down Expand Up @@ -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: '',
Expand Down
80 changes: 71 additions & 9 deletions packages/playground/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Blob | undefined>;
}
Comment thread
ashfame marked this conversation as resolved.

export interface StartPlaygroundAPIOptions {
iframe: HTMLIFrameElement;
apiUrl: string;
}

/**
* Loads playground in iframe and returns a PlaygroundClient instance.
*
Expand Down Expand Up @@ -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)
Expand All @@ -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<PlaygroundAPIClient> {
const { iframe, apiUrl } = options;
assertLikelyCompatibleAPIOrigin(apiUrl);
allowStorageAccessByUserActivation(iframe);
const resolvedAPIUrl = new URL(apiUrl, remoteOrigin).toString();

await loadIframe(iframe, resolvedAPIUrl);

const api = consumeAPI<PlaygroundAPIClient>(
iframe.contentWindow!,
iframe.ownerDocument!.defaultView!
);
await api.isConnected();
await api.isReady();

return api;
}

function loadIframe(iframe: HTMLIFrameElement, url: string): Promise<void> {
return new Promise((resolve) => {
iframe.addEventListener('load', () => resolve(), { once: true });
iframe.src = url;
});
}

async function shouldUseBlueprintV2Handler(
blueprint: StartPlaygroundWebOptions['blueprint']
) {
Expand Down Expand Up @@ -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(
Comment thread
ashfame marked this conversation as resolved.
'\n'
)}`
Expand Down
24 changes: 24 additions & 0 deletions packages/playground/website/api.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<!doctype html>
<html>
<head>
<title>WordPress Playground API</title>
</head>
<body>
<p id="api-description" hidden>
WordPress Playground uses this page to export saved sites. It isn't
intended to be opened directly.
</p>
<script>
if (window.self === window.top) {
document.getElementById('api-description').hidden = false;
}
</script>
<script type="module">
import { bootPlaygroundAPI } from './src/lib/boot-playground-api';

if (window.self !== window.top) {
window.playgroundAPI = bootPlaygroundAPI();
}
</script>
Comment thread
brandonpayton marked this conversation as resolved.
</body>
</html>
35 changes: 35 additions & 0 deletions packages/playground/website/src/lib/boot-playground-api.spec.ts
Original file line number Diff line number Diff line change
@@ -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.'
);
});
});
18 changes: 18 additions & 0 deletions packages/playground/website/src/lib/boot-playground-api.ts
Original file line number Diff line number Diff line change
@@ -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<PlaygroundAPIClient, unknown>({
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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand All @@ -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: [
Expand All @@ -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 () => {
Expand Down
31 changes: 31 additions & 0 deletions packages/playground/website/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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)
),
Expand Down Expand Up @@ -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
Expand Down
Loading