web: use service worker for cross-tab communication only as a fallback

This commit basically reverts 1b63b2dccf.
Using service worker for this by default is a bad idea because:

1. It takes some time for the service worker to be installed and ready
which slows down first launch significantly
2. Service worker is not as reliable when updating from version to
version

We can, however, use it as a fallback i.e for devices that don't yet
support shared worker.
This commit is contained in:
Abdullah Atta
2025-03-28 20:52:18 +05:00
parent 84d5420b51
commit c23f31f284
4 changed files with 127 additions and 50 deletions

View File

@@ -17,8 +17,15 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import SharedWorker from "./shared-service.worker.ts?sharedworker";
import { Mutex } from "async-mutex";
const sharedWorker = globalThis.SharedWorker
? new SharedWorker({
name: "SharedService"
})
: null;
export class SharedService<T extends object> extends EventTarget {
#clientId: Promise<string>;
@@ -152,7 +159,7 @@ export class SharedService<T extends object> extends EventTarget {
}
try {
thisArg.#sendPortToClient(data, requestedPort);
await thisArg.#sendPortToClient(data, requestedPort);
} catch (e) {
console.error(e, providerId, data);
// retry if port has been neutered, this can happen when
@@ -202,47 +209,69 @@ export class SharedService<T extends object> extends EventTarget {
}
async #sendPortToClient(message: any, port: MessagePort) {
// if (!sharedWorker)
// throw new Error("Shared worker is not supported in this environment.");
// sharedWorker.port.postMessage(message, [port]);
// Return the port to the client via the service worker.
const serviceWorker = await navigator.serviceWorker.ready;
serviceWorker.active?.postMessage(message, [port]);
if (!sharedWorker) {
// Return the port to the client via the service worker.
const serviceWorker = await navigator.serviceWorker.ready;
serviceWorker.active?.postMessage(message, [port]);
} else sharedWorker.port.postMessage(message, [port]);
}
async #getClientId() {
// Getting the clientId from the service worker accomplishes two things:
// 1. It gets the clientId for this context.
// 2. It ensures that the service worker is activated.
//
// It is possible to do this without polling but it requires about the
// same amount of code and using fetch makes 100% certain the service
// worker is handling requests.
let clientId: string | undefined;
while (!clientId) {
clientId = await fetch("/clientId").then((response) => {
if (response.ok && !!response.headers.get("x-client-id")) {
return response.text();
}
console.warn("service worker not ready, retrying...");
return new Promise((resolve) => setTimeout(resolve, 100));
if (sharedWorker) {
console.time("getting client id");
// Use a Web Lock to determine our clientId.
const nonce = Math.random().toString();
const clientId = await navigator.locks.request(nonce, async () => {
console.log("got clientid lock");
const { held } = await navigator.locks.query();
return held?.find((lock) => lock.name === nonce)?.clientId;
});
// Acquire a Web Lock named after the clientId. This lets other contexts
// track this context's lifetime.
// TODO: It would be better to lock on the clientId+serviceName (passing
// that lock name in the service request). That would allow independent
// instance lifetime tracking.
await SharedService.#acquireContextLock(clientId);
// Configure message forwarding via the SharedWorker. This must be
// done after acquiring the clientId lock to avoid a race condition
// in the SharedWorker.
sharedWorker?.port.addEventListener("message", (event) => {
event.data.ports = event.ports;
this.dispatchEvent(new MessageEvent("message", { data: event.data }));
});
sharedWorker?.port.start();
sharedWorker?.port.postMessage({ clientId });
console.timeEnd("getting client id");
return clientId;
} else {
// Getting the clientId from the service worker accomplishes two things:
// 1. It gets the clientId for this context.
// 2. It ensures that the service worker is activated.
//
// It is possible to do this without polling but it requires about the
// same amount of code and using fetch makes 100% certain the service
// worker is handling requests.
let clientId: string | undefined;
while (!clientId) {
clientId = await fetch("/clientId").then((response) => {
if (response.ok && !!response.headers.get("x-client-id")) {
return response.text();
}
console.warn("service worker not ready, retrying...");
return new Promise((resolve) => setTimeout(resolve, 100));
});
}
navigator.serviceWorker.addEventListener("message", (event) => {
event.data.ports = event.ports;
this.dispatchEvent(new MessageEvent("message", { data: event.data }));
});
return clientId;
}
navigator.serviceWorker.addEventListener("message", (event) => {
event.data.ports = event.ports;
this.dispatchEvent(new MessageEvent("message", { data: event.data }));
});
// Acquire a Web Lock named after the clientId. This lets other contexts
// track this context's lifetime.
// TODO: It would be better to lock on the clientId+serviceName (passing
// that lock name in the service request). That would allow independent
// instance lifetime tracking.
await SharedService.#acquireContextLock(clientId);
return clientId;
}
async #providerChange() {

View File

@@ -0,0 +1,55 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
/* eslint-disable no-var */
/// <reference lib="webworker" />
export default null;
declare var self: SharedWorkerGlobalScope & typeof globalThis;
const mapClientIdToPort: Map<string, MessagePort> = new Map();
self.addEventListener("connect", (event) => {
console.log("connected", event);
// The first message from a client associates the clientId with the port.
const workerPort = event.ports[0];
workerPort.addEventListener(
"message",
(event) => {
console.log("received message", event.data);
mapClientIdToPort.set(event.data.clientId, workerPort);
// Remove the entry when the client goes away, which we detect when
// the lock on its name becomes available.
navigator.locks.request(event.data.clientId, { mode: "shared" }, () => {
mapClientIdToPort.get(event.data.clientId)?.close();
mapClientIdToPort.delete(event.data.clientId);
});
// Subsequent messages will be forwarded.
workerPort.addEventListener("message", (event) => {
const port = mapClientIdToPort.get(event.data.clientId);
console.log("sending message to client", event.data.clientId, port);
port?.postMessage(event.data, [...event.ports]);
});
},
{ once: true }
);
workerPort.start();
});

View File

@@ -46,7 +46,7 @@ const isLocalhost = Boolean(
);
export function register(config: ServiceWorkerRegistrationConfig) {
if ("serviceWorker" in navigator) {
if (import.meta.env.PROD && "serviceWorker" in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(PUBLIC_URL, window.location.href);
if (publicUrl.origin !== window.location.origin) {
@@ -55,11 +55,8 @@ export function register(config: ServiceWorkerRegistrationConfig) {
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
return;
}
console.log("registering service worker");
const swUrl = import.meta.env.PROD
? `${PUBLIC_URL}/service-worker.js`
: "/dev-sw.js?dev-sw";
const swUrl = `${PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config);

View File

@@ -147,15 +147,11 @@ export default defineConfig({
strategies: "injectManifest",
minify: true,
manifest: WEB_MANIFEST,
injectRegister: false,
injectRegister: null,
srcDir: "",
filename:
process.env.NODE_ENV === "production"
? "service-worker.ts"
: "service-worker.dev.ts",
devOptions: {
enabled: true
},
filename: "service-worker.ts",
mode: "production",
workbox: { mode: "production" },
injectManifest: {
globPatterns: ["**/*.{js,css,html,wasm}", "**/Inter-*.woff2"],
globIgnores: [