diff --git a/dist-web/app.js b/dist-web/app.js index f9150ccebd..943ff345cc 100644 --- a/dist-web/app.js +++ b/dist-web/app.js @@ -39,14 +39,17 @@ // 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); - const serviceWorkerChannel = new BroadcastChannel("wordpress-service-worker"); + 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") { @@ -67,12 +70,8 @@ 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 }) { + async function createWordPressWorker({ backend, wordPressSiteUrl: wordPressSiteUrl2, scope }) { while (true) { try { await backend.sendMessage({ type: "is_alive" }, 50); @@ -81,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", @@ -153,20 +158,21 @@ // src/web/app.mjs async function init() { console.log("[Main] Initializing the workers"); - const wasmWorker = await createWordPressWorker( - { - backend: getWorkerBackend(wasmWorkerBackend, wasmWorkerUrl), - wordPressSiteUrl - } - ); - 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); - } - ); + }, + scope: tabScope + }); console.log("[Main] Workers are ready"); - document.querySelector("#wp").src = "/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 9cc5bdb782..91ce8ed7de 100644 --- a/dist-web/service-worker.js +++ b/dist-web/service-worker.js @@ -31,56 +31,92 @@ } // src/web/service-worker.js - var broadcastChannel = new BroadcastChannel("wordpress-service-worker"); + var broadcastChannel = new BroadcastChannel(`wordpress-service-worker`); self.addEventListener("activate", (event) => { event.waitUntil(clients.claim()); }); 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 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(); + 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", + scope, + 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 isScopedStaticFileRequest = isScopedRequest; + if (isScopedStaticFileRequest) { + const scopedUrl = url + ""; + url.pathname = "/" + url.pathname.split("/").slice(2).join("/"); + 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..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}`; + 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(` { + onRequest: async (request) => { return await wasmWorker.HTTPRequest(request); - } - ); + }, + scope: tabScope + }); console.log("[Main] Workers are ready") - document.querySelector('#wp').src = '/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 3d6d4a26f3..3da9d568d4 100644 --- a/src/web/library.js +++ b/src/web/library.js @@ -4,14 +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) { +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); - const serviceWorkerChannel = new BroadcastChannel('wordpress-service-worker'); + 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; @@ -39,16 +49,16 @@ export async function registerServiceWorker(url, onRequest) { await sleep(0); const wordPressDomain = new URL(url).origin; - const response = await fetch(`${wordPressDomain}/wp-admin/atomlib.php`); - if (!response.ok) { - // The service worker did not claim this page for some reason. Let's reload. - window.location.reload(); - } + // const response = await fetch(`${wordPressDomain}/wp-admin/atomlib.php`); + // if (!response.ok) { + // // The service worker did not claim this page for some reason. Let's reload. + // window.location.reload(); + // } } // // -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 { @@ -60,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({ @@ -68,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 c4d4043018..fde86a7795 100644 --- a/src/web/service-worker.js +++ b/src/web/service-worker.js @@ -1,6 +1,6 @@ import { postMessageExpectReply, awaitReply } from '../shared/messaging.mjs'; -const broadcastChannel = new BroadcastChannel( 'wordpress-service-worker' ); +const broadcastChannel = new BroadcastChannel( `wordpress-service-worker` ); /** * Ensure the client gets claimed by this service worker right after the registration. @@ -16,61 +16,129 @@ const broadcastChannel = new BroadcastChannel( 'wordpress-service-worker' ); 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 */ -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 ]; - } - - let wpResponse; - try { - const message = { - type: 'httpRequest', - request: { - path: url.pathname + url.search, - method: event.request.method, - _POST: post, - headers: requestHeaders, + /** + * 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(); + 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', + scope, + 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; - } - - accept( new Response( - wpResponse.body, - { - headers: wpResponse.headers, - }, - ) ); - } ), - ); -} ); + )); + }), + ); + } + + const isScopedStaticFileRequest = isScopedRequest; + if (isScopedStaticFileRequest) { + const scopedUrl = url + ''; + url.pathname = '/' + url.pathname.split('/').slice(2).join('/'); + 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}`); +}); + +/** + * 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' ) {