clone

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

feat/fix-sync-enrollment-startup
lofi / package / runtime / sw.js
sw.js 7.7K
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. Mutable paths (HTML, manifests) use `cache: "reload"`, matching34 // the manifest's no-store fetch — otherwise a new revision's cache could35 // be populated from stale HTTP-cached responses and serve them36 // cache-first indefinitely. Build assets under `_astro/` carry a content37 // hash in their name, so a stale response is impossible; admitting the38 // HTTP cache for them lets a first visit reuse the bytes the page's own39 // module and engine fetches just downloaded instead of downloading the40 // shell a second time.41 const cache = await caches.open(SHELL_CACHE_NAME);42 await cache.addAll(43 paths.map((path) => {44 const url = new URL(path, self.registration.scope);45 const contentHashed = /(?:^|\/)_astro\//.test(url.pathname);46 return new Request(url, contentHashed ? {} : { cache: "reload" });47 }),48 );49 } catch (error) {50 await reportFailure(51 "precache",52 error instanceof Error ? error.message : "precache failed",53 );54 throw error;55 }56 })());57});5859self.addEventListener("message", (event) => {60 if (event.data?.type === "LOFI_SKIP_WAITING") event.waitUntil(self.skipWaiting());61});6263self.addEventListener("activate", (event) => {64 event.waitUntil((async () => {65 // Prune only this scope's stale revisions. Legacy unscoped66 // `lofi-shell-*`/`lofi-runtime-*` names are left alone: they are67 // indistinguishable from a sibling app's caches, and deleting what we68 // cannot prove is ours is the failure mode this scoping exists to end.69 for (const name of await caches.keys()) {70 const staleShell = name.startsWith(SHELL_CACHE_PREFIX) && name !== SHELL_CACHE_NAME;71 const staleRuntime = name.startsWith(RUNTIME_CACHE_PREFIX) && name !== RUNTIME_CACHE_NAME;72 if (staleShell || staleRuntime) await caches.delete(name);73 }74 // Navigation preload is intentionally disabled: generated routes and their75 // assets are precached, so starting a network request before the cache lookup76 // would spend bandwidth on the normal cache-first path.77 await self.clients.claim();78 })());79});8081// Look up responses only in this worker's own caches. An unscoped82// caches.match() searches every cache on the origin, letting product or83// sibling-app caches shadow lofi resources before ours are even consulted.84async function matchOwnCaches(request, options) {85 const shell = await caches.open(SHELL_CACHE_NAME);86 const cached = await shell.match(request, options);87 if (cached) return cached;88 const runtime = await caches.open(RUNTIME_CACHE_NAME);89 return await runtime.match(request, options);90}9192async function cachedPrerenderedNavigation(request) {93 const requestUrl = new URL(request.url);94 const scope = new URL(self.registration.scope);95 if (requestUrl.origin !== scope.origin || !requestUrl.pathname.startsWith(scope.pathname)) {96 return undefined;97 }98 const route = requestUrl.pathname.slice(scope.pathname.length).replace(/^\/+|\/+$/g, "");99 if (!route) return await matchOwnCaches(scope);100 return await matchOwnCaches(new URL(`${route}/index.html`, scope));101}102103function isRuntimeCacheEligible(request) {104 if (request.method !== "GET" || request.mode === "navigate") return false;105 if (!RUNTIME_DESTINATIONS.has(request.destination)) return false;106 const url = new URL(request.url);107 const scope = new URL(self.registration.scope);108 return url.origin === scope.origin && url.pathname.startsWith(scope.pathname);109}110111function isRuntimeResponseCacheable(response) {112 if (!response.ok || response.status === 206) return false;113 if (response.type !== "basic" && response.type !== "default") return false;114 const cacheControl = response.headers.get("cache-control") ?? "";115 if (/(?:^|,)\s*(?:no-store|private)(?:\s|,|$)/i.test(cacheControl)) return false;116 return response.headers.get("vary")?.trim() !== "*";117}118119async function trimRuntimeCache(cache) {120 const keys = await cache.keys();121 const overflow = keys.length - MAX_RUNTIME_ENTRIES;122 for (const request of keys.slice(0, Math.max(0, overflow))) await cache.delete(request);123}124125async function writeRuntimeCache(request, response) {126 const cache = await caches.open(RUNTIME_CACHE_NAME);127 // Reinsert a refreshed URL so cache key order tracks the latest successful write.128 await cache.delete(request);129 await cache.put(request, response);130 await trimRuntimeCache(cache);131}132133async function routeRequest(request) {134 const cached = await matchOwnCaches(request, {135 ignoreSearch: request.mode === "navigate",136 });137 if (cached) return { response: cached };138 if (request.mode === "navigate") {139 const prerendered = await cachedPrerenderedNavigation(request);140 if (prerendered) return { response: prerendered };141 }142 try {143 const response = await fetch(request);144 const runtimeWrite = isRuntimeCacheEligible(request) && isRuntimeResponseCacheable(response)145 ? writeRuntimeCache(request, response.clone())146 : undefined;147 return { response, runtimeWrite };148 } catch (error) {149 if (request.mode === "navigate") {150 const shell = await matchOwnCaches(new URL("./", self.registration.scope));151 if (shell) return { response: shell };152 }153 throw error;154 }155}156157self.addEventListener("fetch", (event) => {158 if (event.request.method !== "GET") return;159 // Cached responses are complete 200s; answering a Range request with one160 // breaks seeking and Safari media playback. Let the browser go to network.161 if (event.request.headers?.get?.("range")) return;162 const requestUrl = new URL(event.request.url);163 if (requestUrl.origin !== self.location.origin) return;164 const result = routeRequest(event.request);165 event.respondWith(result.then(({ response }) => response));166 // waitUntil is attached synchronously. It follows the eventual cache write167 // without delaying the response or misclassifying ordinary network failures.168 event.waitUntil(result.then(169 ({ runtimeWrite }) =>170 runtimeWrite?.catch((error) =>171 reportFailure(172 "runtime-cache",173 error instanceof Error ? error.message : "runtime cache write failed",174 )175 ),176 () => undefined,177 ));178});