Skip to content

Commit

Permalink
Scoped WordPress instances to support multiple browser tabs (#31)
Browse files Browse the repository at this point in the history
## What problem does this PR solve?

Adds support for running WASM WordPress in multiple browser tabs and solves #9.

All WordPress requests are routed through a single service worker shared between all browser tabs. The request lifecycle looks as follows:

1. A request originates in a specific tab
2. A service worker intercepts it and requests the same tab to pass it to its WASM WordPress instance
3. The tab renders it and sends the response to the service worker
4. The service worker responds to the intercepted HTTP request using the WordPress-generated response
5. The original tab receives the WordPress-generated response and displays it to the user

It's a back-and-forth conversation between a specific browser tab and the service worker.

Unfortunately, Service workers communicate with tabs using a `BroadcastChannel` – it's a messaging strategy that routes every message to every listener. As a result, each WordPress request was rendered in every tab, often causing unexpected behaviors.

## How does this PR propose to solve it?

This PR introduces a concept of WordPress `scope` and enables the service worker to post BroadcastChannel messages scoped to specific listeners.

Scoping a WordPress instance means installing it at a unique pathname starting with `/scope:<unique number>`. For example:

* In an unscoped WordPress instance, `/wp-login.php` would be available at `http://localhost:8778/wp-login.php`
* In a scoped WordPress instance, `/wp-login.php` would be available at `http://localhost:8778/scope:96253/wp-login.php`

The scope number is a random and unique number generated by a specific browser tab. The service worker is aware of this concept and will use any `/scope:` found in the request URL to tag all the related `BroadcastChannel` communication. The WASM workers running in specific browser tabs will then ignore all the `BroadcastChannel` communication with an unfamiliar `scope` attached.

## Alternatives considered

* Using the `scope` feature of ServiceWorker – it led to multiple worker registrations and was hard to reason about.
* Loading Workers from a tab-specific unique URL, e.g. `sw.js?tab_id=432` or `sw-1.js` – it led to the same problems as relying on the `scope` feature.
* Match the request with its originating tab in the ServiceWorker – There's not enough information available. The worker can't figure out the top-level client ID from a request originating in an iframe, and the top-level client wouldn't be able to tell whether the request originated in its browsing context.
* Scoping WordPress instance by a domain, e.g. `w87953.localhost` – it  would require setting up a catch-all DNS domain to even use this project. That's a steep barrier of entry.
* Displaying an error in other browser tabs – it would be a large and unnecessary limitation.

## How to test?

Run `npm run dev` and open the WASM WordPress page in a few different browser tabs. Log in, create pages, play with it, and confirm that each tab behaves as expected. Specifically:

* No tab should unexpectedly log you in or out 
* No pages and changes should leak between the browser tabs

## Follow-up work

* If a use-case arises, a tab could use `sessionStorage` to preserve the scope across page reloads.
  • Loading branch information
adamziel authored Oct 13, 2022
1 parent 2c51f4d commit 5990946
Show file tree
Hide file tree
Showing 7 changed files with 278 additions and 132 deletions.
44 changes: 25 additions & 19 deletions dist-web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand All @@ -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);
Expand All @@ -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",
Expand Down Expand Up @@ -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();
})();
116 changes: 76 additions & 40 deletions dist-web/service-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 7 additions & 2 deletions dist-web/wasm-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@
SCHEMA = "http";
HOSTNAME = "localhost";
PORT = 80;
HOST = ``;
HOST = "";
PATHNAME = "";
ABSOLUTE_URL = ``;
constructor(php) {
this.php = php;
Expand All @@ -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(`<?php
${this._setupErrorReportingCode()}
Expand Down Expand Up @@ -411,6 +413,9 @@ ADMIN;
}
_runWordPressCode(requestPath) {
let filePath = requestPath;
if (this.PATHNAME) {
filePath = filePath.substr(this.PATHNAME.length);
}
if (filePath.includes(".php")) {
filePath = filePath.split(".php")[0] + ".php";
} else {
Expand Down
11 changes: 8 additions & 3 deletions src/shared/wordpress.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ export default class WordPress {
SCHEMA = 'http';
HOSTNAME = 'localhost';
PORT = 80;
HOST = ``;
HOST = '';
PATHNAME = '';
ABSOLUTE_URL = ``;

constructor( php ) {
Expand All @@ -25,12 +26,13 @@ export default class WordPress {
useFetchForRequests: false,
...options
}
const url = new URL( urlString );
const url = new URL(urlString);
this.HOSTNAME = url.hostname;
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();

Expand Down Expand Up @@ -335,6 +337,9 @@ ADMIN;
_runWordPressCode( requestPath ) {
// Resolve the .php file the request should target.
let filePath = requestPath;
if (this.PATHNAME) {
filePath = filePath.substr( this.PATHNAME.length );
}

// If the path mentions a .php extension, that's our file's path.
if(filePath.includes(".php")) {
Expand Down
26 changes: 14 additions & 12 deletions src/web/app.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,25 @@ import { wordPressSiteUrl, serviceWorkerUrl, wasmWorkerUrl, wasmWorkerBackend }

async function init() {
console.log("[Main] Initializing the workers")

const tabScope = Math.random().toFixed(16);

const wasmWorker = await createWordPressWorker(
{
backend: getWorkerBackend( wasmWorkerBackend, wasmWorkerUrl ),
wordPressSiteUrl: wordPressSiteUrl
}
);
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);
}
);
},
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();
Loading

0 comments on commit 5990946

Please sign in to comment.