Skip to content

Commit

Permalink
[Web] Don't force-reload the Service Worker (#561)
Browse files Browse the repository at this point in the history
Removes the entire concept of detecting the service worker version and enforcing an update on mismatch.

Auto-reloading the service worker when it's updated thrashes any `postMessage` communication that's in progress at the moment of calling `skipWaiting()`. This causes Playground to hang at the "login" step in WordPress/wordpress-playground#559.

However, the browsers handle a lot by default:

* `registration.update()` method downloads the new service-worker.js file and compares it byte-by-byte with the existing one
* The previous service worker won't die until all the browser tabs it serves are closed
* The new service worker will automatically replace the previous one afterwards

The only problem remains deploying a website that is backwards–incompatible with the previous service worker. This is tracked separately in WordPress/wordpress-playground#566

Co-authored-by: Dennis Snell <dennis.snell@automattic.com>
  • Loading branch information
Pookie717 and dmsnell committed Jun 18, 2023
1 parent 438141d commit ec0a129
Show file tree
Hide file tree
Showing 5 changed files with 29 additions and 136 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -16,36 +16,7 @@ import {
* @param config
*/
export function initializeServiceWorker(config: ServiceWorkerConfiguration) {
const { version, handleRequest = defaultRequestHandler } = config;
/**
* Enable the client app to force-update the service worker
* registration.
*/
self.addEventListener('message', (event) => {
if (!event.data) {
return;
}

if (event.data === 'skip-waiting') {
self.skipWaiting();
}
});

/**
* Ensure the client gets claimed by this service worker right after the registration.
*
* Only requests from the "controlled" pages are resolved via the fetch listener below.
* However, simply registering the worker is not enough to make it the "controller" of
* the current page. The user still has to reload the page. If they don't an iframe
* pointing to /index.php will show a 404 message instead of a homepage.
*
* This activation handles saves the user reloading the page after the initial confusion.
* It immediately makes this worker the controller of any client that registers it.
*/
self.addEventListener('activate', (event) => {
// eslint-disable-next-line no-undef
event.waitUntil(self.clients.claim());
});
const { handleRequest = defaultRequestHandler } = config;

/**
* The main method. It captures the requests and loop them back to the
Expand All @@ -54,24 +25,6 @@ export function initializeServiceWorker(config: ServiceWorkerConfiguration) {
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);

// Provide a custom JSON response in the special /version endpoint
// so the frontend app can know whether it's time to update the
// service worker registration.
if (url.pathname === '/version') {
event.preventDefault();
const currentVersion =
typeof version === 'function' ? version() : version;
event.respondWith(
new Response(JSON.stringify({ version: currentVersion }), {
headers: {
'Content-Type': 'application/json',
},
status: 200,
})
);
return;
}

// Don't handle requests to the service worker script itself.
if (url.pathname.startsWith(self.location.pathname)) {
return;
Expand Down Expand Up @@ -236,13 +189,6 @@ export async function broadcastMessageExpectReply(message: any, scope: string) {
}

interface ServiceWorkerConfiguration {
/**
* The version of the service worker – exposed via the /version endpoint.
*
* This is used by the frontend app to know whether it's time to update
* the service worker registration.
*/
version: string | (() => string);
handleRequest?: (event: FetchEvent) => Promise<Response> | undefined;
}

Expand Down
75 changes: 14 additions & 61 deletions packages/php-wasm/web/src/lib/register-service-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,65 +14,28 @@ import { Remote } from 'comlink';
*/
export async function registerServiceWorker<
Client extends Remote<WebPHPEndpoint>
>(phpApi: Client, scope: string, scriptUrl: string, expectedVersion: string) {
const sw = (navigator as any).serviceWorker;
>(phpApi: Client, scope: string, scriptUrl: string) {
const sw = navigator.serviceWorker;
if (!sw) {
throw new Error('Service workers are not supported in this browser.');
}
const registrations = await sw.getRegistrations();
if (registrations.length > 0) {
const actualVersion = await getRegisteredServiceWorkerVersion();
if (expectedVersion !== actualVersion) {
console.debug(
`[window] Reloading the currently registered Service Worker ` +
`(expected version: ${expectedVersion}, registered version: ${actualVersion})`
);
for (const registration of registrations) {
let unregister = false;
try {
await registration.update();
} catch (e) {
// If the worker registration cannot be updated,
// we're probably seeing a blank page in the dev
// mode. Let's unregister the worker and reload
// the page.
unregister = true;
}
const waitingWorker =
registration.waiting || registration.installing;
if (waitingWorker && !unregister) {
if (actualVersion !== null) {
// If the worker exposes a version, it supports
// a "skip-waiting" message – let's force it to
// skip waiting.
waitingWorker.postMessage('skip-waiting');
} else {
// If the version is not exposed, we can't force
// the worker to skip waiting – let's unregister
// and reload the page.
unregister = true;
}
}
if (unregister) {
await registration.unregister();
window.location.reload();
}
}
}
} else {
console.debug(
`[window] Creating a Service Worker registration (version: ${expectedVersion})`
);
await sw.register(scriptUrl, {
type: 'module',
});
}

console.debug(`[window][sw] Registering a Service Worker`);
const registration = await sw.register(scriptUrl, {
type: 'module',
// Always bypass HTTP cache when fetching the new Service Worker script:
updateViaCache: 'none',
});

// Check if there's a new service worker available and, if so, enqueue
// the update:
await registration.update();

// Proxy the service worker messages to the worker thread:
navigator.serviceWorker.addEventListener(
'message',
async function onMessage(event) {
console.debug('Message from ServiceWorker', event);
console.debug('[window][sw] Message from ServiceWorker', event);
/**
* Ignore events meant for other PHP instances to
* avoid handling the same event twice.
Expand All @@ -94,13 +57,3 @@ export async function registerServiceWorker<

sw.startMessages();
}

async function getRegisteredServiceWorkerVersion() {
try {
const response = await fetch('/version');
const data = await response.json();
return data.version;
} catch (e) {
return null;
}
}
8 changes: 0 additions & 8 deletions packages/playground/remote/service-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,6 @@ import {
} from '@php-wasm/web-service-worker';
import { isUploadedFilePath } from './src/lib/is-uploaded-file-path';

// @ts-ignore
import { serviceWorkerVersion } from 'virtual:service-worker-version';

if (!(self as any).document) {
// Workaround: vite translates import.meta.url
// to document.currentScript which fails inside of
Expand All @@ -26,11 +23,6 @@ if (!(self as any).document) {
}

initializeServiceWorker({
// Always use a random version in development to avoid caching issues.
// @ts-ignore
version: import.meta.env.DEV
? () => Math.random() + ''
: serviceWorkerVersion,
handleRequest(event) {
const fullUrl = new URL(event.request.url);
let scope = getURLScope(fullUrl);
Expand Down
19 changes: 14 additions & 5 deletions packages/playground/remote/src/lib/boot-playground-remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ import {
consumeAPI,
recommendedWorkerBackend,
} from '@php-wasm/web';
// @ts-ignore
import { serviceWorkerVersion } from 'virtual:service-worker-version';

import type { PlaygroundWorkerEndpoint } from './worker-thread';
import type { WebClientMixin } from './playground-client';
Expand Down Expand Up @@ -47,6 +45,18 @@ import serviceWorkerPath from '../../service-worker.ts?worker&url';
import { LatestSupportedWordPressVersion } from './get-wordpress-module';
export const serviceWorkerUrl = new URL(serviceWorkerPath, origin);

// Prevent Vite from hot-reloading this file – it would
// cause bootPlaygroundRemote() to register another web worker
// without unregistering the previous one. The first web worker
// would then fight for service worker requests with the second
// one. It's a difficult problem to debug and HMR isn't that useful
// here anyway – let's just disable it for this file.
// @ts-ignore
if (import.meta.hot) {
// @ts-ignore
import.meta.hot.accept(() => {});
}

const query = new URL(document.location.href).searchParams;
export async function bootPlaygroundRemote() {
assertNotInfiniteLoadingLoop();
Expand Down Expand Up @@ -158,11 +168,10 @@ export async function bootPlaygroundRemote() {
await registerServiceWorker(
workerApi,
await workerApi.scope,
serviceWorkerUrl + '',
serviceWorkerVersion
serviceWorkerUrl + ''
);
setupPostMessageRelay(wpFrame, getOrigin(await playground.absoluteUrl));
wpFrame.src = await playground.pathToInternalUrl('/');
setupPostMessageRelay(wpFrame, getOrigin(await playground.absoluteUrl));

setAPIReady();

Expand Down
7 changes: 0 additions & 7 deletions packages/playground/remote/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ import dts from 'vite-plugin-dts';
// eslint-disable-next-line @nx/enforce-module-boundaries
import { remoteDevServerHost, remoteDevServerPort } from '../build-config';
// eslint-disable-next-line @nx/enforce-module-boundaries
import virtualModule from '../vite-virtual-module';
// eslint-disable-next-line @nx/enforce-module-boundaries
import { viteTsConfigPaths } from '../../vite-ts-config-paths';

const path = (filename: string) => new URL(filename, import.meta.url).pathname;
Expand All @@ -19,11 +17,6 @@ const plugins = [
tsConfigFilePath: join(__dirname, 'tsconfig.lib.json'),
skipDiagnostics: true,
}),
virtualModule({
name: 'service-worker-version',
// @TODO: compute a hash of the service worker chunk instead of using the build timestamp
content: `export const serviceWorkerVersion = '${Date.now()}';`,
}),
];
export default defineConfig({
assetsInclude: ['**/*.wasm', '*.data'],
Expand Down

0 comments on commit ec0a129

Please sign in to comment.