From 0882c3ecf5dbaa9862fdf90c609e74dd476a80c0 Mon Sep 17 00:00:00 2001 From: Abdullah Atta Date: Fri, 15 Sep 2023 10:23:56 +0500 Subject: [PATCH] web: use single service worker for everything --- apps/web/public/stream-saver-sw.js | 154 -------------------- apps/web/src/index.tsx | 5 +- apps/web/src/service-worker-registration.ts | 2 + apps/web/src/service-worker.ts | 124 ++++++++++++++++ apps/web/src/utils/stream-saver/mitm.ts | 57 +++----- 5 files changed, 148 insertions(+), 194 deletions(-) delete mode 100644 apps/web/public/stream-saver-sw.js diff --git a/apps/web/public/stream-saver-sw.js b/apps/web/public/stream-saver-sw.js deleted file mode 100644 index 79161736c..000000000 --- a/apps/web/public/stream-saver-sw.js +++ /dev/null @@ -1,154 +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-restricted-globals */ - -self.addEventListener("install", () => { - self.skipWaiting(); -}); - -self.addEventListener("activate", (event) => { - event.waitUntil(self.clients.claim()); -}); - -const map = new Map(); - -// This should be called once per download -// Each event has a dataChannel that the data will be piped through -self.onmessage = (event) => { - // We send a heartbeat every x second to keep the - // service worker alive if a transferable stream is not sent - if (event.data === "ping") { - return; - } - - const data = event.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); - } - - map.set(downloadUrl, metadata); - port.postMessage({ download: downloadUrl }); -}; - -function createStream(port) { - // 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 }); - } - }); -} - -self.onfetch = (event) => { - const url = event.request.url; - - // this only works for Firefox - if (url.endsWith("/ping")) { - return event.respondWith(new Response("pong")); - } - - const metadata = map.get(url); - if (!metadata) return null; - - const [stream, data, port] = metadata; - - map.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" - }); - - let 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" }); -}; diff --git a/apps/web/src/index.tsx b/apps/web/src/index.tsx index 9e8e9d0ab..ad8a3fa6e 100644 --- a/apps/web/src/index.tsx +++ b/apps/web/src/index.tsx @@ -51,8 +51,6 @@ async function renderApp() { const serviceWorkerWhitelist: Routes[] = ["default"]; async function initializeServiceWorker() { if (!IS_DESKTOP_APP) { - await register(); - logger.info("Initializing service worker..."); const serviceWorker = await import("./service-worker-registration"); @@ -69,6 +67,9 @@ async function initializeServiceWorker() { AppEventManager.publish(AppEvents.updateDownloadCompleted, { version: formatted }); + }, + onSuccess(registration) { + register(registration); } }); // window.addEventListener("beforeinstallprompt", () => showInstallNotice()); diff --git a/apps/web/src/service-worker-registration.ts b/apps/web/src/service-worker-registration.ts index 5e439b3a6..a454e31f6 100644 --- a/apps/web/src/service-worker-registration.ts +++ b/apps/web/src/service-worker-registration.ts @@ -86,9 +86,11 @@ function registerValidSW( navigator.serviceWorker .register(swUrl) .then((registration) => { + if (config.onSuccess) config.onSuccess(registration); registration.onupdatefound = () => { const installingWorker = registration.installing; if (installingWorker == null) { + if (config.onSuccess) config.onSuccess(registration); return; } installingWorker.onstatechange = () => { diff --git a/apps/web/src/service-worker.ts b/apps/web/src/service-worker.ts index 91a62b9a8..e85d92ca9 100644 --- a/apps/web/src/service-worker.ts +++ b/apps/web/src/service-worker.ts @@ -80,6 +80,8 @@ registerRoute( }) ); +const downloads = new Map(); + // This allows the web app to trigger skipWaiting via // registration.waiting.postMessage({type: 'SKIP_WAITING'}) self.addEventListener("message", (event) => { @@ -87,6 +89,10 @@ self.addEventListener("message", (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; @@ -100,7 +106,125 @@ self.addEventListener("message", (event) => { }); } 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/src/utils/stream-saver/mitm.ts b/apps/web/src/utils/stream-saver/mitm.ts index a869d6b65..4ea325179 100644 --- a/apps/web/src/utils/stream-saver/mitm.ts +++ b/apps/web/src/utils/stream-saver/mitm.ts @@ -24,7 +24,7 @@ let keepAlive = () => { keepAlive = () => {}; const interval = setInterval(() => { if (sw) { - sw.postMessage("ping"); + sw.postMessage({ type: "PING" }); } else { const ping = location.href.substr(0, location.href.lastIndexOf("/")) + "/ping"; @@ -36,39 +36,6 @@ let keepAlive = () => { }, 10000); }; -function registerWorker() { - return navigator.serviceWorker - .getRegistration("./") - .then((swReg) => { - return ( - swReg || - navigator.serviceWorker.register("stream-saver-sw.js", { scope: "./" }) - ); - }) - .then((swReg) => { - console.log("Stream saver service worker registered!"); - const swRegTmp = swReg.installing || swReg.waiting; - - scope = swReg.scope; - let fn: () => void; - return ( - (sw = swReg.active) || - new Promise((resolve) => { - swRegTmp?.addEventListener( - "statechange", - (fn = () => { - if (swRegTmp.state === "activated") { - swRegTmp.removeEventListener("statechange", fn); - sw = swReg.active; - resolve(undefined); - } - }) - ); - }) - ); - }); -} - // Now that we have the Service Worker registered we can process messages export function postMessage( data: { @@ -81,6 +48,7 @@ export function postMessage( }, ports: MessagePort[] ) { + if (!sw) throw new Error("No service worker registered."); // It's important to have a messageChannel, don't want to interfere // with other simultaneous downloads if (!ports || !ports.length) { @@ -120,19 +88,32 @@ export function postMessage( // messageChannel.port2 to the service worker. The service worker can // then use the transferred port to reply via postMessage(), which // will in turn trigger the onmessage handler on messageChannel.port1. + const transferable = [ports[0]]; if (!data.transferringReadable) { keepAlive(); } - return sw?.postMessage(data, transferable); + return sw.postMessage({ type: "REGISTER_DOWNLOAD", ...data }, transferable); } -export async function register() { - if (navigator.serviceWorker) { - await registerWorker(); +export async function register(registration: ServiceWorkerRegistration) { + sw = registration.active; + scope = registration.scope; + if (!sw) { + const registrations = + (await navigator.serviceWorker?.getRegistrations()) || []; + for (const registration of registrations) { + if (registration.active) { + sw = registration.active; + scope = registration.scope; + } + } } + if (sw) console.log("Registered stream saver!"); + else console.error("Failed to register stream saver!"); + // FF v102 just started to supports transferable streams, but still needs to ping sw.js // even tough the service worker dose not have to do any kind of work and listen to any // messages... #305