Skip to content

Commit 652e0af

Browse files
committed
Add lightweight saved-site export API
1 parent 3912eb4 commit 652e0af

8 files changed

Lines changed: 284 additions & 13 deletions

File tree

packages/playground/client/README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,32 @@ console.log(response.text);
2222

2323
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.
2424

25+
## Saved-site export API
26+
27+
Use `startPlaygroundAPI()` to work with saved OPFS Playgrounds without booting WordPress, PHP, workers, or a service worker. The API endpoint must have the same origin and browser storage partition as the Playground that saved the site. WebKit requires save and export to use the same top-level origin, including its scheme, host, and port; a site saved while Playground is top-level is not visible to an API iframe embedded under another top-level origin.
28+
29+
The `/api.html` entry point is part of the full Playground website deployment. It is not included in the `@wp-playground/remote` npm package.
30+
31+
```js
32+
import { startPlaygroundAPI } from 'https://playground.wordpress.net/client/index.js';
33+
34+
const iframe = document.createElement('iframe');
35+
iframe.hidden = true;
36+
iframe.sandbox.add('allow-scripts');
37+
iframe.sandbox.add('allow-same-origin');
38+
document.body.appendChild(iframe);
39+
40+
const api = await startPlaygroundAPI({
41+
iframe,
42+
apiUrl: 'https://playground.wordpress.net/api.html',
43+
});
44+
const zip = await api.exportSavedSiteAsZip('my-site', {
45+
excludePatterns: ['/*', '!/wp-content/', '!/wp-content/**'],
46+
});
47+
```
48+
49+
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.
50+
2551
## npm package
2652

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

packages/playground/client/src/index.spec.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => {
1313
BlueprintsV1Handler: vi.fn(),
1414
BlueprintsV2Handler: vi.fn(),
1515
createBlueprintReflection: vi.fn(),
16+
consumeAPI: vi.fn(),
1617
};
1718
});
1819

@@ -31,7 +32,15 @@ vi.mock('./blueprints-v2-handler', () => ({
3132
BlueprintsV2Handler: mocks.BlueprintsV2Handler,
3233
}));
3334

34-
import { startPlaygroundWeb } from './index';
35+
vi.mock('@php-wasm/universal', async (importActual) => {
36+
const actual = await importActual();
37+
return {
38+
...(actual as object),
39+
consumeAPI: mocks.consumeAPI,
40+
};
41+
});
42+
43+
import { startPlaygroundAPI, startPlaygroundWeb } from './index';
3544

