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
44 changes: 44 additions & 0 deletions packages/playground/personal-wp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,50 @@ Response:
Blueprint URLs must be `https:`, `data:`, or local `http:` URLs. Dependent tabs
cannot install blueprints and will return an error result.

#### Backup Site

Use `backup-site` to ask Personal Playground to zip the current site and hand the
file to the browser's downloader — the same backup the Site Tools panel produces.
Dependent tabs forward the request to the active tab, which is where the download
then appears.

```js
window.parent.postMessage(
{
type: 'relay',
relayType: 'backup-site',
requestId,
},
'*'
);
```

Response:

```ts
{
type: 'relay';
relayType: 'backup-site-result';
requestId?: string;
status: 'started' | 'success' | 'error';
error?: string;
}
```

`started` is sent as soon as the request is accepted, before the site is zipped.
Waiting for it lets a page tell a slow backup apart from an older Personal
Playground that ignores the message, so it can fall back to pointing at Site
Tools. `success` or `error` follows once the zip is done.

A request that arrives before the site has finished coming up waits for it, up
to 15 seconds, rather than being refused — the page doing the asking was served
by that site, so it is generally the shell's state that is behind.

No confirmation dialog is shown: the file only reaches the user's own disk, the
requesting page never gets to read it, and a browser download is visible anyway.
Only one backup runs at a time; a request that arrives while one is in flight
comes back as an error.

### Offline Support

Works as a Progressive Web App (PWA) for offline use. Install it on your device for a native app-like experience.
Expand Down
103 changes: 103 additions & 0 deletions packages/playground/personal-wp/playwright/e2e/relay-backup.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import type { FrameLocator } from '@playwright/test';
import { test, expect } from '../playground-fixtures';

// The `backup-site` relay message lets a WordPress page (e.g. the My Apps
// "move to hosting" guide) start the same site backup the Site Tools panel
// offers, without the user hunting for it.
test('should download a site backup requested by a relay message', async ({
website,
wordpress,
}) => {
await website.goto('./');
// WordPress renders behind the loading screen, so a non-empty frame body
// is not yet a booted site: while the loading screen is up, a backup is
// refused the same way the Site Tools button is unavailable. Wait for it
// to go away.
await expect(
website.page.getByRole('progressbar', { name: 'Loading WordPress' })
).toHaveCount(0);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const downloadPromise = website.page.waitForEvent('download');
const [result] = await requestBackups(wordpress, 1);

// 'started' is what tells a caller the message was understood at all.
expect(result).toEqual({ statuses: ['started', 'success'] });

const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/-backup-.*\.zip$/);
});

// Two requests in the same turn read the same React state, so only a lock the
// hook holds itself can keep them from zipping the site twice.
test('should run one backup for two requests made in the same turn', async ({
website,
wordpress,
}) => {
await website.goto('./');
await expect(
website.page.getByRole('progressbar', { name: 'Loading WordPress' })
).toHaveCount(0);

const downloads: string[] = [];
website.page.on('download', (download) =>
downloads.push(download.suggestedFilename())
);

const results = await requestBackups(wordpress, 2);

expect(results.map((result) => result.statuses.at(-1)).sort()).toEqual([
'error',
'success',
]);
expect(downloads).toHaveLength(1);
});

