Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Web] Don't force-reload the Service Worker #561

Merged
merged 9 commits into from
Jun 18, 2023
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