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.

## 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>;
}

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(
'\n'
)}`
Expand Down
35 changes: 20 additions & 15 deletions packages/playground/remote/service-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,23 +52,22 @@
* While this strategy enables fast load times and an offline experience, it also
* creates a substantial challenge.
*
* When a new Playground version is deployed, all the clients will load an old
* version of the `remote.html` file on their next visit. Unfortunately, that old
* `remote.html` file contains hardcoded references to assets that may not be
* cached and no longer exist in the new webapp build.
* When a new Playground version is deployed, clients may load an old entry
* document such as `remote.html` or `api.html`. That document contains
* hardcoded references to assets that may no longer exist in the new build.
*
* To solve this problem, we use the **Network first** strategy when `remote.html`
* is requested. This introduces a small network overhead, but it guarantees loading
* the most recent version of `remote.html` and all the referenced assets.
* To solve this problem, we use the **Network first** strategy for entry
* documents. This introduces a small network overhead, but guarantees loading
* the most recent document and all its referenced assets.
*
* Similarly, we use the **Network first** strategy for the `/` path. This is
* useful in situations where the user didn't visit Playground in a while,
* they have a stale version of the `/` route cached, and they open Playground.
* If we loaded the cached version, they'd see the old Playground website on their
* first visit and then the new Playground website only on their second visit.
*
* There's still a small window of time between loading the remote.html file and
* fetching the new assets when a new deployment would break the application.
* There's still a small window between loading an entry document and fetching
* its assets when a new deployment would break the application.
* This should be very rare, but when it happens we provide an error message asking
* the user to reload the page.
*
Expand Down Expand Up @@ -365,7 +364,8 @@ self.addEventListener('fetch', (event) => {
}

/**
* Always fetch the fresh version of `/remote.html` and `/` from the network.
* Always fetch fresh versions of `/remote.html`, `/api.html`, and `/` from
* the network.
*
* This is the secret sauce that enables seamless upgrades of the
* running Playground clients when a new version is deployed on
Expand All @@ -374,13 +374,14 @@ self.addEventListener('fetch', (event) => {
* ## The problem with deployments
*
* App deployments remove all the static assets associated with the
* previous app version. Meanwhile, the remote.html file we've cached
* for offline usage still holds references to those assets.
* previous app version. Meanwhile, cached entry documents still hold
* references to those assets.
*
* If we just loaded the cached remote.html file, the site would crash
* If we just loaded a cached entry document, the client would crash
* with seemingly random errors.
*
* Instead, we fetch the most recent version of remote.html from the network.
* Instead, we fetch the most recent version of each entry document from
* the network.
* It references the static assets that are now available on the server and
* should work just fine.
*
Expand All @@ -392,7 +393,11 @@ self.addEventListener('fetch', (event) => {
* https://github.com/WordPress/wordpress-playground/issues/1821 for more
* details.
*/
if (url.pathname === '/remote.html' || url.pathname === '/') {
if (
url.pathname === '/remote.html' ||
url.pathname === '/api.html' ||
url.pathname === '/'
) {
event.respondWith(networkFirstFetch(event.request));
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,8 @@ function playground_get_custom_response_headers( $requested_path ) {
);
} elseif (
'/' === $requested_path ||
'/index.html' === $requested_path
'/index.html' === $requested_path ||
'/api.html' === $requested_path
) {
return array( 'Cache-Control: max-age=0, no-cache, no-store, must-revalidate' );
} elseif (
Expand Down
7 changes: 7 additions & 0 deletions packages/playground/website-deployment/tests.php
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@ function () {
'My WordPress relay endpoint should not be edge cached'
);

$api_headers = playground_get_custom_response_headers( '/api.html' );
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'
);

$mywp_event_server_snapshot = $_SERVER;

$_SERVER['HTTP_HOST'] = 'my.wordpress.net';
Expand Down
4 changes: 2 additions & 2 deletions packages/playground/website/.htaccess
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
Options +Multiviews
AddEncoding x-gzip .gz

<FilesMatch "index\.html">
<FilesMatch "index\.html|api\.html">

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

As I understand it, remote.html can be added here for completeness. But it's not needed, since service worker has explicit behavior around not caching it. This is only relevant for a self-hoster wanting to use apache, but even then won't make a difference. So, I would rather not touch it for now.

Header unset ETag
Header set Cache-Control "max-age=0, no-cache, no-store, must-revalidate"
</FilesMatch>
<FilesMatch "index\.js|blueprint-schema\.json|logger.php|wp-cli.phar|wordpress-importer.zip|php-code-snippet\.js">
Header set Access-Control-Allow-Origin "*"
Header unset ETag
Header set Cache-Control "max-age=0, no-cache, no-store, must-revalidate"
</FilesMatch>
</FilesMatch>
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>
</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;
}
Loading
Loading