3645
describe('startPlaygroundWeb', () => {
3746
afterEach(() => {
@@ -121,9 +130,69 @@ describe('startPlaygroundWeb', () => {
121130
});
122131
});
123132

133+
describe('startPlaygroundAPI', () => {
134+
afterEach(() => {
135+
vi.clearAllMocks();
136+
});
137+
138+
it('loads api.html and returns its API client', async () => {
139+
const api = createAPIClient();
140+
mocks.consumeAPI.mockReturnValue(api);
141+
const iframe = createIframe();
142+
143+
await expect(
144+
startPlaygroundAPI({
145+
iframe,
146+
apiUrl: 'http://localhost/api.html',
147+
})
148+
).resolves.toBe(api);
149+
150+
expect(iframe.src).toBe('http://localhost/api.html');
151+
expect(mocks.consumeAPI).toHaveBeenCalledWith(
152+
iframe.contentWindow,
153+
iframe.ownerDocument!.defaultView
154+
);
155+
expect(api.isConnected).toHaveBeenCalledTimes(1);
156+
expect(api.isReady).toHaveBeenCalledTimes(1);
157+
});
158+
159+
it('resolves a relative API URL against the default remote origin', async () => {
160+
mocks.consumeAPI.mockReturnValue(createAPIClient());
161+
const iframe = createIframe();
162+
163+
await startPlaygroundAPI({
164+
iframe,
165+
apiUrl: '/api.html',
166+
});
167+
168+
expect(iframe.src).toBe('https://playground.wordpress.net/api.html');
169+
});
170+
171+
it('requires the API URL to point to api.html', async () => {
172+
await expect(
173+
startPlaygroundAPI({
174+
iframe: createIframe(),
175+
apiUrl: 'http://localhost/remote.html',
176+
})
177+
).rejects.toThrow('/api.html');
178+
});
179+
});
180+
181+
function createAPIClient() {
182+
return {
183+
isConnected: vi.fn(async () => undefined),
184+
isReady: vi.fn(async () => undefined),
185+
exportSavedSiteAsZip: vi.fn(),
186+
};
187+
}
188+
124189
function createIframe() {
125190
return {
126191
src: '',
192+
contentWindow: {},
193+
ownerDocument: {
194+
defaultView: {},
195+
},
127196
addEventListener: vi.fn((_event, callback: () => void) => {
128197
callback();
129198
}),

packages/playground/client/src/index.ts

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ import type {
4646
PlaygroundClient,
4747
SiteThumbnail,
4848
} from '@wp-playground/remote';
49-
import type { PathAlias } from '@php-wasm/universal';
49+
import { consumeAPI, type PathAlias } from '@php-wasm/universal';
5050
import type { PHPWebExtension } from '@php-wasm/web';
5151
import { additionalRemoteOrigins } from './additional-remote-origins';
5252
// eslint-disable-next-line @nx/enforce-module-boundaries
@@ -147,6 +147,26 @@ export interface StartPlaygroundWebOptions extends Omit<
147147
onBlueprintValidated?: (blueprint: BlueprintDeclaration) => void;
148148
}
149149

150+
export interface ExportSavedSiteAsZipOptions {
151+
/**
152+
* Gitignore-style exclusion patterns applied relative to the saved site root.
153+
* Patterns starting with `!` re-include paths.
154+
*/
155+
excludePatterns?: readonly string[];
156+
}
157+
158+
export interface PlaygroundAPIClient {
159+
exportSavedSiteAsZip(
160+
slug: string,
161+
options?: ExportSavedSiteAsZipOptions
162+
): Promise<Blob | undefined>;
163+
}
164+
165+
export interface StartPlaygroundAPIOptions {
166+
iframe: HTMLIFrameElement;
167+
apiUrl: string;
168+
}
169+
150170
/**
151171
* Loads playground in iframe and returns a PlaygroundClient instance.
152172
*
@@ -196,6 +216,34 @@ export async function startPlaygroundWeb(
196216
return playground;
197217
}
198218

219+
/**
220+
* Loads the lightweight Playground API endpoint without booting WordPress.
221+
*
222+
* The API endpoint and saved OPFS site must share an origin and storage partition.
223+
*/
224+
export async function startPlaygroundAPI(
225+
options: StartPlaygroundAPIOptions
226+
): Promise<PlaygroundAPIClient> {
227+
const { iframe, apiUrl } = options;
228+
assertLikelyCompatibleAPIOrigin(apiUrl);
229+
allowStorageAccessByUserActivation(iframe);
230+
const resolvedAPIUrl = new URL(apiUrl, remoteOrigin).toString();
231+
232+
await new Promise((resolve) => {
233+
iframe.src = resolvedAPIUrl;
234+
iframe.addEventListener('load', resolve, false);
235+
});
236+
237+
const api = consumeAPI<PlaygroundAPIClient>(
238+
iframe.contentWindow!,
239+
iframe.ownerDocument!.defaultView!
240+
);
241+
await api.isConnected();
242+
await api.isReady();
243+
244+
return api;
245+
}
246+
199247
async function shouldUseBlueprintV2Handler(
200248
blueprint: StartPlaygroundWebOptions['blueprint']
201249
) {
@@ -265,16 +313,27 @@ const remoteOrigin =
265313
* @param remoteHtmlUrl The URL for remote.html
266314
*/
267315
function assertLikelyCompatibleRemoteOrigin(remoteHtmlUrl: string) {
268-
const url = new URL(remoteHtmlUrl, remoteOrigin);
316+
assertLikelyCompatibleRemotePath(remoteHtmlUrl, '/remote.html');
317+
}
318+
319+
function assertLikelyCompatibleAPIOrigin(apiUrl: string) {
320+
assertLikelyCompatibleRemotePath(apiUrl, '/api.html');
321+
}
322+
323+
function assertLikelyCompatibleRemotePath(
324+
urlString: string,
325+
expectedPath: string
326+
) {
327+
const url = new URL(urlString, remoteOrigin);
269328

270329
const validRemote =
271330
validRemoteOrigins.includes(url.origin) &&
272-
url.pathname === '/remote.html';
331+
url.pathname === expectedPath;
273332

274333
if (!validRemote) {
275334
throw new Error(
276335
`Invalid remote URL: ${url}. ` +
277-
'Expected remote URL to have a path of "/remote.html" based ' +
336+
`Expected remote URL to have a path of "${expectedPath}" based ` +
278337
`on one of the following origins:\n ${validRemoteOrigins.join(
279338
'\n'
280339
)}`
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<!doctype html>
2+
<html>
3+
<head>
4+
<title>WordPress Playground API</title>
5+
</head>
6+
<body>
7+
<script type="module">
8+
import { bootPlaygroundAPI } from './src/lib/boot-playground-api';
9+
10+
window.playgroundAPI = bootPlaygroundAPI();
11+
</script>
12+
</body>
13+
</html>
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { bootPlaygroundAPI } from './boot-playground-api';
2+
3+
const mocks = vi.hoisted(() => {
4+
const exportSavedSiteAsZip = vi.fn();
5+
return {
6+
exposeAPI: vi.fn(),
7+
exportSavedSiteAsZip,
8+
opfsSiteStorage: { exportSavedSiteAsZip } as
9+
| { exportSavedSiteAsZip: typeof exportSavedSiteAsZip }
10+
| undefined,
11+
setAPIReady: vi.fn(),
12+
};
13+
});
14+
15+
vi.mock('@php-wasm/universal', () => ({
16+
exposeAPI: mocks.exposeAPI,
17+
}));
18+
19+
vi.mock('./state/opfs/opfs-site-storage', () => ({
20+
get opfsSiteStorage() {
21+
return mocks.opfsSiteStorage;
22+
},
23+
}));
24+
25+
describe('bootPlaygroundAPI', () => {
26+
beforeEach(() => {
27+
mocks.opfsSiteStorage = {
28+
exportSavedSiteAsZip: mocks.exportSavedSiteAsZip,
29+
};
30+
});
31+
32+
afterEach(() => {
33+
vi.clearAllMocks();
34+
});
35+
36+
it('exposes the saved-site export API and forwards its options', async () => {
37+
const exposedAPI = { isReady: vi.fn() };
38+
mocks.exposeAPI.mockReturnValue([
39+
mocks.setAPIReady,
40+
vi.fn(),
41+
exposedAPI,
42+
]);
43+
const zip = new Blob(['zip'], { type: 'application/zip' });
44+
mocks.exportSavedSiteAsZip.mockResolvedValue(zip);
45+
46+
expect(bootPlaygroundAPI()).toBe(exposedAPI);
47+
const methods = mocks.exposeAPI.mock.calls[0][0];
48+
await expect(
49+
methods.exportSavedSiteAsZip('my-site', {
50+
excludePatterns: ['/*', '!/wp-content/**'],
51+
})
52+
).resolves.toBe(zip);
53+
54+
expect(mocks.exportSavedSiteAsZip).toHaveBeenCalledWith('my-site', {
55+
excludePatterns: ['/*', '!/wp-content/**'],
56+
});
57+
expect(mocks.setAPIReady).toHaveBeenCalledTimes(1);
58+
});
59+
60+
it('reports when OPFS site storage is unavailable', async () => {
61+
mocks.exposeAPI.mockReturnValue([
62+
mocks.setAPIReady,
63+
vi.fn(),
64+
{ isReady: vi.fn() },
65+
]);
66+
mocks.opfsSiteStorage = undefined;
67+
68+
bootPlaygroundAPI();
69+
const methods = mocks.exposeAPI.mock.calls[0][0];
70+
71+
await expect(methods.exportSavedSiteAsZip('my-site')).rejects.toThrow(
72+
'OPFS site storage is unavailable in this context.'
73+
);
74+
});
75+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { exposeAPI } from '@php-wasm/universal';
2+
import type { PlaygroundAPIClient } from '@wp-playground/client';
3+
import { opfsSiteStorage } from './state/opfs/opfs-site-storage';
4+
5+
export function bootPlaygroundAPI() {
6+
const [setAPIReady, , api] = exposeAPI<PlaygroundAPIClient, undefined>({
7+
async exportSavedSiteAsZip(slug, options) {
8+
if (!opfsSiteStorage) {
9+
throw new Error(
10+
'OPFS site storage is unavailable in this context.'
11+
);
12+
}
13+
return await opfsSiteStorage.exportSavedSiteAsZip(slug, options);
14+
},
15+
});
16+
setAPIReady();
17+
return api;
18+
}

packages/playground/website/src/lib/state/opfs/opfs-site-storage.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
type PHPConstants,
2222
getBlueprintDeclaration,
2323
} from '@wp-playground/blueprints';
24+
import type { ExportSavedSiteAsZipOptions } from '@wp-playground/client';
2425
import type { AllPHPVersion } from '@php-wasm/universal';
2526
import { RecommendedPHPVersion } from '@wp-playground/common';
2627
import {
@@ -65,14 +66,6 @@ type StoredSiteChanges = {
6566
originalUrlParams?: OriginalUrlParams;
6667
};
6768

68-
export interface ExportSavedSiteAsZipOptions {
69-
/**
70-
* Gitignore-style exclusion patterns applied relative to the saved site root.
71-
* Patterns starting with `!` re-include paths.
72-
*/
73-
excludePatterns?: readonly string[];
74-
}
75-
7669
let opfsSitesRoot: FileSystemDirectoryHandle | undefined = undefined;
7770
try {
7871
opfsSitesRoot = await navigator.storage.getDirectory();

0 commit comments

Comments
 (0)