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: [