From 0838de8ae4f10fea12c645a924e6f795902d3e59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 13 Oct 2022 11:25:00 -1000 Subject: [PATCH 1/3] Proof of concept: WordPress instances scoped to a specific subdirectory --- dist-web/app.js | 12 ++-- dist-web/service-worker.js | 112 +++++++++++++++++++---------- dist-web/wasm-worker.js | 4 +- src/shared/wordpress.mjs | 4 +- src/web/app.mjs | 4 +- src/web/library.js | 14 ++-- src/web/service-worker.js | 144 +++++++++++++++++++++++++------------ 7 files changed, 192 insertions(+), 102 deletions(-) diff --git a/dist-web/app.js b/dist-web/app.js index f9150ccebd..5c43094c1e 100644 --- a/dist-web/app.js +++ b/dist-web/app.js @@ -44,7 +44,9 @@ alert("Service workers are not supported in this browser."); throw new Exception("Service workers are not supported in this browser."); } - await navigator.serviceWorker.register(url); + await navigator.serviceWorker.register(url, { + scope: "./subdirectory" + }); const serviceWorkerChannel = new BroadcastChannel("wordpress-service-worker"); serviceWorkerChannel.addEventListener("message", async function onMessage(event) { console.debug(`[Main] "${event.data.type}" message received from a service worker`); @@ -67,10 +69,6 @@ navigator.serviceWorker.startMessages(); await sleep(0); const wordPressDomain = new URL(url).origin; - const response = await fetch(`${wordPressDomain}/wp-admin/atomlib.php`); - if (!response.ok) { - window.location.reload(); - } } async function createWordPressWorker({ backend, wordPressSiteUrl: wordPressSiteUrl2 }) { while (true) { @@ -156,7 +154,7 @@ const wasmWorker = await createWordPressWorker( { backend: getWorkerBackend(wasmWorkerBackend, wasmWorkerUrl), - wordPressSiteUrl + wordPressSiteUrl: wordPressSiteUrl + "/subdirectory" } ); await registerServiceWorker( @@ -166,7 +164,7 @@ } ); console.log("[Main] Workers are ready"); - document.querySelector("#wp").src = "/wp-login.php"; + document.querySelector("#wp").src = "/subdirectory/wp-login.php"; } init(); })(); diff --git a/dist-web/service-worker.js b/dist-web/service-worker.js index 9cc5bdb782..aa9cf397bd 100644 --- a/dist-web/service-worker.js +++ b/dist-web/service-worker.js @@ -38,49 +38,83 @@ self.addEventListener("fetch", (event) => { const url = new URL(event.request.url); const isWpOrgRequest = url.hostname.includes("api.wordpress.org"); - const isPHPRequest = url.pathname.endsWith("/") && url.pathname !== "/" || url.pathname.endsWith(".php"); - if (isWpOrgRequest || !isPHPRequest) { + if (isWpOrgRequest) { console.log(`[ServiceWorker] Ignoring request: ${url.pathname}`); - return; } - event.preventDefault(); - return event.respondWith( - new Promise(async (accept) => { - console.log(`[ServiceWorker] Serving request: ${url.pathname}?${url.search}`); - console.log({ isWpOrgRequest, isPHPRequest }); - const post = await parsePost(event.request); - const requestHeaders = {}; - for (const pair of event.request.headers.entries()) { - requestHeaders[pair[0]] = pair[1]; - } - let wpResponse; - try { - const message = { - type: "httpRequest", - request: { - path: url.pathname + url.search, - method: event.request.method, - _POST: post, - headers: requestHeaders - } - }; - console.log("[ServiceWorker] Forwarding a request to the main app", { message }); - const messageId = postMessageExpectReply(broadcastChannel, message); - wpResponse = await awaitReply(broadcastChannel, messageId); - console.log("[ServiceWorker] Response received from the main app", { wpResponse }); - } catch (e) { - console.error(e); - throw e; - } - accept(new Response( - wpResponse.body, - { - headers: wpResponse.headers + const isPHPRequest = url.pathname.endsWith("/") && url.pathname !== "/" || url.pathname.endsWith(".php"); + if (isPHPRequest) { + event.preventDefault(); + return event.respondWith( + new Promise(async (accept) => { + console.log(`[ServiceWorker] Serving request: ${url.pathname}?${url.search}`); + console.log({ isWpOrgRequest, isPHPRequest }); + const post = await parsePost(event.request); + const requestHeaders = {}; + for (const pair of event.request.headers.entries()) { + requestHeaders[pair[0]] = pair[1]; } - )); - }) - ); + let wpResponse; + try { + const message = { + type: "httpRequest", + request: { + path: url.pathname + url.search, + method: event.request.method, + _POST: post, + headers: requestHeaders + } + }; + console.log("[ServiceWorker] Forwarding a request to the main app", { message }); + const messageId = postMessageExpectReply(broadcastChannel, message); + wpResponse = await awaitReply(broadcastChannel, messageId); + console.log("[ServiceWorker] Response received from the main app", { wpResponse }); + } catch (e) { + console.error(e); + throw e; + } + accept(new Response( + wpResponse.body, + { + headers: wpResponse.headers + } + )); + }) + ); + } + const isStaticFileRequest = url.pathname.startsWith("/subdirectory/"); + if (isStaticFileRequest) { + const scopedUrl = url + ""; + url.pathname = url.pathname.substr("/subdirectory".length); + const serverUrl = url + ""; + console.log(`[ServiceWorker] Rerouting static request from ${scopedUrl} to ${serverUrl}`); + event.preventDefault(); + return event.respondWith( + new Promise(async (accept) => { + const newRequest = await cloneRequest(event.request, { + url: serverUrl + }); + accept(fetch(newRequest)); + }) + ); + } + console.log(`[ServiceWorker] Ignoring a request to ${event.request.url}`); }); + async function cloneRequest(request, overrides) { + const body = ["GET", "HEAD"].includes(request.method) || "body" in overrides ? void 0 : await r.blob(); + return new Request(overrides.url || request.url, { + body, + method: request.method, + headers: request.headers, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + mode: request.mode, + credentials: request.credentials, + cache: request.cache, + redirect: request.redirect, + integrity: request.integrity, + ...overrides + }); + } async function parsePost(request) { if (request.method !== "POST") { return void 0; diff --git a/dist-web/wasm-worker.js b/dist-web/wasm-worker.js index bb5d944d69..997d9e0285 100644 --- a/dist-web/wasm-worker.js +++ b/dist-web/wasm-worker.js @@ -111,7 +111,7 @@ this.PORT = url.port ? url.port : url.protocol === "https:" ? 443 : 80; this.SCHEMA = (url.protocol || "").replace(":", ""); this.HOST = `${this.HOSTNAME}:${this.PORT}`; - this.ABSOLUTE_URL = `${this.SCHEMA}://${this.HOSTNAME}:${this.PORT}`; + this.ABSOLUTE_URL = `${this.SCHEMA}://${this.HOSTNAME}:${this.PORT}/subdirectory`; await this.php.refresh(); const result = await this.php.run(` diff --git a/src/web/service-worker.js b/src/web/service-worker.js index c4d4043018..97e55cb294 100644 --- a/src/web/service-worker.js +++ b/src/web/service-worker.js @@ -21,56 +21,112 @@ self.addEventListener("activate", (event) => { * The main method. It captures the requests and loop them back to the main * application using the Loopback request */ -self.addEventListener( 'fetch', ( event ) => { +self.addEventListener('fetch', (event) => { // @TODO A more involved hostname check - const url = new URL( event.request.url ); - const isWpOrgRequest = url.hostname.includes( 'api.wordpress.org' ); - const isPHPRequest = ( url.pathname.endsWith( '/' ) && url.pathname !== '/' ) || url.pathname.endsWith( '.php' ); - if ( isWpOrgRequest || ! isPHPRequest ) { - console.log( `[ServiceWorker] Ignoring request: ${ url.pathname }` ); - return; + const url = new URL(event.request.url); + const isWpOrgRequest = url.hostname.includes('api.wordpress.org'); + if (isWpOrgRequest) { + console.log(`[ServiceWorker] Ignoring request: ${url.pathname}`); } - event.preventDefault(); - return event.respondWith( - new Promise( async ( accept ) => { - console.log( `[ServiceWorker] Serving request: ${ url.pathname }?${ url.search }` ); - console.log( { isWpOrgRequest, isPHPRequest } ); - const post = await parsePost( event.request ); - const requestHeaders = {}; - for ( const pair of event.request.headers.entries() ) { - requestHeaders[ pair[ 0 ] ] = pair[ 1 ]; - } + const isPHPRequest = (url.pathname.endsWith('/') && url.pathname !== '/') || url.pathname.endsWith('.php'); + if (isPHPRequest) { + event.preventDefault(); + return event.respondWith( + new Promise(async (accept) => { + console.log(`[ServiceWorker] Serving request: ${url.pathname}?${url.search}`); + console.log({ isWpOrgRequest, isPHPRequest }); + const post = await parsePost(event.request); + const requestHeaders = {}; + for (const pair of event.request.headers.entries()) { + requestHeaders[pair[0]] = pair[1]; + } - let wpResponse; - try { - const message = { - type: 'httpRequest', - request: { - path: url.pathname + url.search, - method: event.request.method, - _POST: post, - headers: requestHeaders, + let wpResponse; + try { + const message = { + type: 'httpRequest', + request: { + path: url.pathname + url.search, + method: event.request.method, + _POST: post, + headers: requestHeaders, + }, + }; + console.log('[ServiceWorker] Forwarding a request to the main app', { message }); + const messageId = postMessageExpectReply(broadcastChannel, message); + wpResponse = await awaitReply(broadcastChannel, messageId); + console.log('[ServiceWorker] Response received from the main app', { wpResponse }); + } catch (e) { + console.error(e); + throw e; + } + + accept(new Response( + wpResponse.body, + { + headers: wpResponse.headers, }, - }; - console.log( '[ServiceWorker] Forwarding a request to the main app', { message } ); - const messageId = postMessageExpectReply( broadcastChannel, message ); - wpResponse = await awaitReply( broadcastChannel, messageId ); - console.log( '[ServiceWorker] Response received from the main app', { wpResponse } ); - } catch ( e ) { - console.error( e ); - throw e; - } + )); + }), + ); + } + + const isStaticFileRequest = url.pathname.startsWith('/subdirectory/'); + if (isStaticFileRequest) { + const scopedUrl = url + ''; + url.pathname = url.pathname.substr('/subdirectory'.length); + const serverUrl = url + ''; + console.log(`[ServiceWorker] Rerouting static request from ${scopedUrl} to ${serverUrl}`); + + event.preventDefault(); + return event.respondWith( + new Promise(async (accept) => { + const newRequest = await cloneRequest(event.request, { + url: serverUrl + }); + accept(fetch(newRequest)); + }) + ); + } + + console.log(`[ServiceWorker] Ignoring a request to ${event.request.url}`); +}); - accept( new Response( - wpResponse.body, - { - headers: wpResponse.headers, - }, - ) ); - } ), - ); -} ); +/** + * Copy a request with custom overrides. + * + * This function is only needed because Request properties + * are read-only. The only way to change e.g. a URL is to + * create an entirely new request: + * + * https://developer.mozilla.org/en-US/docs/Web/API/Request + * + * @param {Request} request + * @param {Object} overrides + * @returns Request + */ +async function cloneRequest(request, overrides) { + const body = + ['GET', 'HEAD'].includes(request.method) + || 'body' in overrides + ? undefined + : await r.blob() + ; + return new Request(overrides.url || request.url, { + body, + method: request.method, + headers: request.headers, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + mode: request.mode, + credentials: request.credentials, + cache: request.cache, + redirect: request.redirect, + integrity: request.integrity, + ...overrides + }); +} async function parsePost( request ) { if ( request.method !== 'POST' ) { From a763416048d17c7c884fb7eb054de1a7b4bb7f35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 13 Oct 2022 12:10:15 -1000 Subject: [PATCH 2/3] Configurable WordPress subdirectory --- dist-web/app.js | 15 +++++++++------ dist-web/service-worker.js | 8 +++++--- dist-web/wasm-worker.js | 11 ++++++++--- src/shared/wordpress.mjs | 13 +++++++++---- src/web/app.mjs | 10 +++++++--- src/web/library.js | 6 +++--- src/web/service-worker.js | 10 ++++++---- 7 files changed, 47 insertions(+), 26 deletions(-) diff --git a/dist-web/app.js b/dist-web/app.js index 5c43094c1e..eb76a325dd 100644 --- a/dist-web/app.js +++ b/dist-web/app.js @@ -39,15 +39,15 @@ // src/web/library.js var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, 50)); - async function registerServiceWorker(url, onRequest) { + async function registerServiceWorker(url, onRequest, scope = "") { if (!navigator.serviceWorker) { alert("Service workers are not supported in this browser."); throw new Exception("Service workers are not supported in this browser."); } await navigator.serviceWorker.register(url, { - scope: "./subdirectory" + scope }); - const serviceWorkerChannel = new BroadcastChannel("wordpress-service-worker"); + const serviceWorkerChannel = new BroadcastChannel(`wordpress-service-worker-${scope}`); serviceWorkerChannel.addEventListener("message", async function onMessage(event) { console.debug(`[Main] "${event.data.type}" message received from a service worker`); let result; @@ -151,20 +151,23 @@ // src/web/app.mjs async function init() { console.log("[Main] Initializing the workers"); + const tabId = Math.random().toFixed(16); + const subdirectory = `/${tabId}`; const wasmWorker = await createWordPressWorker( { backend: getWorkerBackend(wasmWorkerBackend, wasmWorkerUrl), - wordPressSiteUrl: wordPressSiteUrl + "/subdirectory" + wordPressSiteUrl: wordPressSiteUrl + subdirectory } ); await registerServiceWorker( serviceWorkerUrl, async (request) => { return await wasmWorker.HTTPRequest(request); - } + }, + subdirectory ); console.log("[Main] Workers are ready"); - document.querySelector("#wp").src = "/subdirectory/wp-login.php"; + document.querySelector("#wp").src = `${subdirectory}/wp-login.php`; } init(); })(); diff --git a/dist-web/service-worker.js b/dist-web/service-worker.js index aa9cf397bd..24ca7c9fb7 100644 --- a/dist-web/service-worker.js +++ b/dist-web/service-worker.js @@ -31,7 +31,9 @@ } // src/web/service-worker.js - var broadcastChannel = new BroadcastChannel("wordpress-service-worker"); + var pathname = new URL(self.registration.scope).pathname; + var workerScope = pathname.replace(/\/+$/, ""); + var broadcastChannel = new BroadcastChannel(`wordpress-service-worker-${pathname}`); self.addEventListener("activate", (event) => { event.waitUntil(clients.claim()); }); @@ -81,10 +83,10 @@ }) ); } - const isStaticFileRequest = url.pathname.startsWith("/subdirectory/"); + const isStaticFileRequest = url.pathname.startsWith(`${workerScope}/`); if (isStaticFileRequest) { const scopedUrl = url + ""; - url.pathname = url.pathname.substr("/subdirectory".length); + url.pathname = url.pathname.substr(workerScope.length); const serverUrl = url + ""; console.log(`[ServiceWorker] Rerouting static request from ${scopedUrl} to ${serverUrl}`); event.preventDefault(); diff --git a/dist-web/wasm-worker.js b/dist-web/wasm-worker.js index 997d9e0285..2d145fa2c0 100644 --- a/dist-web/wasm-worker.js +++ b/dist-web/wasm-worker.js @@ -96,7 +96,8 @@ SCHEMA = "http"; HOSTNAME = "localhost"; PORT = 80; - HOST = ``; + HOST = ""; + PATHNAME = ""; ABSOLUTE_URL = ``; constructor(php) { this.php = php; @@ -111,7 +112,8 @@ this.PORT = url.port ? url.port : url.protocol === "https:" ? 443 : 80; this.SCHEMA = (url.protocol || "").replace(":", ""); this.HOST = `${this.HOSTNAME}:${this.PORT}`; - this.ABSOLUTE_URL = `${this.SCHEMA}://${this.HOSTNAME}:${this.PORT}/subdirectory`; + this.PATHNAME = url.pathname.replace(/\/+$/, ""); + this.ABSOLUTE_URL = `${this.SCHEMA}://${this.HOSTNAME}:${this.PORT}${this.PATHNAME}`; await this.php.refresh(); const result = await this.php.run(` { return await wasmWorker.HTTPRequest(request); - } + }, + subdirectory ); console.log("[Main] Workers are ready") - document.querySelector('#wp').src = '/subdirectory/wp-login.php'; + document.querySelector('#wp').src = `${subdirectory}/wp-login.php`; } init(); diff --git a/src/web/library.js b/src/web/library.js index 0e476a42ad..5a603ae2ca 100644 --- a/src/web/library.js +++ b/src/web/library.js @@ -4,15 +4,15 @@ const sleep = ms => new Promise(resolve => setTimeout(resolve, 50)); // // Register the service worker and handle any HTTP WordPress requests it provides us: -export async function registerServiceWorker(url, onRequest) { +export async function registerServiceWorker(url, onRequest, scope = '') { if ( ! navigator.serviceWorker ) { alert('Service workers are not supported in this browser.'); throw new Exception('Service workers are not supported in this browser.'); } await navigator.serviceWorker.register(url, { - scope: './subdirectory' + scope }); - const serviceWorkerChannel = new BroadcastChannel('wordpress-service-worker'); + const serviceWorkerChannel = new BroadcastChannel(`wordpress-service-worker-${scope}`); serviceWorkerChannel.addEventListener('message', async function onMessage(event) { console.debug(`[Main] "${event.data.type}" message received from a service worker`); diff --git a/src/web/service-worker.js b/src/web/service-worker.js index 97e55cb294..6cc40a2082 100644 --- a/src/web/service-worker.js +++ b/src/web/service-worker.js @@ -1,6 +1,8 @@ import { postMessageExpectReply, awaitReply } from '../shared/messaging.mjs'; -const broadcastChannel = new BroadcastChannel( 'wordpress-service-worker' ); +const pathname = new URL(self.registration.scope).pathname; +const workerScope = pathname.replace(/\/+$/, ''); +const broadcastChannel = new BroadcastChannel( `wordpress-service-worker-${pathname}` ); /** * Ensure the client gets claimed by this service worker right after the registration. @@ -16,7 +18,7 @@ const broadcastChannel = new BroadcastChannel( 'wordpress-service-worker' ); self.addEventListener("activate", (event) => { event.waitUntil(clients.claim()); }); - + /** * The main method. It captures the requests and loop them back to the main * application using the Loopback request @@ -72,10 +74,10 @@ self.addEventListener('fetch', (event) => { ); } - const isStaticFileRequest = url.pathname.startsWith('/subdirectory/'); + const isStaticFileRequest = url.pathname.startsWith(`${workerScope}/`); if (isStaticFileRequest) { const scopedUrl = url + ''; - url.pathname = url.pathname.substr('/subdirectory'.length); + url.pathname = url.pathname.substr(workerScope.length); const serverUrl = url + ''; console.log(`[ServiceWorker] Rerouting static request from ${scopedUrl} to ${serverUrl}`); From 6f4d54606bb1173821f825446415629303db5ef5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 13 Oct 2022 12:44:01 -1000 Subject: [PATCH 3/3] Use server paths to scope WordPress instances instead of relying on the `scope` feature of service workers. Scoping service workers only leads to registering dozens of workers and a hard to untangle mess. --- dist-web/app.js | 45 +++++++++++++++++++++----------------- dist-web/service-worker.js | 14 ++++++------ src/web/app.mjs | 26 ++++++++++------------ src/web/library.js | 34 +++++++++++++++++++++++----- src/web/service-worker.js | 24 ++++++++++++++------ 5 files changed, 89 insertions(+), 54 deletions(-) diff --git a/dist-web/app.js b/dist-web/app.js index eb76a325dd..943ff345cc 100644 --- a/dist-web/app.js +++ b/dist-web/app.js @@ -39,16 +39,17 @@ // src/web/library.js var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, 50)); - async function registerServiceWorker(url, onRequest, scope = "") { + async function registerServiceWorker({ url, onRequest, scope }) { if (!navigator.serviceWorker) { alert("Service workers are not supported in this browser."); throw new Exception("Service workers are not supported in this browser."); } - await navigator.serviceWorker.register(url, { - scope - }); - const serviceWorkerChannel = new BroadcastChannel(`wordpress-service-worker-${scope}`); + await navigator.serviceWorker.register(url); + const serviceWorkerChannel = new BroadcastChannel(`wordpress-service-worker`); serviceWorkerChannel.addEventListener("message", async function onMessage(event) { + if (scope && event.data.scope !== scope) { + return; + } console.debug(`[Main] "${event.data.type}" message received from a service worker`); let result; if (event.data.type === "request" || event.data.type === "httpRequest") { @@ -70,7 +71,7 @@ await sleep(0); const wordPressDomain = new URL(url).origin; } - async function createWordPressWorker({ backend, wordPressSiteUrl: wordPressSiteUrl2 }) { + async function createWordPressWorker({ backend, wordPressSiteUrl: wordPressSiteUrl2, scope }) { while (true) { try { await backend.sendMessage({ type: "is_alive" }, 50); @@ -79,11 +80,17 @@ } await sleep(50); } + if (scope) { + wordPressSiteUrl2 += `/scope:${scope}`; + } await backend.sendMessage({ type: "initialize_wordpress", siteURL: wordPressSiteUrl2 }); return { + urlFor(path) { + return `${wordPressSiteUrl2}${path}`; + }, async HTTPRequest(request) { return await backend.sendMessage({ type: "request", @@ -151,23 +158,21 @@ // src/web/app.mjs async function init() { console.log("[Main] Initializing the workers"); - const tabId = Math.random().toFixed(16); - const subdirectory = `/${tabId}`; - const wasmWorker = await createWordPressWorker( - { - backend: getWorkerBackend(wasmWorkerBackend, wasmWorkerUrl), - wordPressSiteUrl: wordPressSiteUrl + subdirectory - } - ); - await registerServiceWorker( - serviceWorkerUrl, - async (request) => { + const tabScope = Math.random().toFixed(16); + const wasmWorker = await createWordPressWorker({ + backend: getWorkerBackend(wasmWorkerBackend, wasmWorkerUrl), + wordPressSiteUrl, + scope: tabScope + }); + await registerServiceWorker({ + url: serviceWorkerUrl, + onRequest: async (request) => { return await wasmWorker.HTTPRequest(request); }, - subdirectory - ); + scope: tabScope + }); console.log("[Main] Workers are ready"); - document.querySelector("#wp").src = `${subdirectory}/wp-login.php`; + document.querySelector("#wp").src = wasmWorker.urlFor(`/wp-login.php`); } init(); })(); diff --git a/dist-web/service-worker.js b/dist-web/service-worker.js index 24ca7c9fb7..91ce8ed7de 100644 --- a/dist-web/service-worker.js +++ b/dist-web/service-worker.js @@ -31,9 +31,7 @@ } // src/web/service-worker.js - var pathname = new URL(self.registration.scope).pathname; - var workerScope = pathname.replace(/\/+$/, ""); - var broadcastChannel = new BroadcastChannel(`wordpress-service-worker-${pathname}`); + var broadcastChannel = new BroadcastChannel(`wordpress-service-worker`); self.addEventListener("activate", (event) => { event.waitUntil(clients.claim()); }); @@ -43,6 +41,8 @@ if (isWpOrgRequest) { console.log(`[ServiceWorker] Ignoring request: ${url.pathname}`); } + const isScopedRequest = url.pathname.startsWith(`/scope:`); + const scope = isScopedRequest ? url.pathname.split("/")[1].split(":")[1] : null; const isPHPRequest = url.pathname.endsWith("/") && url.pathname !== "/" || url.pathname.endsWith(".php"); if (isPHPRequest) { event.preventDefault(); @@ -59,6 +59,7 @@ try { const message = { type: "httpRequest", + scope, request: { path: url.pathname + url.search, method: event.request.method, @@ -83,11 +84,10 @@ }) ); } - const isStaticFileRequest = url.pathname.startsWith(`${workerScope}/`); - if (isStaticFileRequest) { + const isScopedStaticFileRequest = isScopedRequest; + if (isScopedStaticFileRequest) { const scopedUrl = url + ""; - url.pathname = url.pathname.substr(workerScope.length); - const serverUrl = url + ""; + url.pathname = "/" + url.pathname.split("/").slice(2).join("/"); console.log(`[ServiceWorker] Rerouting static request from ${scopedUrl} to ${serverUrl}`); event.preventDefault(); return event.respondWith( diff --git a/src/web/app.mjs b/src/web/app.mjs index edab2ed00b..0cbfd4c053 100644 --- a/src/web/app.mjs +++ b/src/web/app.mjs @@ -4,26 +4,24 @@ import { wordPressSiteUrl, serviceWorkerUrl, wasmWorkerUrl, wasmWorkerBackend } async function init() { console.log("[Main] Initializing the workers") - const tabId = Math.random().toFixed(16); - const subdirectory = `/${tabId}`; + const tabScope = Math.random().toFixed(16); - const wasmWorker = await createWordPressWorker( - { - backend: getWorkerBackend( wasmWorkerBackend, wasmWorkerUrl ), - wordPressSiteUrl: wordPressSiteUrl + subdirectory - } - ); - await registerServiceWorker( - serviceWorkerUrl, + const wasmWorker = await createWordPressWorker({ + backend: getWorkerBackend( wasmWorkerBackend, wasmWorkerUrl ), + wordPressSiteUrl: wordPressSiteUrl, + scope: tabScope + }); + await registerServiceWorker({ + url: serviceWorkerUrl, // Forward any HTTP requests to a worker to resolve them in another process. // This way they won't slow down the UI interactions. - async (request) => { + onRequest: async (request) => { return await wasmWorker.HTTPRequest(request); }, - subdirectory - ); + scope: tabScope + }); console.log("[Main] Workers are ready") - document.querySelector('#wp').src = `${subdirectory}/wp-login.php`; + document.querySelector('#wp').src = wasmWorker.urlFor(`/wp-login.php`); } init(); diff --git a/src/web/library.js b/src/web/library.js index 5a603ae2ca..3da9d568d4 100644 --- a/src/web/library.js +++ b/src/web/library.js @@ -4,16 +4,24 @@ const sleep = ms => new Promise(resolve => setTimeout(resolve, 50)); // // Register the service worker and handle any HTTP WordPress requests it provides us: -export async function registerServiceWorker(url, onRequest, scope = '') { +export async function registerServiceWorker({ url, onRequest, scope }) { if ( ! navigator.serviceWorker ) { alert('Service workers are not supported in this browser.'); throw new Exception('Service workers are not supported in this browser.'); } - await navigator.serviceWorker.register(url, { - scope - }); - const serviceWorkerChannel = new BroadcastChannel(`wordpress-service-worker-${scope}`); + await navigator.serviceWorker.register(url); + const serviceWorkerChannel = new BroadcastChannel(`wordpress-service-worker`); serviceWorkerChannel.addEventListener('message', async function onMessage(event) { + /** + * Ignore events meant for other WordPress instances to + * avoid handling the same event twice. + * + * This is important because BroadcastChannel transmits + * events to all the listeners across all browser tabs. + */ + if (scope && event.data.scope !== scope) { + return; + } console.debug(`[Main] "${event.data.type}" message received from a service worker`); let result; @@ -50,7 +58,7 @@ export async function registerServiceWorker(url, onRequest, scope = '') { // // -export async function createWordPressWorker({ backend, wordPressSiteUrl }) { +export async function createWordPressWorker({ backend, wordPressSiteUrl, scope }) { // Keep asking if the worker is alive until we get a response while (true) { try { @@ -62,6 +70,17 @@ export async function createWordPressWorker({ backend, wordPressSiteUrl }) { await sleep(50); } + /** + * Scoping a WordPress instances means hosting it on a + * path starting with `/scope:`. This helps WASM workers + * avoid rendering any requests meant for other WASM workers. + * + * @see registerServiceWorker for more details + */ + if (scope) { + wordPressSiteUrl += `/scope:${scope}`; + } + // Now that the worker is up and running, let's ask it to initialize // WordPress: await backend.sendMessage({ @@ -70,6 +89,9 @@ export async function createWordPressWorker({ backend, wordPressSiteUrl }) { }); return { + urlFor(path) { + return `${wordPressSiteUrl}${path}`; + }, async HTTPRequest(request) { return await backend.sendMessage({ type: 'request', diff --git a/src/web/service-worker.js b/src/web/service-worker.js index 6cc40a2082..fde86a7795 100644 --- a/src/web/service-worker.js +++ b/src/web/service-worker.js @@ -1,8 +1,6 @@ import { postMessageExpectReply, awaitReply } from '../shared/messaging.mjs'; -const pathname = new URL(self.registration.scope).pathname; -const workerScope = pathname.replace(/\/+$/, ''); -const broadcastChannel = new BroadcastChannel( `wordpress-service-worker-${pathname}` ); +const broadcastChannel = new BroadcastChannel( `wordpress-service-worker` ); /** * Ensure the client gets claimed by this service worker right after the registration. @@ -19,6 +17,8 @@ self.addEventListener("activate", (event) => { event.waitUntil(clients.claim()); }); +const urlMap = {} + /** * The main method. It captures the requests and loop them back to the main * application using the Loopback request @@ -31,6 +31,16 @@ self.addEventListener('fetch', (event) => { console.log(`[ServiceWorker] Ignoring request: ${url.pathname}`); } + /** + * Detect scoped requests – their url starts with `/scope:` + * + * We need this mechanics because BroadcastChannel transmits + * events to all the listeners across all browser tabs. Scopes + * helps WASM workers ignore requests meant for other WASM workers. + */ + const isScopedRequest = url.pathname.startsWith(`/scope:`); + const scope = isScopedRequest ? url.pathname.split('/')[1].split(':')[1] : null; + const isPHPRequest = (url.pathname.endsWith('/') && url.pathname !== '/') || url.pathname.endsWith('.php'); if (isPHPRequest) { event.preventDefault(); @@ -48,6 +58,7 @@ self.addEventListener('fetch', (event) => { try { const message = { type: 'httpRequest', + scope, request: { path: url.pathname + url.search, method: event.request.method, @@ -74,11 +85,10 @@ self.addEventListener('fetch', (event) => { ); } - const isStaticFileRequest = url.pathname.startsWith(`${workerScope}/`); - if (isStaticFileRequest) { + const isScopedStaticFileRequest = isScopedRequest; + if (isScopedStaticFileRequest) { const scopedUrl = url + ''; - url.pathname = url.pathname.substr(workerScope.length); - const serverUrl = url + ''; + url.pathname = '/' + url.pathname.split('/').slice(2).join('/'); console.log(`[ServiceWorker] Rerouting static request from ${scopedUrl} to ${serverUrl}`); event.preventDefault();