/// /// import { RpcHelper } from "@mercuryworkshop/rpc"; import type { Controllerbound, SWbound } from "./types"; import type { RawHeaders } from "@mercuryworkshop/proxy-transports"; function makeId(): string { return Math.random().toString(36).substring(2, 10); } const cookieResolvers: Record void> = {}; addEventListener("message", (e) => { if (!e.data) return; if (typeof e.data != "object") return; if (e.data.$sw$setCookieDone && typeof e.data.$sw$setCookieDone == "object") { const done = e.data.$sw$setCookieDone; const resolver = cookieResolvers[done.id]; if (resolver) { resolver(); delete cookieResolvers[done.id]; } } if ( e.data.$sw$initRemoteTransport && typeof e.data.$sw$initRemoteTransport == "object" ) { const { port, prefix } = e.data.$sw$initRemoteTransport; const relevantcontroller = tabs.find((tab) => new URL(prefix).pathname.startsWith(tab.prefix) ); if (!relevantcontroller) { console.error("No relevant controller found for transport init"); return; } relevantcontroller.rpc.call("initRemoteTransport", port, [port]); } }); class ControllerReference { rpc: RpcHelper; constructor( public prefix: string, public id: string, port: MessagePort ) { this.rpc = new RpcHelper( { sendSetCookie: async ({ cookies, options }) => { const clients = await self.clients.matchAll(); const ids: string[] = []; const promises: Promise[] = []; // Navigation fetches (document/iframe) deliver cookies via the inject // script's embedded cookieJar dump — the destination page doesn't have // inject.ts loaded yet to ack, so awaiting would deadlock. Broadcast // so any already-loaded clients can update their jars, but don't wait. const isNavigation = options?.destination === "document" || options?.destination === "iframe"; for (const client of clients) { const id = makeId(); ids.push(id); client.postMessage({ $controller$setCookie: { cookies, options, id, }, }); if (!isNavigation) { promises.push( new Promise((resolve) => { // Resolve with the id so we know which client replied. cookieResolvers[id] = () => resolve(id); }) ); } } // Wait for the first client to acknowledge the cookie sync. // Using Promise.any (not Promise.all) so that extra SW clients created by // window.open (e.g. test popup windows) don't cause timeouts — only the // main controller client needs to respond. if (promises.length > 0) { let timeoutId: ReturnType | undefined; let responded = false; const timeoutPromise = new Promise((resolve) => { timeoutId = setTimeout(() => { if (!responded) { const pending = ids.filter( (id) => cookieResolvers[id] !== undefined ); console.error( "timed out waiting for set cookie response (deadlock?): " + `cookies=${cookies.length} clients=${clients.length} ` + `pending=${pending.length}/${ids.length} ` + `clientUrls=${clients.map((c) => c.url).join(",")}` ); } resolve(); }, 1000); }); try { await Promise.race([ timeoutPromise, Promise.any(promises) .then(() => { responded = true; }) .catch(() => {}), ]); } finally { // Clear the timeout so it doesn't fire spuriously after the // race has already been won by Promise.any. if (timeoutId !== undefined) clearTimeout(timeoutId); // Clean up any pending resolvers so clients that never // responded don't leak entries in cookieResolvers. for (const id of ids) { delete cookieResolvers[id]; } } } }, }, "tabchannel-" + id, (data, transfer) => { port.postMessage(data, transfer); } ); port.onmessage = (e: MessageEvent) => { this.rpc.recieve(e.data); }; port.onmessageerror = console.error; this.rpc.call("ready", undefined); } } const tabs: ControllerReference[] = []; addEventListener("message", (e) => { if (!e.data) return; if (typeof e.data != "object") return; if (!e.data.$controller$init) return; if (typeof e.data.$controller$init != "object") return; const init = e.data.$controller$init; const existing = tabs.findIndex((t) => t.id === init.id); if (existing !== -1) { tabs.splice(existing, 1); } tabs.push(new ControllerReference(init.prefix, init.id, e.ports[0])); }); export function shouldRoute(event: FetchEvent): boolean { const url = new URL(event.request.url); const tab = tabs.find((tab) => url.pathname.startsWith(tab.prefix)); return tab !== undefined; } export async function route(event: FetchEvent): Promise { try { const url = new URL(event.request.url); const tab = tabs.find((tab) => url.pathname.startsWith(tab.prefix))!; const client = await clients.get(event.clientId); const rawheaders: RawHeaders = [...event.request.headers]; const response = await tab.rpc.call( "request", { rawUrl: event.request.url, rawReferrer: event.request.referrer, destination: event.request.destination, mode: event.request.mode, referrer: event.request.referrer, method: event.request.method, body: event.request.body, cache: event.request.cache, forceCrossOriginIsolated: false, initialHeaders: rawheaders, rawClientUrl: client ? client.url : undefined, clientId: event.clientId || event.resultingClientId, }, event.request.body instanceof ReadableStream || // @ts-expect-error the types for fetchevent are messed up event.request.body instanceof ArrayBuffer ? [event.request.body] : undefined ); return new Response(response.body, { status: response.status, statusText: response.statusText, headers: response.headers, }); } catch (e) { console.error("Service Worker error:", e); return new Response( "Internal Service Worker Error: " + (e as Error).message, { status: 500, } ); } } addEventListener("install", () => { self.skipWaiting(); }); addEventListener("activate", (event: ExtendableEvent) => { event.waitUntil(clients.claim()); }); // the only way to know if a service worker has suddenly died is if this code runs again // notify all clients to send over their messageports again setTimeout(async () => { console.log("service worker activated, notifying clients to revive"); for (const client of await clients.matchAll()) { client.postMessage({ $controller$swrevive: {}, }); } // short delay is apparently needed }, 100);