web: replace shared worker with service worker for sqlite

This commit is contained in:
Abdullah Atta
2025-03-25 15:58:47 +05:00
parent 703456b808
commit 117fc44f00
6 changed files with 235 additions and 243 deletions

View File

@@ -17,15 +17,8 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>. 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"; import { Mutex } from "async-mutex";
const sharedWorker = globalThis.SharedWorker
? new SharedWorker({
name: "SharedService"
})
: null;
export class SharedService<T extends object> extends EventTarget { export class SharedService<T extends object> extends EventTarget {
#clientId: Promise<string>; #clientId: Promise<string>;
@@ -208,20 +201,38 @@ export class SharedService<T extends object> extends EventTarget {
this.#onClose.abort(); this.#onClose.abort();
} }
#sendPortToClient(message: any, port: MessagePort) { async #sendPortToClient(message: any, port: MessagePort) {
if (!sharedWorker) // if (!sharedWorker)
throw new Error("Shared worker is not supported in this environment."); // throw new Error("Shared worker is not supported in this environment.");
sharedWorker.port.postMessage(message, [port]); // 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() { async #getClientId() {
console.time("getting client id"); // Getting the clientId from the service worker accomplishes two things:
// Use a Web Lock to determine our clientId. // 1. It gets the clientId for this context.
const nonce = Math.random().toString(); // 2. It ensures that the service worker is activated.
const clientId = await navigator.locks.request(nonce, async () => { //
console.log("got clientid lock"); // It is possible to do this without polling but it requires about the
const { held } = await navigator.locks.query(); // same amount of code and using fetch makes 100% certain the service
return held?.find((lock) => lock.name === nonce)?.clientId; // 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 // Acquire a Web Lock named after the clientId. This lets other contexts
@@ -231,17 +242,6 @@ export class SharedService<T extends object> extends EventTarget {
// instance lifetime tracking. // instance lifetime tracking.
await SharedService.#acquireContextLock(clientId); 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; return clientId;
} }

View File

@@ -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 <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) { 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. // The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(PUBLIC_URL, window.location.href); const publicUrl = new URL(PUBLIC_URL, window.location.href);
if (publicUrl.origin !== window.location.origin) { 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 // serve assets; see https://github.com/facebook/create-react-app/issues/2374
return; 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) { if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not. // This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config); checkValidServiceWorker(swUrl, config);

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
/// <reference lib="webworker" />
/// <reference lib="es2015" />
// 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<string, any[]>();
// 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 });
}
});
}

View File

@@ -19,7 +19,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/* eslint-disable no-var */ /* eslint-disable no-var */
/// <reference lib="webworker" /> /// <reference lib="webworker" />
import { clientsClaim } from "workbox-core";
import { ExpirationPlugin } from "workbox-expiration"; import { ExpirationPlugin } from "workbox-expiration";
import { import {
precacheAndRoute, precacheAndRoute,
@@ -28,12 +27,12 @@ import {
} from "workbox-precaching"; } from "workbox-precaching";
import { registerRoute } from "workbox-routing"; import { registerRoute } from "workbox-routing";
import { StaleWhileRevalidate } from "workbox-strategies"; import { StaleWhileRevalidate } from "workbox-strategies";
import "./service-worker.dev.js";
declare var self: ServiceWorkerGlobalScope & typeof globalThis; declare var self: ServiceWorkerGlobalScope & typeof globalThis;
clientsClaim();
cleanupOutdatedCaches(); cleanupOutdatedCaches();
precacheAndRoute(self.__WB_MANIFEST); precacheAndRoute(self.__WB_MANIFEST);
// Set up App Shell-style routing, so that all navigation requests // Set up App Shell-style routing, so that all navigation requests
@@ -76,153 +75,3 @@ registerRoute(
] ]
}) })
); );
const downloads = new Map<string, any[]>();
// 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 });
}
});
}

View File

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