const IMG_PATHS = []; const CACHE_NAME = "static-shared-assets"; const leafValues = tree => Object.values(tree).map(x => typeof x !== "object" ? x : leafValues(x) ); const collectLeaves = tree => leafValues(tree).flat(Infinity); const store = async (caches, request, response) => { const cache = await caches.open(CACHE_NAME); await cache.put(request, response); }; const matchRoute = (segments, table) => { if (segments.length === 0) { return table ?? null; } const simpleMatch = matchRoute(segments.slice(1), table?.[segments[0]]); if (simpleMatch !== null) { return simpleMatch; } const hasGlob = table?.["*"] !== undefined; if (!hasGlob) { return null; } const keys = Object.keys(table); const isTrailing = keys.length === 1 || ( keys.length === 2 && table[""] !== undefined ); if (isTrailing) { return table["*"][""]; } return matchRoute(segments.slice(1), table["*"]); }; const fromCache = async ({ caches }, { event: { request } }) => { const cache = await caches.open(CACHE_NAME); return await cache.match(request); } const fromNetwork = async ( _env, { fetch, event: { request, preloadResponse } }, ) => { const preloadedResponse = await preloadResponse; if (preloadResponse) { return preloadResponse; } return await fetch(request); }; const networkThanCache = async (env, ctx) => { const networkResponse = fromNetwork(env, ctx); store(env.caches, ctx.event.request, networkResponse.clone()); return networkResponse; }; const immutableGet = async (env, ctx) => { const cachedResponse = await fromCache(env, ctx); if (cachedResponse) { return cachedResponse; } const response = fromNetwork(env, ctx); store(env.caches, ctx.event.request, response); return response; } const staleOK = async (env, ctx) => { return Promise.any([ fromCache(env, ctx), networkThanCache(env, ctx), ]); }; const staleOrFallback = async (env, ctx) => { try { return await staleOK(env, ctx); } catch (e) { const fallbackRoute = ctx.strategy.match; const fallbackResponse = await env.caches.match(fallbackRoute); if (fallbackResponse) { return fallbackResponse; } throw e; } }; const STRATEGIES = [ { fn: immutableGet, routes: new Set([ "cas/*", ]), }, { fn: staleOK, routes: new Set([ "", // FIXME: should be equivalent to "./", not "/" "index.html", "style.css", "papo.js", ...IMG_PATHS, ]), }, { fn: staleOrFallback, routes: new Set([ ["img/profile/*", "img/profile/fallback.svg"], ]), }, { fn: fromNetwork, routes: new Set([ "*" ]), }, ]; const strategyFor = (segments, baseSegments, strategies = STRATEGIES) => { const effectiveSegments = segments.slice(baseSegments.length); for (const strategy of strategies) { const match = matchRoute(effectiveSegments, strategy.routes); if (match) { return { ...strategy, match }; } } }; const normalizeSegments = segments => segments.concat( segments.length === 1 && segments[0] === "" ? [] : [""] ); const pathToSegments = path => normalizeSegments(path .replace(/^\/*/, "") .replace( /\/*$/, "") .replace(/\/+/, "/") .split("/")); const hasPathParams = segments => segments.some(s => s.startsWith(":")); const flatten = arr => arr.flat(Infinity); /// We don't use `cache.addAll()` since we can tolerate failure to add individual /// items, and we'd rather have a partially filled cache over an empty one. const populateCache = async ({ caches }, paths = DEFAULT_INSTALL_PATHS) => { const cache = await caches.open(CACHE_NAME); return Promise.all(paths.map(cache.add)); }; /// We don't `event.waitUntil()`, so that we can be activated sooner. const mkInstallHandler = env => async event => { self.skipWaiting(); populateCache(env); }; const mkActivateHandler = ({ self, clients }) => event => event.waitUntil(Promise.all([ self.registration.navigationPreload?.enable(), clients.claim(), ])); // FIXME: noop if "Connection": "Upgrade" is in header // if (event.request.headers.get("Connection").toLowerCase() == "upgrade"); // out([...event.request.headers.entries()]); const mkFetchHandler = env => async (event, mkfetch = () => fetch) => { if (event.request.method !== "GET") { return; } const segments = pathToSegments(new URL(event.request.url).pathname); const strategy = strategyFor(segments, env.baseSegments); event.respondWith(strategy.fn(env, { event, segments, strategy, fetch: mkfetch(), })); }; const registerListeners = env => { env.self.addEventListener("install", mkInstallHandler(env)); env.self.addEventListener("activate", mkActivateHandler(env)); env.self.addEventListener("fetch", mkFetchHandler(env)); }; const main = ({ self, caches, clients, out = console.log, err = console.warn, } = {}) => registerListeners({ self, caches, clients, baseSegments: asSegments(self.location.pathname), out, err, }); // handy resources for pushes: // https://web.dev/articles/offline-cookbook // https://web.dev/articles/push-notifications-overview // https://developer.chrome.com/blog/background-sync // https://learn.microsoft.com/en-us/microsoft-edge/progressive-web-apps-chromium/how-to/push // https://learn.microsoft.com/en-us/microsoft-edge/progressive-web-apps-chromium/how-to/background-syncs // https://web.dev/learn/forms // https://alistapart.com/article/responsive-web-design/ // https://developer.chrome.com/docs/workbox/modules/workbox-background-sync // https://www.npmjs.com/package/idb // https://web.dev/articles/indexeddb-best-practices-app-state // https://web.dev/learn/pwa/app-design // https://web.dev/articles/accent-color // https://jakearchibald.com/2016/caching-best-practices/#max-age-on-mutable-content-is-often-the-wrong-choice