clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

feat/prf-provision-custody
lofi / package / runtime / sw.js
sw.js 7.3K
1const BUILD_REVISION = "__LOFI_BUILD_REVISION__";2// Package-owned service worker source.3// Cache names carry the registration scope so sibling lofi apps on the same4// origin never see or delete each other's caches. The `lofi-scope-` family5// also deliberately escapes the legacy `lofi-shell-`/`lofi-runtime-` prefixes,6// so a sibling still running a pre-scoped worker cannot prune these caches.7const SCOPE_KEY = new URL(self.registration.scope).pathname8 .replace(/[^a-zA-Z0-9]+/g, "-")9 .replace(/^-+|-+$/g, "") || "root";10const SHELL_CACHE_PREFIX = `lofi-scope-${SCOPE_KEY}-shell-`;11const RUNTIME_CACHE_PREFIX = `lofi-scope-${SCOPE_KEY}-runtime-`;12const SHELL_CACHE_NAME = `${SHELL_CACHE_PREFIX}${BUILD_REVISION}`;13const RUNTIME_CACHE_NAME = `${RUNTIME_CACHE_PREFIX}${BUILD_REVISION}`;14const MAX_RUNTIME_ENTRIES = 64;15const RUNTIME_DESTINATIONS = new Set(["font", "image", "script", "style", "worker"]);1617async function reportFailure(code, message) {18 const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });19 for (const client of windows) {20 client.postMessage({ type: "LOFI_PWA_FAILURE", code, message });21 }22}2324self.addEventListener("install", (event) => {25 event.waitUntil((async () => {26 try {27 const manifestUrl = new URL("./lofi-precache.json", self.registration.scope);28 const response = await fetch(manifestUrl, { cache: "no-store" });29 if (!response.ok) throw new Error(`precache manifest failed: ${response.status}`);30 const paths = await response.json();31 // Every emitted path is part of the generated offline shell. Installation32 // fails as one transaction rather than claiming readiness with a partial33 // shell. `cache: "reload"` bypasses the HTTP cache for every shell asset,34 // matching the manifest's no-store fetch — otherwise a new revision's35 // cache could be populated from stale HTTP-cached responses and serve36 // them cache-first indefinitely.37 const cache = await caches.open(SHELL_CACHE_NAME);38 await cache.addAll(39 paths.map((path) =>40 new Request(new URL(path, self.registration.scope), { cache: "reload" })41 ),42 );43 } catch (error) {44 await reportFailure(45 "precache",46 error instanceof Error ? error.message : "precache failed",47 );48 throw error;49 }50 })());51});5253self.addEventListener("message", (event) => {54 if (event.data?.type === "LOFI_SKIP_WAITING") event.waitUntil(self.skipWaiting());55});5657self.addEventListener("activate", (event) => {58 event.waitUntil((async () => {59 // Prune only this scope's stale revisions. Legacy unscoped60 // `lofi-shell-*`/`lofi-runtime-*` names are left alone: they are61 // indistinguishable from a sibling app's caches, and deleting what we62 // cannot prove is ours is the failure mode this scoping exists to end.63 for (const name of await caches.keys()) {64 const staleShell = name.startsWith(SHELL_CACHE_PREFIX) && name !== SHELL_CACHE_NAME;65 const staleRuntime = name.startsWith(RUNTIME_CACHE_PREFIX) && name !== RUNTIME_CACHE_NAME;66 if (staleShell || staleRuntime) await caches.delete(name);67 }68 // Navigation preload is intentionally disabled: generated routes and their69 // assets are precached, so starting a network request before the cache lookup70 // would spend bandwidth on the normal cache-first path.71 await self.clients.claim();72 })());73});7475// Look up responses only in this worker's own caches. An unscoped76// caches.match() searches every cache on the origin, letting product or77// sibling-app caches shadow lofi resources before ours are even consulted.78async function matchOwnCaches(request, options) {79 const shell = await caches.open(SHELL_CACHE_NAME);80 const cached = await shell.match(request, options);81 if (cached) return cached;82 const runtime = await caches.open(RUNTIME_CACHE_NAME);83 return await runtime.match(request, options);84}8586async function cachedPrerenderedNavigation(request) {87 const requestUrl = new URL(request.url);88 const scope = new URL(self.registration.scope);89 if (requestUrl.origin !== scope.origin || !requestUrl.pathname.startsWith(scope.pathname)) {90 return undefined;91 }92 const route = requestUrl.pathname.slice(scope.pathname.length).replace(/^\/+|\/+$/g, "");93 if (!route) return await matchOwnCaches(scope);94 return await matchOwnCaches(new URL(`${route}/index.html`, scope));95}9697function isRuntimeCacheEligible(request) {98 if (request.method !== "GET" || request.mode === "navigate") return false;99 if (!RUNTIME_DESTINATIONS.has(request.destination)) return false;100 const url = new URL(request.url);101 const scope = new URL(self.registration.scope);102 return url.origin === scope.origin && url.pathname.startsWith(scope.pathname);103}104105function isRuntimeResponseCacheable(response) {106 if (!response.ok || response.status === 206) return false;107 if (response.type !== "basic" && response.type !== "default") return false;108 const cacheControl = response.headers.get("cache-control") ?? "";109 if (/(?:^|,)\s*(?:no-store|private)(?:\s|,|$)/i.test(cacheControl)) return false;110 return response.headers.get("vary")?.trim() !== "*";111}112113async function trimRuntimeCache(cache) {114 const keys = await cache.keys();115 const overflow = keys.length - MAX_RUNTIME_ENTRIES;116 for (const request of keys.slice(0, Math.max(0, overflow))) await cache.delete(request);117}118119async function writeRuntimeCache(request, response) {120 const cache = await caches.open(RUNTIME_CACHE_NAME);121 // Reinsert a refreshed URL so cache key order tracks the latest successful write.122 await cache.delete(request);123 await cache.put(request, response);124 await trimRuntimeCache(cache);125}126127async function routeRequest(request) {128 const cached = await matchOwnCaches(request, {129 ignoreSearch: request.mode === "navigate",130 });131 if (cached) return { response: cached };132 if (request.mode === "navigate") {133 const prerendered = await cachedPrerenderedNavigation(request);134 if (prerendered) return { response: prerendered };135 }136 try {137 const response = await fetch(request);138 const runtimeWrite = isRuntimeCacheEligible(request) && isRuntimeResponseCacheable(response)139 ? writeRuntimeCache(request, response.clone())140 : undefined;141 return { response, runtimeWrite };142 } catch (error) {143 if (request.mode === "navigate") {144 const shell = await matchOwnCaches(new URL("./", self.registration.scope));145 if (shell) return { response: shell };146 }147 throw error;148 }149}150151self.addEventListener("fetch", (event) => {152 if (event.request.method !== "GET") return;153 // Cached responses are complete 200s; answering a Range request with one154 // breaks seeking and Safari media playback. Let the browser go to network.155 if (event.request.headers?.get?.("range")) return;156 const requestUrl = new URL(event.request.url);157 if (requestUrl.origin !== self.location.origin) return;158 const result = routeRequest(event.request);159 event.respondWith(result.then(({ response }) => response));160 // waitUntil is attached synchronously. It follows the eventual cache write161 // without delaying the response or misclassifying ordinary network failures.162 event.waitUntil(result.then(163 ({ runtimeWrite }) =>164 runtimeWrite?.catch((error) =>165 reportFailure(166 "runtime-cache",167 error instanceof Error ? error.message : "runtime cache write failed",168 )169 ),170 () => undefined,171 ));172});