From 58acf8c483bae2a5f2e7749be85424c0bf855e07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 20 Nov 2025 11:51:42 +0100 Subject: [PATCH 1/6] Explore making all iframes controlled --- packages/playground/remote/service-worker.ts | 215 +++++++++++++++++- .../lib/playground-mu-plugin/0-playground.php | 2 +- 2 files changed, 211 insertions(+), 6 deletions(-) diff --git a/packages/playground/remote/service-worker.ts b/packages/playground/remote/service-worker.ts index 8603ee987b..99509d00fa 100644 --- a/packages/playground/remote/service-worker.ts +++ b/packages/playground/remote/service-worker.ts @@ -121,7 +121,7 @@ import { shouldCacheUrl, } from './src/lib/offline-mode-cache'; -if (!(self as any).document) { +if (!self.document) { // Workaround: vite translates import.meta.url // to document.currentScript which fails inside of // a service worker because document is undefined @@ -192,6 +192,178 @@ self.addEventListener('activate', function (event) { } event.waitUntil(doActivate()); }); +// sw.ts +declare const self: ServiceWorkerGlobalScope; + +// Derive scope once and use it to build every path. +const SCOPE = new URL(self.registration.scope).pathname.replace(/\/$/, ''); +const VIRTUAL_PREFIX = `${SCOPE}/__iframes/`; +const LOADER_PATH = `${SCOPE}/wp-includes/empty.html`; +const BOOTSTRAP_URL = `${SCOPE}/__bootstrap/controlled-iframes.js`; + +// Paste the compiled JS from controlled-iframes.ts here. +const BOOTSTRAP_JS = ` +(() => { + if ((window).__controlled_iframes__) return; + (window).__controlled_iframes__ = true; + + const BUCKET = 'iframe-virtual-docs-v1'; + + // Get the SW scope path, reliably. Works in top pages and in loader-created iframes. + let scopePath = (document.currentScript)?.dataset.scope || null; + const scopePromise = (async () => { + if (scopePath) return scopePath.replace(/\\/$/, ''); + const reg = await navigator.serviceWorker.ready; + scopePath = new URL(reg.scope).pathname.replace(/\\/$/, ''); + return scopePath; + })(); + + const native = { + setAttribute: Element.prototype.setAttribute, + src: Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'src'), + srcdoc: Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'srcdoc'), + }; + + const uid = () => \`${Date.now().toString(36)}-${Math.random() + .toString(36) + .slice(2, 10)}\`; + + async function paths() { + const scope = await scopePromise; + return { + VIRTUAL_PREFIX: \`\${scope}/__iframes/\`, + LOADER_PATH: \`\${scope}/wp-includes/empty.html\`, + }; + } + + async function putVirtual(id, html) { + const cache = await caches.open(BUCKET); + const { VIRTUAL_PREFIX } = await paths(); + await cache.put(\`${VIRTUAL_PREFIX}\${id}.html\`, new Response(html, { + headers: { 'Content-Type': 'text/html; charset=utf-8' } + })); + } + + async function makeLoaderUrl(id, prettyUrl) { + const { LOADER_PATH } = await paths(); + const qs = new URLSearchParams({ id, base: document.baseURI, url: prettyUrl ?? '' }).toString(); + return \`${LOADER_PATH}#\${qs}\`; + } + + async function rewriteToLoader(iframe, html, prettyUrl) { + const id = uid(); + await putVirtual(id, html); // ensure blob exists first + const url = await makeLoaderUrl(id, prettyUrl); // then navigate + native.src.set.call(iframe, url); + } + + async function handleSrcdoc(iframe, html) { + await rewriteToLoader(iframe, html); + } + async function handleDataUrl(iframe, url) { + const html = await (await fetch(url)).text(); + await rewriteToLoader(iframe, html); + } + async function handleBlobUrl(iframe, url) { + const html = await (await fetch(url)).text(); + await rewriteToLoader(iframe, html); + } + + Object.defineProperty(HTMLIFrameElement.prototype, 'srcdoc', { + configurable: true, + get: native.srcdoc.get.bind(HTMLIFrameElement.prototype), + set(value) { + console.log('srcdoc set', value); + void handleSrcdoc(this, String(value)); + } + }); + + Object.defineProperty(HTMLIFrameElement.prototype, 'src', { + configurable: true, + get: native.src.get.bind(HTMLIFrameElement.prototype), + set(value) { + console.log('src set', value); + const v = String(value); + if (v.startsWith('data:text/html')) { void handleDataUrl(this, v); return; } + if (v.startsWith('blob:')) { void handleBlobUrl(this, v); return; } + native.src.set.call(this, v); + } + }); + + Element.prototype.setAttribute = function(name, value) { + if (this instanceof HTMLIFrameElement) { + const n = name.toLowerCase(); + const v = String(value); + if (n === 'srcdoc') { void handleSrcdoc(this, v); return; } + if (n === 'src') { + if (v.startsWith('data:text/html')) { void handleDataUrl(this, v); return; } + if (v.startsWith('blob:')) { void handleBlobUrl(this, v); return; } + } + } + return native.setAttribute.call(this, name, value); + }; + + function process(node) { + console.log('process', node); + if (node instanceof HTMLIFrameElement) { + const sd = node.getAttribute('srcdoc'); + if (sd != null) { void handleSrcdoc(node, sd); return; } + const s = node.getAttribute('src') || ''; + if (s.startsWith('data:text/html')) { void handleDataUrl(node, s); return; } + if (s.startsWith('blob:')) { void handleBlobUrl(node, s); return; } + } else if (node instanceof Element) { + node.querySelectorAll('iframe').forEach(process); + } + } + + process(document.documentElement); + + new MutationObserver(muts => { + for (const m of muts) for (const n of m.addedNodes) process(n); + }).observe(document.documentElement, { childList: true, subtree: true }); + + // Optional: kill the flash while normalizing + const style = document.createElement('style'); + style.textContent = 'iframe{visibility:hidden} iframe[data-controlled="1"]{visibility:visible}'; + document.documentElement.appendChild(style); +})();`; + +self.addEventListener('install', (e) => e.waitUntil(self.skipWaiting())); +self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim())); + +const LOADER_HTML = + `'; }); add_action('init', 'networking_disabled'); From 76af9ec1f304e12207f2e719083377f943c942d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 20 Nov 2025 13:36:16 +0100 Subject: [PATCH 2/6] Generalized support for controlling all the iframes --- packages/playground/remote/iframes-trap.js | 274 +++++++++++++++++++ packages/playground/remote/service-worker.ts | 224 +++++---------- 2 files changed, 344 insertions(+), 154 deletions(-) create mode 100644 packages/playground/remote/iframes-trap.js diff --git a/packages/playground/remote/iframes-trap.js b/packages/playground/remote/iframes-trap.js new file mode 100644 index 0000000000..b5d9b58fa9 --- /dev/null +++ b/packages/playground/remote/iframes-trap.js @@ -0,0 +1,274 @@ +'use strict'; +var _a, _b, _c; +const __once = window.__controlled_iframes_loaded__; +if (__once) { + /* already loaded */ +} +window.__controlled_iframes_loaded__ = true; +const BUCKET = 'iframe-virtual-docs-v1'; +// Best-effort synchronous scope guess so we can seed src immediately in createElement +const SYNC_SCOPE_GUESS = + ((_a = document.currentScript) === null || _a === void 0 + ? void 0 + : _a.dataset.scope) || + ((_c = + (_b = location.pathname.match(/^\/scope:[^/]+/)) === null || + _b === void 0 + ? void 0 + : _b[0]) !== null && _c !== void 0 + ? _c + : ''); +// Async authoritative scope from the SW registration +const scopePromise = (async () => { + try { + const reg = await navigator.serviceWorker.ready; + return new URL(reg.scope).pathname.replace(/\/$/, ''); + } catch (_a) { + return SYNC_SCOPE_GUESS.replace(/\/$/, ''); + } +})(); +function scopedPaths(scope) { + const base = scope.replace(/\/$/, ''); + return { + VIRTUAL_PREFIX: `${base}/__iframes/`, + LOADER_PATH: `${base}/wp-includes/empty.html`, + }; +} +// Capture natives up front +const Native = { + createElement: Document.prototype.createElement, + setAttribute: Element.prototype.setAttribute, + // Accessors + iframeSrc: Object.getOwnPropertyDescriptor( + HTMLIFrameElement.prototype, + 'src' + ), + iframeSrcdoc: Object.getOwnPropertyDescriptor( + HTMLIFrameElement.prototype, + 'srcdoc' + ), +}; +function setIframeSrc(el, url) { + var _a; + if ((_a = Native.iframeSrc) === null || _a === void 0 ? void 0 : _a.set) { + Reflect.apply(Native.iframeSrc.set, el, [url]); + } else { + Reflect.apply(Native.setAttribute, el, ['src', url]); + } +} +function setIframeSrcdoc(el, html) { + var _a; + if ( + (_a = Native.iframeSrcdoc) === null || _a === void 0 ? void 0 : _a.set + ) { + Reflect.apply(Native.iframeSrcdoc.set, el, [html]); + } else { + Reflect.apply(Native.setAttribute, el, ['srcdoc', html]); + } +} +const uid = () => + `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +async function putVirtual(id, html) { + const cache = await caches.open(BUCKET); + const scope = await scopePromise; + const { VIRTUAL_PREFIX } = scopedPaths(scope); + await cache.put( + `${VIRTUAL_PREFIX}${id}.html`, + new Response(html, { + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }) + ); +} +async function toLoaderUrl(opts) { + const { id, prettyUrl, base } = Object.assign( + { base: document.baseURI, prettyUrl: '' }, + opts + ); + const scope = await scopePromise; + const { LOADER_PATH } = scopedPaths(scope); + const qs = new URLSearchParams({ + base, + url: prettyUrl !== null && prettyUrl !== void 0 ? prettyUrl : '', + }); + if (id) { + qs.set('id', id); + } + return `${LOADER_PATH}#${qs.toString()}`; +} +async function rewriteSrcdoc(el, html, opts = {}) { + const id = uid(); + await putVirtual(id, html); + const url = await toLoaderUrl(Object.assign({ id }, opts)); + setIframeSrc(el, url); + el.setAttribute('data-controlled', '1'); +} +async function rewriteDataOrBlob(el, url) { + const res = await fetch(url); + const html = await res.text(); + await rewriteSrcdoc(el, html); +} +// --- Interceptors --- +// 1) createElement: seed blank iframes with a real loader *src* synchronously. +// Using SYNC_SCOPE_GUESS is fine: the authoritative scope is the same or wider later. +Document.prototype.createElement = function (tagName, options) { + const el = Reflect.apply(Native.createElement, this, [tagName, options]); + if (String(tagName).toLowerCase() === 'iframe') { + const ifr = el; + try { + if (!ifr.hasAttribute('src') && !ifr.hasAttribute('srcdoc')) { + const { LOADER_PATH } = scopedPaths(SYNC_SCOPE_GUESS); + if (LOADER_PATH) { + const url = `${LOADER_PATH}#${new URLSearchParams({ + base: document.baseURI, + }).toString()}`; + setIframeSrc(ifr, url); + ifr.setAttribute('data-controlled', '1'); + } + } + attachControlCheck(ifr); + } catch (_a) {} + } + return el; +}; +// 2) Attribute form +Element.prototype.setAttribute = function (name, value) { + if (this instanceof HTMLIFrameElement) { + const n = name.toLowerCase(); + const v = String(value); + if (n === 'srcdoc') { + // Virtualize srcdoc + void rewriteSrcdoc(this, v); + return; + } + if (n === 'src') { + if (v.startsWith('data:text/html') || v.startsWith('blob:')) { + void rewriteDataOrBlob(this, v); + return; + } + if (v === 'about:blank' || v === '') { + // Treat about:blank like srcdoc so the iframe is a real navigation + // and can inherit the service worker. + void rewriteSrcdoc(this, '', { + base: document.baseURI, + prettyUrl: location.href, + }); + return; + } + // For normal URLs, let it through + } + } + Reflect.apply(Native.setAttribute, this, [name, value]); +}; +var _aa, _bb; +// capture originals once +const Orig = { + src: Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'src'), + srcdoc: Object.getOwnPropertyDescriptor( + HTMLIFrameElement.prototype, + 'srcdoc' + ), +}; +// Reinstall getters/setters correctly. +// - Getter: call the original getter with the *element* as `this`. +// - Setter: delegate to Element.prototype.setAttribute so it flows through your interceptor. +Object.defineProperty(HTMLIFrameElement.prototype, 'src', { + configurable: true, + enumerable: + (_aa = Orig.src.enumerable) !== null && _aa !== void 0 ? _aa : true, + get: function () { + return Orig.src.get.call(this); + }, + set: function (v) { + // go through your patched setAttribute so data:/blob:/srcdoc normalization still applies + Element.prototype.setAttribute.call(this, 'src', String(v)); + }, +}); +Object.defineProperty(HTMLIFrameElement.prototype, 'srcdoc', { + configurable: true, + enumerable: + (_bb = Orig.srcdoc.enumerable) !== null && _bb !== void 0 ? _bb : true, + get: function () { + return Orig.srcdoc.get.call(this); + }, + set: function (v) { + Element.prototype.setAttribute.call(this, 'srcdoc', String(v)); + }, +}); + +// 4) Catch iframes added via innerHTML, etc. +const mo = new MutationObserver((muts) => { + for (const m of muts) + for (const n of m.addedNodes) { + if (n instanceof HTMLIFrameElement) { + if (!n.hasAttribute('src') && !n.hasAttribute('srcdoc')) { + const { LOADER_PATH } = scopedPaths(SYNC_SCOPE_GUESS); + const url = `${LOADER_PATH}#${new URLSearchParams({ + base: document.baseURI, + }).toString()}`; + setIframeSrc(n, url); + n.setAttribute('data-controlled', '1'); + } + attachControlCheck(n); + } else if (n instanceof Element) { + n.querySelectorAll('iframe:not([src]):not([srcdoc])').forEach( + (ifr) => { + const { LOADER_PATH } = scopedPaths(SYNC_SCOPE_GUESS); + const url = `${LOADER_PATH}#${new URLSearchParams({ + base: document.baseURI, + }).toString()}`; + setIframeSrc(ifr, url); + ifr.setAttribute('data-controlled', '1'); + attachControlCheck(ifr); + } + ); + } + } +}); +mo.observe(document.documentElement, { childList: true, subtree: true }); +function captureDoctype(doc) { + const dt = doc.doctype; + if (!dt) return ''; + const idPublic = dt.publicId ? ` \"${dt.publicId}\"` : ''; + const idSystem = dt.systemId ? ` \"${dt.systemId}\"` : ''; + return ``; +} +async function ensureIframeControlled(ifr) { + try { + const win = ifr.contentWindow; + if (!win) return; + if (win.navigator?.serviceWorker?.controller) return; + const doc = win.document; + if (!doc) return; + const htmlRoot = doc.documentElement?.outerHTML || doc.body?.outerHTML; + if (!htmlRoot) return; + const html = `${captureDoctype(doc)}\n${htmlRoot}`; + const base = doc.baseURI || document.baseURI; + const prettyUrl = (() => { + try { + return doc.URL || ''; + } catch (_a) { + return ''; + } + })(); + await rewriteSrcdoc(ifr, html, { base, prettyUrl }); + } catch (_b) { + /* ignore cross-origin */ + } +} +function attachControlCheck(ifr) { + const trigger = () => void ensureIframeControlled(ifr); + try { + if ( + ifr.contentDocument && + ifr.contentDocument.readyState !== 'loading' + ) { + setTimeout(trigger, 0); + } + ifr.addEventListener('load', trigger); + } catch (_a) {} +} +document.querySelectorAll('iframe').forEach((ifr) => attachControlCheck(ifr)); +// Anti-flash while the rewrite happens +const style = document.createElement('style'); +style.textContent = `iframe{visibility:hidden} iframe[data-controlled="1"]{visibility:visible}`; +document.documentElement.appendChild(style); diff --git a/packages/playground/remote/service-worker.ts b/packages/playground/remote/service-worker.ts index 99509d00fa..4114dd5357 100644 --- a/packages/playground/remote/service-worker.ts +++ b/packages/playground/remote/service-worker.ts @@ -121,6 +121,9 @@ import { shouldCacheUrl, } from './src/lib/offline-mode-cache'; +// @ts-ignore +import BOOTSTRAP_JS from './iframes-trap.js?raw'; + if (!self.document) { // Workaround: vite translates import.meta.url // to document.currentScript which fails inside of @@ -195,152 +198,62 @@ self.addEventListener('activate', function (event) { // sw.ts declare const self: ServiceWorkerGlobalScope; -// Derive scope once and use it to build every path. const SCOPE = new URL(self.registration.scope).pathname.replace(/\/$/, ''); +const BUCKET = 'iframe-virtual-docs-v1'; const VIRTUAL_PREFIX = `${SCOPE}/__iframes/`; const LOADER_PATH = `${SCOPE}/wp-includes/empty.html`; const BOOTSTRAP_URL = `${SCOPE}/__bootstrap/controlled-iframes.js`; -// Paste the compiled JS from controlled-iframes.ts here. -const BOOTSTRAP_JS = ` -(() => { - if ((window).__controlled_iframes__) return; - (window).__controlled_iframes__ = true; - - const BUCKET = 'iframe-virtual-docs-v1'; - - // Get the SW scope path, reliably. Works in top pages and in loader-created iframes. - let scopePath = (document.currentScript)?.dataset.scope || null; - const scopePromise = (async () => { - if (scopePath) return scopePath.replace(/\\/$/, ''); - const reg = await navigator.serviceWorker.ready; - scopePath = new URL(reg.scope).pathname.replace(/\\/$/, ''); - return scopePath; - })(); - - const native = { - setAttribute: Element.prototype.setAttribute, - src: Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'src'), - srcdoc: Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'srcdoc'), - }; - - const uid = () => \`${Date.now().toString(36)}-${Math.random() - .toString(36) - .slice(2, 10)}\`; - - async function paths() { - const scope = await scopePromise; - return { - VIRTUAL_PREFIX: \`\${scope}/__iframes/\`, - LOADER_PATH: \`\${scope}/wp-includes/empty.html\`, - }; - } - - async function putVirtual(id, html) { - const cache = await caches.open(BUCKET); - const { VIRTUAL_PREFIX } = await paths(); - await cache.put(\`${VIRTUAL_PREFIX}\${id}.html\`, new Response(html, { - headers: { 'Content-Type': 'text/html; charset=utf-8' } - })); - } - - async function makeLoaderUrl(id, prettyUrl) { - const { LOADER_PATH } = await paths(); - const qs = new URLSearchParams({ id, base: document.baseURI, url: prettyUrl ?? '' }).toString(); - return \`${LOADER_PATH}#\${qs}\`; - } - - async function rewriteToLoader(iframe, html, prettyUrl) { - const id = uid(); - await putVirtual(id, html); // ensure blob exists first - const url = await makeLoaderUrl(id, prettyUrl); // then navigate - native.src.set.call(iframe, url); - } - - async function handleSrcdoc(iframe, html) { - await rewriteToLoader(iframe, html); - } - async function handleDataUrl(iframe, url) { - const html = await (await fetch(url)).text(); - await rewriteToLoader(iframe, html); - } - async function handleBlobUrl(iframe, url) { - const html = await (await fetch(url)).text(); - await rewriteToLoader(iframe, html); - } +self.addEventListener('install', (e) => e.waitUntil(self.skipWaiting())); +self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim())); - Object.defineProperty(HTMLIFrameElement.prototype, 'srcdoc', { - configurable: true, - get: native.srcdoc.get.bind(HTMLIFrameElement.prototype), - set(value) { - console.log('srcdoc set', value); - void handleSrcdoc(this, String(value)); - } - }); - - Object.defineProperty(HTMLIFrameElement.prototype, 'src', { - configurable: true, - get: native.src.get.bind(HTMLIFrameElement.prototype), - set(value) { - console.log('src set', value); - const v = String(value); - if (v.startsWith('data:text/html')) { void handleDataUrl(this, v); return; } - if (v.startsWith('blob:')) { void handleBlobUrl(this, v); return; } - native.src.set.call(this, v); +const LOADER_HTML = ` +`; self.addEventListener('fetch', (event) => { if (!isCurrentServiceWorkerActive()) { @@ -383,6 +294,7 @@ self.addEventListener('fetch', (event) => { return; } + // Serve the loader for navigations to LOADER_PATH if (event.request.mode === 'navigate' && url.pathname === LOADER_PATH) { event.respondWith( new Response(LOADER_HTML, { @@ -392,14 +304,18 @@ self.addEventListener('fetch', (event) => { return; } + // Serve virtual iframe documents from CacheStorage if (url.pathname.startsWith(VIRTUAL_PREFIX)) { event.respondWith( (async () => { - const cache = await caches.open('iframe-virtual-docs-v1'); + const cache = await caches.open(BUCKET); const match = await cache.match(event.request); return ( match || - new Response('Not found', { status: 404 }) + new Response('Not found', { + status: 404, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }) ); })() ); @@ -604,16 +520,16 @@ async function handleScopedRequest(event: FetchEvent, scope) { unscopedUrl.pathname.endsWith('/block-editor/index.js') || unscopedUrl.pathname.endsWith('/block-editor/index.min.js') ) { - const script = await workerResponse.text(); - const newScript = `${controlledIframe} ${script.replace( - /\(\s*"iframe",/, - '(__playground_ControlledIframe,' - )}`; - return new Response(newScript, { - status: workerResponse.status, - statusText: workerResponse.statusText, - headers: workerResponse.headers, - }); + // const script = await workerResponse.text(); + // const newScript = `${controlledIframe} ${script.replace( + // /\(\s*"iframe",/, + // '(__playground_ControlledIframe,' + // )}`; + // return new Response(newScript, { + // status: workerResponse.status, + // statusText: workerResponse.statusText, + // headers: workerResponse.headers, + // }); } return workerResponse; From 0ba50f4c111ada3f01b175385340cff27edb02ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 20 Nov 2025 16:44:13 +0100 Subject: [PATCH 3/6] Document the service worker operations --- packages/playground/remote/iframes-trap.js | 2 - packages/playground/remote/service-worker.ts | 323 ++++++++---------- .../lib/playground-mu-plugin/0-playground.php | 2 +- packages/playground/remote/tsconfig.lib.json | 1 + .../playwright/e2e/controlled-iframes.spec.ts | 50 +++ 5 files changed, 185 insertions(+), 193 deletions(-) create mode 100644 packages/playground/website/playwright/e2e/controlled-iframes.spec.ts diff --git a/packages/playground/remote/iframes-trap.js b/packages/playground/remote/iframes-trap.js index b5d9b58fa9..ef1028b897 100644 --- a/packages/playground/remote/iframes-trap.js +++ b/packages/playground/remote/iframes-trap.js @@ -34,11 +34,9 @@ function scopedPaths(scope) { LOADER_PATH: `${base}/wp-includes/empty.html`, }; } -// Capture natives up front const Native = { createElement: Document.prototype.createElement, setAttribute: Element.prototype.setAttribute, - // Accessors iframeSrc: Object.getOwnPropertyDescriptor( HTMLIFrameElement.prototype, 'src' diff --git a/packages/playground/remote/service-worker.ts b/packages/playground/remote/service-worker.ts index 4114dd5357..f436d35278 100644 --- a/packages/playground/remote/service-worker.ts +++ b/packages/playground/remote/service-worker.ts @@ -121,7 +121,8 @@ import { shouldCacheUrl, } from './src/lib/offline-mode-cache'; -// @ts-ignore +// NOTE: import the compiled JS (pre-built) to avoid shipping TypeScript source to runtime. +// Keep packages/playground/remote/iframes-trap.js in sync with the .ts source. import BOOTSTRAP_JS from './iframes-trap.js?raw'; if (!self.document) { @@ -173,7 +174,6 @@ self.addEventListener('install', (event) => { * intercepted here. * * However, the initial Playground load already downloads a few large assets, - * like a 12MB wordpress-static.zip file. We need to cache them these requests. * Otherwise they'll be fetched again on the next page load. * * client.claim() only affects pages loaded before the initial servie worker @@ -195,84 +195,152 @@ self.addEventListener('activate', function (event) { } event.waitUntil(doActivate()); }); -// sw.ts -declare const self: ServiceWorkerGlobalScope; -const SCOPE = new URL(self.registration.scope).pathname.replace(/\/$/, ''); -const BUCKET = 'iframe-virtual-docs-v1'; -const VIRTUAL_PREFIX = `${SCOPE}/__iframes/`; -const LOADER_PATH = `${SCOPE}/wp-includes/empty.html`; -const BOOTSTRAP_URL = `${SCOPE}/__bootstrap/controlled-iframes.js`; +/** + * Make all iframes controlled by the service worker. + * + * ## The problem + * + * Iframes created as about:blank / srcdoc / data / blob are not controlled by this + * service worker. This means that all network calls initiated by these iframes are + * sent directly to the network. This means Gutenberg cannot load any CSS files, + * TInyMCE can't load media images, etc. + * + * Only iframes created with `src` pointing to a URL already controlled by this service worker + * are themselves controlled. + * + * ## The solution + * + * We inject a `iframes-trap.js` script into every HTML page to override a set of DOM + * methods used to create iframes. Whenever an src/srcdoc attribute is set on an iframe, + * we intercept that and: + * + * 1) Store the initial HTML of the iframe in CacheStorage. + * 2) Set the iframe's src to iframeLoaderUrl (coming from a controlled URL). + * 3) The loader replaces the iframe's content with the cached HTML. + * 4) The loader ensures `iframes-trap.js` is also loaded and executed inside the iframe + * to cover any nested iframes. + * + * As a result, every same-origin iframe is forced onto a real navigation that the SW can control, + * so all fetches (including inside editors like TinyMCE) go through our handler + * without per-product patches. This replaces the former Gutenberg-only shim. + * + * References + * + * - Chrome: https://bugs.chromium.org/p/chromium/issues/detail?id=880768 + * - Firefox: https://bugzilla.mozilla.org/show_bug.cgi?id=1293277 + * - Spec discussion: https://github.com/w3c/ServiceWorker/issues/765 + * - Gutenberg context: https://github.com/WordPress/gutenberg/pull/38855 + * - Playground historical issue: https://github.com/WordPress/wordpress-playground/issues/42 + */ + +/** + * The CacheStorage bucked used by iframes-trap.js to store the HTML contents + * of iframes initialized from srcdoc/data/blob. + */ +const iframeCacheBucket = 'iframe-virtual-docs-v1'; + +/** + * A unique path prefix for all the cached iframe markup. It helps the service worker + * decide whether the incoming request is related to a cached iframe markup. + */ +const iframeCacheKeyPrefix = `/__iframes/`; + +/** + * Service worker serves `./iframes-trap.js` at this path: + */ +const iframeTrapScriptUrl = `/__bootstrap/iframes-trap.js`; -self.addEventListener('install', (e) => e.waitUntil(self.skipWaiting())); -self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim())); +/** + * Service worker serves `iframeLoaderHtml` at this path. It's used + * to initialize new iframes. + */ +const iframeLoaderPath = `/wp-includes/empty.html`; -const LOADER_HTML = ` +/** + * The HTML content of the iframe loader. This is the inital page + * every iframe is forced to load when it's created. + */ +const iframeLoaderHtml = ` `; @@ -294,21 +362,24 @@ self.addEventListener('fetch', (event) => { return; } - // Serve the loader for navigations to LOADER_PATH - if (event.request.mode === 'navigate' && url.pathname === LOADER_PATH) { + // Serve the iframe loader + if ( + event.request.mode === 'navigate' && + url.pathname === iframeLoaderPath + ) { event.respondWith( - new Response(LOADER_HTML, { + new Response(iframeLoaderHtml, { headers: { 'Content-Type': 'text/html; charset=utf-8' }, }) ); return; } - // Serve virtual iframe documents from CacheStorage - if (url.pathname.startsWith(VIRTUAL_PREFIX)) { + // Serve the cached iframe contents (written by iframe-trap.js) + if (url.pathname.startsWith(iframeCacheKeyPrefix)) { event.respondWith( (async () => { - const cache = await caches.open(BUCKET); + const cache = await caches.open(iframeCacheBucket); const match = await cache.match(event.request); return ( match || @@ -322,7 +393,7 @@ self.addEventListener('fetch', (event) => { return; } - if (url.pathname === BOOTSTRAP_URL) { + if (url.pathname === iframeTrapScriptUrl) { event.respondWith( new Response(BOOTSTRAP_JS, { headers: { @@ -443,10 +514,6 @@ self.addEventListener('fetch', (event) => { async function handleScopedRequest(event: FetchEvent, scope) { const fullUrl = new URL(event.request.url); const unscopedUrl = removeURLScope(fullUrl); - if (fullUrl.pathname.endsWith('/wp-includes/empty.html')) { - return emptyHtml(); - } - const workerResponse = await convertFetchEventToPHPRequest(event); if ( @@ -509,135 +576,11 @@ async function handleScopedRequest(event: FetchEvent, scope) { }); } - // Path the block-editor.js file to ensure the site editor's iframe - // inherits the service worker. - // @see controlledIframe below for more details. - if ( - // WordPress Core version of block-editor.js - unscopedUrl.pathname.endsWith('/block-editor.js') || - unscopedUrl.pathname.endsWith('/block-editor.min.js') || - // Gutenberg version of block-editor.js - unscopedUrl.pathname.endsWith('/block-editor/index.js') || - unscopedUrl.pathname.endsWith('/block-editor/index.min.js') - ) { - // const script = await workerResponse.text(); - // const newScript = `${controlledIframe} ${script.replace( - // /\(\s*"iframe",/, - // '(__playground_ControlledIframe,' - // )}`; - // return new Response(newScript, { - // status: workerResponse.status, - // statusText: workerResponse.statusText, - // headers: workerResponse.headers, - // }); - } - return workerResponse; } reportServiceWorkerMetrics(self); -/** - * Pair the site editor's nested iframe to the Service Worker. - * - * Without the patch below, the site editor initiates network requests that - * aren't routed through the service worker. That's a known browser issue: - * - * * https://bugs.chromium.org/p/chromium/issues/detail?id=880768 - * * https://bugzilla.mozilla.org/show_bug.cgi?id=1293277 - * * https://github.com/w3c/ServiceWorker/issues/765 - * - * The problem with iframes using srcDoc and src="about:blank" as they - * fail to inherit the root site's service worker. - * - * Gutenberg loads the site editor using