/* Service worker. Served from /sw.js rather than /static/js/sw.js: a worker's scope is the directory it is served from, so one under /static/ could never control the pages it is meant to serve. See api/pages.py. What this is for is installability and an honest offline page -- NOT offline chat. LLeMbas renders every page on the server, so a cached conversation would be a snapshot that silently went stale, and a cached one belonging to whoever was signed in last. The shell is cached; nothing with a user in it is. The cache is versioned from the query string the registration adds (/sw.js?v=), so a release invalidates it with no separate step. */ "use strict"; var VERSION = new URL(self.location).searchParams.get("v") || "dev"; var CACHE = "lembas-" + VERSION; /* The shell: everything needed to draw a page, plus the page shown when there is no network. Deliberately no HTML but /offline -- see above. */ var SHELL = [ "/offline", "/static/css/tokens.css", "/static/css/app.css", "/static/css/chat.css", "/static/css/admin.css", "/static/js/app.js", "/static/js/ui.js", "/static/js/audio.js", "/static/vendor/htmx.min.js", "/static/vendor/htmx-ext-sse.js", "/static/vendor/alpine.min.js", "/static/img/favicon.svg", "/static/img/logo-mark.svg", "/static/img/icon-192.png", "/static/img/icon-512.png", ]; self.addEventListener("install", function (event) { event.waitUntil( caches.open(CACHE).then(function (cache) { // addAll is all-or-nothing: one 404 would leave the worker uninstalled // and the whole feature silently off, so each entry is added on its own. return Promise.all( SHELL.map(function (path) { return cache.add(new Request(path, { cache: "reload" })).catch(function () {}); }) ); }).then(function () { return self.skipWaiting(); }) ); }); self.addEventListener("activate", function (event) { event.waitUntil( caches.keys().then(function (names) { return Promise.all( names.map(function (name) { if (name !== CACHE && name.indexOf("lembas-") === 0) return caches.delete(name); return null; }) ); }).then(function () { return self.clients.claim(); }) ); }); /* Paths this worker must never touch. /api/ carries the reply stream, the unread poll, uploads and attachment downloads; /auth/ and /admin/ carry credentials and settings. A cached response on any of them is at best stale and at worst somebody else's. */ function isExcluded(url) { return url.pathname.indexOf("/api/") === 0 || url.pathname.indexOf("/auth/") === 0 || url.pathname.indexOf("/admin/") === 0 || url.pathname === "/sw.js"; } self.addEventListener("fetch", function (event) { var request = event.request; if (request.method !== "GET") return; var url = new URL(request.url); if (url.origin !== self.location.origin) return; if (isExcluded(url)) return; /* A reply arrives as an endless event stream. Passing one through a worker is the reliable way to turn a streaming answer into a single delivery at the end, or into nothing at all -- so it is left entirely alone. */ if ((request.headers.get("accept") || "").indexOf("text/event-stream") !== -1) return; if (request.mode === "navigate") { event.respondWith( fetch(request).catch(function () { return caches.match("/offline"); }) ); return; } // Static assets: serve from cache, refresh in the background. They are // versioned by the cache name, so a stale one only lasts until the next // release. event.respondWith( caches.match(request).then(function (hit) { var live = fetch(request).then(function (response) { if (response && response.ok) { var copy = response.clone(); caches.open(CACHE).then(function (cache) { cache.put(request, copy); }); } return response; }).catch(function () { return hit; }); return hit || live; }) ); });