// The app's renderer, and its backend. Hand-written: this file is the source, // served as-is apart from the two lines server.py stamps (see below). // // The files on disk are fragments: a // and one root element. The shell around them lives in base.njk and // is applied here, per request, with nunjucks -- so changing the chrome means // editing one template rather than rebuilding every book. // // It is also the app's backend. The bookmark lives in IndexedDB here, not in // localStorage on the page, and is read and written over ./api/bookmark by // htmx exactly as if it were a server. // // STRATEGY decides what a same-origin GET does: // // network-first ask the network, fall back to the cache when it fails. // Offline, fetch rejects immediately, so the fallback is // effectively instant -- the only cost is one round trip // while online, and you never see a stale page. // cache-first answer from the cache the moment there is a hit. Fastest // possible load, but a rebuild needs two reloads to appear. // // Either way every response that arrives is written back to the cache, so the // offline copy stays current, and any change on disk changes VERSION, dropping // the caches belonging to older versions. // Imported scripts are stored with the registration, so this survives offline // without being in ASSETS. importScripts('./nunjucks.min.js'); const STRATEGY = 'network-first'; const NETWORK_TIMEOUT_MS = 3000; // VERSION and ASSETS are stamped in by server.py from the files actually on // disk, so a template edit reaches installed devices with no build step: the // worker's bytes change, the browser sees a new worker, and the old cache is // dropped on activate. The values below are the standalone fallback. const VERSION = 'dev'; const CACHE = 'bible-' + VERSION; const ASSETS = ["./"]; self.addEventListener('install', (event) => { event.waitUntil((async () => { const cache = await caches.open(CACHE); // Added one at a time rather than addAll(): addAll is atomic, so a single // failed asset would leave the app with no offline copy at all. let failed = 0; await Promise.all(ASSETS.map((url) => cache.add(new Request(url, { cache: 'reload' })).catch(() => { failed++; }))); if (failed) console.warn('precache: ' + failed + ' of ' + ASSETS.length + ' failed'); await self.skipWaiting(); })()); }); self.addEventListener('activate', (event) => { event.waitUntil((async () => { const keys = await caches.keys(); await Promise.all(keys .filter((k) => k.startsWith('bible-') && k !== CACHE) .map((k) => caches.delete(k))); await self.clients.claim(); })()); }); self.addEventListener('fetch', (event) => { const req = event.request; const url = new URL(req.url); if (url.origin !== location.origin) return; // Checked before the method guard: the bookmark API is the one thing here // that answers POST and DELETE. if (isBookmarkApi(url)) return event.respondWith(bookmarkApi(event)); if (req.method !== 'GET') return; // A top-level navigation is the only request that gets the shell wrapped // around it. htmx's own requests are not navigations, so they receive the // bare fragment and swap it into the shell that is already on screen. if (req.mode === 'navigate') return event.respondWith(compose(event)); event.respondWith(answer(req, event)); }); // ---------------------------------------------------------------- fetching // Aborted rather than raced, so a hung request is actually torn down instead // of leaking while the cache answers. async function fromNetwork(req) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), NETWORK_TIMEOUT_MS); try { return await fetch(req, { signal: controller.signal }); } finally { clearTimeout(timer); } } function cached(req) { return caches.match(req, { ignoreSearch: true }); } // Everything the worker reads goes through here, templates included, so a // .njk file is cached and served on exactly the same terms as a book. // // A string argument becomes a fresh Request, which matters beyond // convenience: it is not a navigation, so the origin server can tell an // internal fetch from a browser one and hand back the fragment rather than // the bootstrap page. async function answer(input, event) { const req = typeof input === 'string' ? new Request(input) : input; if (STRATEGY === 'cache-first') { const hit = await cached(req); if (hit) return hit; } try { const res = await fromNetwork(req); if (res && res.ok && res.type === 'basic') { const copy = res.clone(); // Not awaited: the response should not wait on the cache write. const write = caches.open(CACHE).then((c) => c.put(req, copy)); if (event) event.waitUntil(write); else write.catch(() => {}); } return res; } catch (err) { const hit = await cached(req); if (hit) return hit; throw err; } } async function readText(input, event) { const res = await answer(input, event); if (!res || !res.ok) throw new Error('cannot load ' + (input.url || input)); return res.text(); } function htmlResponse(body, status) { return new Response(body, { status: status || 200, headers: { 'Content-Type': 'text/html; charset=utf-8', // Composed on the fly from parts that are themselves cached; storing // the result too would pin a copy of the old shell. 'Cache-Control': 'no-store' } }); } // --------------------------------------------------------------- templates // Nunjucks ships a WebLoader built on XMLHttpRequest, which does not exist in // a service worker. This one reads through answer(), so templates are offline // on the same terms as everything else. function CacheLoader() {} CacheLoader.prototype.async = true; CacheLoader.prototype.getSource = function (name, callback) { readText('./' + name).then( (src) => callback(null, { src: src, path: name, noCache: false }), (err) => callback(err)); }; // Compiled templates are held in memory for the worker's lifetime. A rebuild // changes VERSION, which starts a new worker, so this never goes stale. const env = new nunjucks.Environment(new CacheLoader(), { autoescape: true }); function render(name, context) { return new Promise((resolve, reject) => { env.render(name, context, (err, out) => (err ? reject(err) : resolve(out))); }); } // ---------------------------------------------------------------- bookmark const DB_NAME = 'bible'; const DB_STORE = 'kv'; const BOOKMARK_KEY = 'bookmark'; function openDb() { return new Promise((resolve, reject) => { const req = indexedDB.open(DB_NAME, 1); req.onupgradeneeded = () => req.result.createObjectStore(DB_STORE); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } // Resolved on the transaction rather than the request, so the value is only // reported once the write it belongs to has actually committed. async function withStore(mode, fn) { const db = await openDb(); try { return await new Promise((resolve, reject) => { const tx = db.transaction(DB_STORE, mode); const req = fn(tx.objectStore(DB_STORE)); tx.oncomplete = () => resolve(req ? req.result : undefined); tx.onabort = tx.onerror = () => reject(tx.error); }); } finally { db.close(); } } // Storage being unavailable degrades to "no bookmark" rather than a broken // page, the same way the old localStorage code did. function getBookmark() { return withStore('readonly', (s) => s.get(BOOKMARK_KEY)).catch(() => null); } function isBookmarkApi(url) { return url.pathname === '/api/bookmark' || url.pathname.endsWith('/api/bookmark'); } async function bookmarkFragment(status) { const mark = await getBookmark(); let html = await render('bookmark.njk', { mark: mark || null }); if (status) { // Out of band, so one response updates both the navbar slot and the // modal's status line without the modal having to ask separately. Same // template the shell uses, so the two cannot drift apart. html += '\n' + await render('status.njk', { text: status, oob: true }); } return html; } async function bookmarkApi(event) { const req = event.request; let status = ''; try { if (req.method === 'POST') { const form = await req.formData(); const ref = String(form.get('ref') || '').trim(); const href = String(form.get('href') || '').trim(); if (!ref || !href) return htmlResponse('', 400); await withStore('readwrite', (s) => s.put({ ref: ref, href: href, savedAt: Date.now() }, BOOKMARK_KEY)); status = 'Bookmark set.'; } else if (req.method === 'DELETE') { await withStore('readwrite', (s) => s.delete(BOOKMARK_KEY)); status = 'Bookmark cleared.'; } else if (req.method !== 'GET') { return htmlResponse('', 405); } return htmlResponse(await bookmarkFragment(status)); } catch (err) { return htmlResponse(await bookmarkFragment('Could not save: storage unavailable.')); } } // ------------------------------------------------------------- composition const TITLE_RE = /<title>([\s\S]*?)<\/title>/i; const TEMPLATE_RE = /\sdata-template="([\w-]+)"/; function fragmentPath(url) { const name = url.pathname.split('/').pop(); return './' + (name || 'index.html'); } async function compose(event) { const url = new URL(event.request.url); let path = fragmentPath(url); // The bootstrap page is a whole document already; wrapping it in the shell // would nest one inside the other. if (path === './boot.html') return answer(event.request, event); let fragment; try { fragment = await readText(path, event); } catch (err) { // Offline and never cached. The index is always precached, so falling // back to it keeps the app usable instead of showing a network error. if (path === './index.html') throw err; path = './index.html'; fragment = await readText(path, event); } // Already escaped by the renderer, hence the |safe on it in base.njk. const title = (TITLE_RE.exec(fragment) || ['', ''])[1].trim(); const body = fragment.replace(TITLE_RE, ''); const template = (TEMPLATE_RE.exec(body) || ['', 'page'])[1] + '.njk'; return htmlResponse(await render(template, { title: title, content: body, mark: (await getBookmark()) || null })); }