From 1b63b2dccfd43c74541e79ddc4850e7d972cb9d0 Mon Sep 17 00:00:00 2001 From: Abdullah Atta Date: Tue, 25 Mar 2025 15:58:47 +0500 Subject: [PATCH] web: replace shared worker with service worker for sqlite --- apps/web/src/common/sqlite/shared-service.ts | 58 +++--- .../common/sqlite/shared-service.worker.ts | 55 ----- apps/web/src/service-worker-registration.ts | 7 +- apps/web/src/service-worker.dev.ts | 191 ++++++++++++++++++ apps/web/src/service-worker.ts | 155 +------------- apps/web/vite.config.ts | 12 +- 6 files changed, 235 insertions(+), 243 deletions(-) delete mode 100644 apps/web/src/common/sqlite/shared-service.worker.ts create mode 100644 apps/web/src/service-worker.dev.ts diff --git a/apps/web/src/common/sqlite/shared-service.ts b/apps/web/src/common/sqlite/shared-service.ts index 90e326cad..ee0ed2a98 100644 --- a/apps/web/src/common/sqlite/shared-service.ts +++ b/apps/web/src/common/sqlite/shared-service.ts @@ -17,15 +17,8 @@ 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; @@ -208,20 +201,38 @@ export class SharedService extends EventTarget { this.#onClose.abort(); } - #sendPortToClient(message: any, port: MessagePort) { - if (!sharedWorker) - throw new Error("Shared worker is not supported in this environment."); - sharedWorker.port.postMessage(message, [port]); + 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]); } async #getClientId() { - 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; + // 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 })); }); // Acquire a Web Lock named after the clientId. This lets other contexts @@ -231,17 +242,6 @@ export class SharedService extends EventTarget { // 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; } diff --git a/apps/web/src/common/sqlite/shared-service.worker.ts b/apps/web/src/common/sqlite/shared-service.worker.ts deleted file mode 100644 index 7a6c6cdad..000000000 --- a/apps/web/src/common/sqlite/shared-service.worker.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* -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 b3a17d484..b6c0e7587 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 (import.meta.env.PROD && "serviceWorker" in navigator) { + if ("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,8 +55,11 @@ 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 = `${PUBLIC_URL}/service-worker.js`; + const swUrl = import.meta.env.PROD + ? `${PUBLIC_URL}/service-worker.js` + : "/dev-sw.js?dev-sw"; 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/src/service-worker.dev.ts b/apps/web/src/service-worker.dev.ts new file mode 100644 index 000000000..edac9d752 --- /dev/null +++ b/apps/web/src/service-worker.dev.ts @@ -0,0 +1,191 @@ +/* +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 . +*/ + +/// +/// +// just need this to satisfy TS +import type {} from "workbox-core"; + +declare const self: ServiceWorkerGlobalScope & typeof globalThis; + +self.addEventListener("activate", () => self.clients.claim()); + +const downloads = new Map(); + +// This allows the web app to trigger skipWaiting via +// registration.waiting.postMessage({type: 'SKIP_WAITING'}) +self.addEventListener("message", async (event) => { + const { data } = event; + if (!data) return; + + if (data.sharedService) { + const client = await self.clients.get(event.data.clientId); + client?.postMessage(event.data, event.ports as MessagePort[]); + return; + } + + switch (data.type) { + // We send a heartbeat every x second to keep the + // service worker alive if a transferable stream is not sent + case "PING": + break; + case "SKIP_WAITING": + self.skipWaiting(); + break; + case "GET_VERSION": + { + if (!event.source) return; + event.source.postMessage({ + type: data.type, + version: APP_VERSION, + hash: GIT_HASH, + isBeta: IS_BETA + }); + } + break; + case "REGISTER_DOWNLOAD": + { + console.log("register download", data); + const downloadUrl = + data.url || + self.registration.scope + + Math.random() + + "/" + + (typeof data === "string" ? data : data.filename); + const port = event.ports[0]; + const metadata = new Array(3); // [stream, data, port] + + metadata[1] = data; + metadata[2] = port; + + if (event.data.transferringReadable) { + port.onmessage = (evt) => { + port.onmessage = null; + metadata[0] = evt.data.readableStream; + }; + } else { + metadata[0] = createStream(port); + } + + downloads.set(downloadUrl, metadata); + port.postMessage({ download: downloadUrl }); + } + break; + default: + break; + } +}); + +self.addEventListener("fetch", (event) => { + const url = event.request.url; + + if (url === self.registration.scope + "clientId") { + return event.respondWith( + new Response(event.clientId, { + headers: { "Content-Type": "text/plain", "X-Client-Id": event.clientId } + }) + ); + } + + // this only works for Firefox + if (url.endsWith("/ping")) { + return event.respondWith(new Response("pong")); + } + + const metadata = downloads.get(url); + if (!metadata) return null; + + const [stream, data, port] = metadata; + + downloads.delete(url); + + // Not comfortable letting any user control all headers + // so we only copy over the length & disposition + const responseHeaders = new Headers({ + "Content-Type": "application/octet-stream; charset=utf-8", + + // To be on the safe side, The link can be opened in a iframe. + // but octet-stream should stop it. + "Content-Security-Policy": "default-src 'none'", + "X-Content-Security-Policy": "default-src 'none'", + "X-WebKit-CSP": "default-src 'none'", + "X-XSS-Protection": "1; mode=block" + }); + + const headers = new Headers(data.headers || {}); + + if (headers.has("Content-Length")) { + responseHeaders.set("Content-Length", headers.get("Content-Length")!); + } + + if (headers.has("Content-Disposition")) { + responseHeaders.set( + "Content-Disposition", + headers.get("Content-Disposition")! + ); + } + + // data, data.filename and size should not be used anymore + if (data.size) { + console.warn("Depricated"); + responseHeaders.set("Content-Length", data.size); + } + + let fileName = typeof data === "string" ? data : data.filename; + if (fileName) { + console.warn("Depricated"); + // Make filename RFC5987 compatible + fileName = encodeURIComponent(fileName) + .replace(/['()]/g, escape) + .replace(/\*/g, "%2A"); + responseHeaders.set( + "Content-Disposition", + "attachment; filename*=UTF-8''" + fileName + ); + } + + event.respondWith(new Response(stream, { headers: responseHeaders })); + + port.postMessage({ debug: "Download started" }); +}); + +function createStream(port: MessagePort) { + // ReadableStream is only supported by chrome 52 + return new ReadableStream({ + start(controller) { + // When we receive data on the messageChannel, we write + port.onmessage = ({ data }) => { + if (data === "end") { + return controller.close(); + } + + if (data === "abort") { + controller.error("Aborted the download"); + return; + } + + controller.enqueue(data); + }; + }, + cancel(reason) { + console.log("user aborted", reason); + port.postMessage({ abort: true }); + } + }); +} diff --git a/apps/web/src/service-worker.ts b/apps/web/src/service-worker.ts index c6cb46d3c..e8cb86d1c 100644 --- a/apps/web/src/service-worker.ts +++ b/apps/web/src/service-worker.ts @@ -19,7 +19,6 @@ along with this program. If not, see . /* eslint-disable no-var */ /// -import { clientsClaim } from "workbox-core"; import { ExpirationPlugin } from "workbox-expiration"; import { precacheAndRoute, @@ -28,12 +27,12 @@ import { } from "workbox-precaching"; import { registerRoute } from "workbox-routing"; import { StaleWhileRevalidate } from "workbox-strategies"; +import "./service-worker.dev.js"; declare var self: ServiceWorkerGlobalScope & typeof globalThis; -clientsClaim(); - cleanupOutdatedCaches(); + precacheAndRoute(self.__WB_MANIFEST); // Set up App Shell-style routing, so that all navigation requests @@ -76,153 +75,3 @@ registerRoute( ] }) ); - -const downloads = new Map(); - -// This allows the web app to trigger skipWaiting via -// registration.waiting.postMessage({type: 'SKIP_WAITING'}) -self.addEventListener("message", (event) => { - const { data } = event; - if (!data) return; - - switch (data.type) { - // We send a heartbeat every x second to keep the - // service worker alive if a transferable stream is not sent - case "PING": - break; - case "SKIP_WAITING": - self.skipWaiting(); - break; - case "GET_VERSION": - { - if (!event.source) return; - event.source.postMessage({ - type: data.type, - version: APP_VERSION, - hash: GIT_HASH, - isBeta: IS_BETA - }); - } - break; - case "REGISTER_DOWNLOAD": - { - console.log("register download", data); - const downloadUrl = - data.url || - self.registration.scope + - Math.random() + - "/" + - (typeof data === "string" ? data : data.filename); - const port = event.ports[0]; - const metadata = new Array(3); // [stream, data, port] - - metadata[1] = data; - metadata[2] = port; - - if (event.data.transferringReadable) { - port.onmessage = (evt) => { - port.onmessage = null; - metadata[0] = evt.data.readableStream; - }; - } else { - metadata[0] = createStream(port); - } - - downloads.set(downloadUrl, metadata); - port.postMessage({ download: downloadUrl }); - } - break; - default: - break; - } -}); - -self.addEventListener("fetch", (event) => { - const url = event.request.url; - - // this only works for Firefox - if (url.endsWith("/ping")) { - return event.respondWith(new Response("pong")); - } - - const metadata = downloads.get(url); - if (!metadata) return null; - - const [stream, data, port] = metadata; - - downloads.delete(url); - - // Not comfortable letting any user control all headers - // so we only copy over the length & disposition - const responseHeaders = new Headers({ - "Content-Type": "application/octet-stream; charset=utf-8", - - // To be on the safe side, The link can be opened in a iframe. - // but octet-stream should stop it. - "Content-Security-Policy": "default-src 'none'", - "X-Content-Security-Policy": "default-src 'none'", - "X-WebKit-CSP": "default-src 'none'", - "X-XSS-Protection": "1; mode=block" - }); - - const headers = new Headers(data.headers || {}); - - if (headers.has("Content-Length")) { - responseHeaders.set("Content-Length", headers.get("Content-Length")!); - } - - if (headers.has("Content-Disposition")) { - responseHeaders.set( - "Content-Disposition", - headers.get("Content-Disposition")! - ); - } - - // data, data.filename and size should not be used anymore - if (data.size) { - console.warn("Depricated"); - responseHeaders.set("Content-Length", data.size); - } - - let fileName = typeof data === "string" ? data : data.filename; - if (fileName) { - console.warn("Depricated"); - // Make filename RFC5987 compatible - fileName = encodeURIComponent(fileName) - .replace(/['()]/g, escape) - .replace(/\*/g, "%2A"); - responseHeaders.set( - "Content-Disposition", - "attachment; filename*=UTF-8''" + fileName - ); - } - - event.respondWith(new Response(stream, { headers: responseHeaders })); - - port.postMessage({ debug: "Download started" }); -}); - -function createStream(port: MessagePort) { - // ReadableStream is only supported by chrome 52 - return new ReadableStream({ - start(controller) { - // When we receive data on the messageChannel, we write - port.onmessage = ({ data }) => { - if (data === "end") { - return controller.close(); - } - - if (data === "abort") { - controller.error("Aborted the download"); - return; - } - - controller.enqueue(data); - }; - }, - cancel(reason) { - console.log("user aborted", reason); - port.postMessage({ abort: true }); - } - }); -} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 8cb259a5e..5d41c300d 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -147,11 +147,15 @@ export default defineConfig({ strategies: "injectManifest", minify: true, manifest: WEB_MANIFEST, - injectRegister: null, + injectRegister: false, srcDir: "", - filename: "service-worker.ts", - mode: "production", - workbox: { mode: "production" }, + filename: + process.env.NODE_ENV === "production" + ? "service-worker.ts" + : "service-worker.dev.ts", + devOptions: { + enabled: true + }, injectManifest: { globPatterns: ["**/*.{js,css,html,wasm}", "**/Inter-*.woff2"], globIgnores: [