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
221 changes: 214 additions & 7 deletions packages/playground/remote/service-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,8 +212,17 @@ self.addEventListener('fetch', (event) => {
return;
}

if (url.pathname === '/feature-detect/document-isolation-policy.html') {
return event.respondWith(documentIsolationPolicyHtml());
}

if (isURLScoped(url)) {
return event.respondWith(handleScopedRequest(event, getURLScope(url)!));
const scope = getURLScope(url)!;
return event.respondWith(
handleScopedRequest(event, scope).then((response) =>
rewriteCoopHeadersToDocumentIsolationPolicy(response, scope)
)
);
}

let referrerUrl;
Expand All @@ -224,8 +233,11 @@ self.addEventListener('fetch', (event) => {
}

if (referrerUrl && isURLScoped(referrerUrl)) {
const scope = getURLScope(referrerUrl)!;
return event.respondWith(
handleScopedRequest(event, getURLScope(referrerUrl)!)
handleScopedRequest(event, scope).then((response) =>
rewriteCoopHeadersToDocumentIsolationPolicy(response, scope)
)
);
}

Expand Down Expand Up @@ -323,7 +335,7 @@ async function handleScopedRequest(event: FetchEvent, scope: string) {
const fullUrl = new URL(event.request.url);
const unscopedUrl = removeURLScope(fullUrl);
if (fullUrl.pathname.endsWith('/wp-includes/empty.html')) {
return emptyHtml();
return emptyHtml(scope);
}

const workerResponse = await convertFetchEventToPHPRequest(event);
Expand Down Expand Up @@ -504,15 +516,34 @@ window.__playground_ControlledIframe = window.wp.element.forwardRef(function (pr

/**
* The empty HTML file loaded by the patched editor iframe.
*
* @param scope The scope of the request, used to determine whether cross-origin isolation is needed
*/
function emptyHtml() {
function emptyHtml(scope: string) {
const headers: Record<string, string> = {
'content-type': 'text/html',
};

/**
* Only add Document-Isolation-Policy when the parent page also has cross-origin
* isolation headers (COEP/COOP that were rewritten to Document-Isolation-Policy).
*
* Without this header in empty.html, Gutenberg fails to populate the editor iframe
* with the editor markup when the editor page is loaded with COOP/COEP headers set.
*
* However, adding this header unconditionally breaks REST API authentication because
* `isolate-and-credentialless` causes cross-origin requests to be sent without
* credentials (cookies), resulting in "Session expired" errors.
*/
if (scopesWithCrossOriginIsolation.has(scope)) {
headers['Document-Isolation-Policy'] = 'isolate-and-credentialless';
}

return new Response(
'<!doctype html><script>const hash = window.location.hash.substring(1); if ( hash ) document.write(decodeURIComponent(hash))</script>',
{
status: 200,
headers: {
'content-type': 'text/html',
},
headers,
}
);
}
Expand All @@ -534,3 +565,179 @@ async function getScopedWpDetails(scope: string): Promise<WPModuleDetails> {
}
return scopeToWpModule[scope];
}

/**
* Rewrites COEP/COOP headers to the newer Document-Isolation-Policy spec
* in browsers that support it.
*
* ## Origin isolation
*
* The client-side media processing experiment relies on SharedArrayBuffer support.
* However, SharedArrayBuffer is only available in cross-origin isolated contexts. The
* usual way of achieving cross-origin isolation is via the Cross-Origin-Embedder-Policy (COEP)
* and Cross-Origin-Resource-Policy (CORP) headers.
*
* However, COEP/COOP are viral-ish. Once a part of a site sets them, the rest of the site must
* follow. This breaks external embeds, like YouTube videos, that don't set the necessary headers.
* Serving them by default on the entire playground.wordpress.net site would break existing
* WordPress features.
*
* Gutenberg only uses them in the block editor iframe and only when the
* client-side media processing experiment is enabled. This is fine for native WordPress, where
* navigating between wp-admin pages triggers a full page reload, but it's problematic in
* Playground, where the top-level page remains open the entire time you use WordPress.
*
* ## Document-Isolation-Policy
*
* There is a newer specification called Document-Isolation-Policy:
*
* https://developer.chrome.com/blog/document-isolation-policy
*
* That spec enables origin isolation on a per-document basis, without affecting the rest of the
* site. It also supports embedding external resources that don't set COEP/COOP headers. This is
* exactly what we need for Playground.
*
* In a perfect world, we could just make WordPress use that header. However, it is not
* widely supported yet and WordPress would have no easy way of detecting that support
* server-side.
*
* ## Header rewriting
*
* Playground rewrites the COEP/COOP headers to Document-Isolation-Policy in the supporting
* browsers. The support is decided using feature detection. As more browsers implement the
* specification, they'll automatically start receiving the new header and a better experience.
*
*
* @see boot-playground-remote.ts for the other part of the feature detection logic.
* @see https://github.com/WordPress/wordpress-playground/issues/2954
* @see https://developer.chrome.com/blog/document-isolation-policy
*/
/**
* Whether the browser supports Document-Isolation-Policy.
* This is set via the 'message' event listener below.
*/
let browserSupportsDocumentIsolationPolicy: boolean | undefined;

/**
* Scopes that have cross-origin isolation enabled (COEP headers were rewritten to
* Document-Isolation-Policy). This is used to determine whether empty.html should
* also have Document-Isolation-Policy header.
*/
const scopesWithCrossOriginIsolation = new Set<string>();

self.addEventListener('message', (event) => {
if (event.data?.type === 'document-isolation-policy-support-check') {
browserSupportsDocumentIsolationPolicy = event.data.supported === true;
}
});

/**
* Rewrites COEP/COOP headers to Document-Isolation-Policy for browsers that support it.
*
* When the browser supports Document-Isolation-Policy, this function:
* - Removes Cross-Origin-Embedder-Policy (COEP) header
* - Removes Cross-Origin-Opener-Policy (COOP) header
* - Adds Document-Isolation-Policy: isolate-and-credentialless
*
* This enables cross-origin isolation (for SharedArrayBuffer) without breaking
* external embeds like YouTube videos that don't set COEP/COOP headers.
*
* @param response The response to potentially modify
* @param scope The scope of the request, used to track which scopes have cross-origin isolation
* @returns A new Response with rewritten headers, or the original response if no rewriting is needed
*/
function rewriteCoopHeadersToDocumentIsolationPolicy(
response: Response,
scope: string
): Response {
// If we don't know whether the browser supports Document-Isolation-Policy,
// or if it doesn't support it, return the original response unchanged.
if (!browserSupportsDocumentIsolationPolicy) {
return response;
}

// Check if the response has COEP or COOP headers that we should rewrite
if (
!response.headers.has('cross-origin-embedder-policy') &&
!response.headers.has('cross-origin-opener-policy')
) {
return response;
}

// Only rewrite if the response has COEP headers that indicate cross-origin isolation intent.
// COOP alone doesn't achieve cross-origin isolation, so we key off COEP.
const coep = response.headers.get('cross-origin-embedder-policy');
if (!coep || (coep !== 'require-corp' && coep !== 'credentialless')) {
return response;
}

/**
* Map COEP value to the equivalent Document-Isolation-Policy value.
* - require-corp → isolate-and-require-corp (strict: requires CORP/CORS on all resources)
* - credentialless → isolate-and-credentialless (relaxed: strips credentials instead)
*
* ## Mapping explanation
*
* COEP has three values:
* - `unsafe-none` (default): No cross-origin restrictions
* - `require-corp`: Cross-origin resources must have CORP header or use CORS
* - `credentialless`: Cross-origin no-cors requests sent without credentials
*
* Document-Isolation-Policy has two values that map directly to COEP's isolation modes:
* - `isolate-and-require-corp` ← COEP: require-corp
* - `isolate-and-credentialless` ← COEP: credentialless
*
* COOP is not directly mapped because Document-Isolation-Policy inherently provides the
* cross-origin isolation that COOP: same-origin would provide, but without breaking
* cross-origin popup communication.
*/
const dipValue =
coep === 'require-corp'
? 'isolate-and-require-corp'
: 'isolate-and-credentialless';

const newHeaders = new Headers(response.headers);
newHeaders.delete('cross-origin-embedder-policy');
newHeaders.delete('cross-origin-opener-policy');
newHeaders.set('document-isolation-policy', dipValue);

// Track that this scope has cross-origin isolation enabled so that
// empty.html (the editor iframe) can also get the Document-Isolation-Policy header.
scopesWithCrossOriginIsolation.add(scope);

return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: newHeaders,
});
}

/**
* Serves a minimal HTML document with the `Document-Isolation-Policy` header
* for feature detection.
*
* The document is served at `/feature-detection/document-isolation-policy.html` and
* with the `Document-Isolation-Policy` header. SharedArrayBuffer is only available
* in this document if the browser supports `Document-Isolation-Policy`.
*
* @see rewriteCoopHeadersToDocumentIsolationPolicy
*/
function documentIsolationPolicyHtml() {
return new Response(
`<!doctype html><script>
window.parent.postMessage(
{
supported: typeof SharedArrayBuffer !== 'undefined'
},
'*'
);
</script>`,
{
status: 200,
headers: {
'content-type': 'text/html',
'document-isolation-policy': 'isolate-and-credentialless',
},
}
);
}
Loading