From c23f31f284a52a90d4ed45da4bd4b4d494721cf8 Mon Sep 17 00:00:00 2001 From: Abdullah Atta Date: Fri, 28 Mar 2025 20:52:18 +0500 Subject: [PATCH] web: use service worker for cross-tab communication only as a fallback This commit basically reverts 1b63b2dccfd43c74541e79ddc4850e7d972cb9d0. 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. --- apps/web/src/common/sqlite/shared-service.ts | 103 +++++++++++------- .../common/sqlite/shared-service.worker.ts | 55 ++++++++++ apps/web/src/service-worker-registration.ts | 7 +- apps/web/vite.config.ts | 12 +- 4 files changed, 127 insertions(+), 50 deletions(-) create mode 100644 apps/web/src/common/sqlite/shared-service.worker.ts diff --git a/apps/web/src/common/sqlite/shared-service.ts b/apps/web/src/common/sqlite/shared-service.ts index ee0ed2a98..23e5ee570 100644 --- a/apps/web/src/common/sqlite/shared-service.ts +++ b/apps/web/src/common/sqlite/shared-service.ts @@ -17,8 +17,15 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . */ +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 extends EventTarget { #clientId: Promise; @@ -152,7 +159,7 @@ export class SharedService 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 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() { diff --git a/apps/web/src/common/sqlite/shared-service.worker.ts b/apps/web/src/common/sqlite/shared-service.worker.ts new file mode 100644 index 000000000..7a6c6cdad --- /dev/null +++ b/apps/web/src/common/sqlite/shared-service.worker.ts @@ -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 . +*/ +/* eslint-disable no-var */ + +/// + +export default null; + +declare var self: SharedWorkerGlobalScope & typeof globalThis; +const mapClientIdToPort: Map = 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(); +}); diff --git a/apps/web/src/service-worker-registration.ts b/apps/web/src/service-worker-registration.ts index b6c0e7587..b3a17d484 100644 --- a/apps/web/src/service-worker-registration.ts +++ b/apps/web/src/service-worker-registration.ts @@ -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); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 5d41c300d..8cb259a5e 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -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: [