/**
* Post `count` backup requests from the WordPress frame in one turn and
* resolve once each has reported a final status.
*/
function requestBackups(wordpress: FrameLocator, count: number) {
return wordpress.locator('body').evaluate(
(_body, requestCount: number) =>
Promise.all(
Array.from(
{ length: requestCount },
(_, index) =>
new Promise<{ statuses: string[]; error?: string }>(
(resolve) => {
const requestId = `e2e-backup-request-${index}`;
const statuses: string[] = [];
window.addEventListener('message', (event) => {
const data = event.data;
if (
!data ||
data.type !== 'relay' ||
data.relayType !==
'backup-site-result' ||
data.requestId !== requestId
) {
return;
}
statuses.push(data.status);
if (data.status !== 'started') {
resolve({
statuses,
error: data.error,
});
}
});
window.parent.postMessage(
{
type: 'relay',
relayType: 'backup-site',
requestId,
},
'*'
);
}
)
)
),
count
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -1081,6 +1081,11 @@ function SeamlessViewport({ siteSlug }: { siteSlug: string }) {
!isBooting &&
(isDependentMode ? mainTabStatus === 'connected' : !!playground);
const hasActiveSiteError = activeSiteError && activeSiteSlug === siteSlug;
// A relay request can arrive while the site is still coming up, and it has
// to read the state as of that moment rather than as of the render that
// registered the listener.
const backupStateRef = useRef({ canBackupNow, performBackup });
backupStateRef.current = { canBackupNow, performBackup };

const loadingScreenHtml = useMemo(
() =>
Expand Down Expand Up @@ -1484,6 +1489,14 @@ function SeamlessViewport({ siteSlug }: { siteSlug: string }) {
);
if (installBlueprintMessage) {
void installBlueprintFromRelay(event, installBlueprintMessage);
return;
}

const backupSiteMessage = getBackupSiteMessageData(
relayValidation.data
);
if (backupSiteMessage) {
void backupSiteFromRelay(event, backupSiteMessage);
}
}
window.addEventListener('message', handleMessage);
Expand All @@ -1493,8 +1506,11 @@ function SeamlessViewport({ siteSlug }: { siteSlug: string }) {
}, [
applyBlueprint,
applyBlueprintInMainTab,
canBackupNow,
hasLocalRuntimeClient,
isDependentMode,
mainTabStatus,
performBackup,
requestBlueprintInstallConfirmation,
siteSlug,
url,
Expand Down Expand Up @@ -1566,6 +1582,56 @@ function SeamlessViewport({ siteSlug }: { siteSlug: string }) {
});
}

/**
* Zip the current site for a `backup-site` relay message and report back.
*
* Runs the same backup as the Site Tools button, so a dependent tab
* forwards to the active tab and the download appears there.
*/
async function backupSiteFromRelay(
event: MessageEvent,
message: BackupSiteMessageData
) {
const { requestId } = message;
// The page that asked was served by this very site, so a site that
// looks unready here is almost always one whose state has not caught
// up yet — worth a short wait before refusing.
if (!(await waitForBackupReadiness(backupStateRef))) {
postBackupSiteResult(event, {
requestId,
status: 'error',
error: isDependentMode
? getMainTabUnavailableMessage(mainTabStatus)
: 'Playground is not ready.',
});
return;
}

// Zipping a site takes a while. Acknowledge the request right away so
// the requesting page can tell a slow backup apart from a Personal
// Playground that does not support this message at all.
postBackupSiteResult(event, { requestId, status: 'started' });

try {
const succeeded = await backupStateRef.current.performBackup();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
postBackupSiteResult(event, {
requestId,
status: succeeded ? 'success' : 'error',
...(succeeded
? {}
: {
error: 'The backup could not be created. Another backup may still be running.',
}),
});
} catch (e) {
postBackupSiteResult(event, {
requestId,
status: 'error',
error: getErrorMessage(e),
});
}
}

// Reflect the WordPress URL in the browser's address bar.
useEffect(() => {
if (!url) {
Expand Down Expand Up @@ -2156,6 +2222,12 @@ type InstallBlueprintMessageData = {
requestId?: string;
};

type BackupSiteMessageData = {
type: 'relay';
relayType: 'backup-site';
requestId?: string;
};

type BlueprintInstallDialogRequest = {
blueprintUrl: string;
};
Expand Down Expand Up @@ -2197,6 +2269,14 @@ type InstallBlueprintResultMessage = {
error?: string;
};

type BackupSiteResultMessage = {
type: 'relay';
relayType: 'backup-site-result';
requestId?: string;
status: 'started' | 'success' | 'error';
error?: string;
};

function getRelayMessageValidation(
event: MessageEvent,
iframe: HTMLIFrameElement | null
Expand Down Expand Up @@ -2244,6 +2324,38 @@ function getInstallBlueprintMessageData(
};
}

/**
* Wait until the site can serve a backup, up to `timeoutMs`.
*
* Resolves to whether it got there.
*/
async function waitForBackupReadiness(
stateRef: { current: { canBackupNow: boolean } },
timeoutMs = 15000
): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (!stateRef.current.canBackupNow && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
return stateRef.current.canBackupNow;
}

/**
* Read a validated relay message as a backup request, if that is what it is.
*/
function getBackupSiteMessageData(
data: RelayMessageData
): BackupSiteMessageData | undefined {
if (data.relayType !== 'backup-site') {
return;
}
return {
type: 'relay',
relayType: 'backup-site',
requestId: getRequestId(data),
};
}

function getRequestId(data: RelayMessageData): string | undefined {
return typeof data.requestId === 'string' ? data.requestId : undefined;
}
Expand Down Expand Up @@ -2309,6 +2421,26 @@ function postInstallBlueprintResult(
);
}

/**
* Send a `backup-site-result` back to the frame that asked for the backup.
*/
function postBackupSiteResult(
event: MessageEvent,
result: Omit<BackupSiteResultMessage, 'type' | 'relayType'>
) {
if (!event.source) {
return;
}
(event.source as Window).postMessage(
{
type: 'relay',
relayType: 'backup-site-result',
...result,
} satisfies BackupSiteResultMessage,
event.origin
);
}

function isMessageFromIframeTree(
event: MessageEvent,
iframe: HTMLIFrameElement | null
Expand Down
Loading
Loading