// The app's offline copy, and its backend. Hand-written: this file is the // source, served as-is apart from the three lines server.py stamps (see below). // // The files on disk are fragments: a title element and one root element. The shell // around it is shell.html, a static document with an empty #content. This // worker does not assemble anything -- it answers every navigation with that // one file, and app.js asks for the fragment the URL names. So there is a // single way a page is built, online or off, first visit or hundredth. // // 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. // // Every same-origin GET is 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. Every response that arrives is written back to the // cache, so a book that has been read once stays readable offline. // // The whole corpus is kept offline, but not by making anyone wait for it and // not by telling them about it. Install caches the shell alone -- a handful of // small files -- and the app is usable the moment that is done. The books are // then fetched a few at a time in the background while the reader reads, with // nothing on screen to say so. Close the tab half way through and the next // visit picks up where it left off: the cache is the record of what has been // fetched, so there is no separate bookkeeping to lose. const NETWORK_TIMEOUT_MS = 3000; // The background download gets a longer leash than a page the reader is // waiting on: nothing is blocked on it, and giving up early on a slow link // only means fetching the same book again later. const DOWNLOAD_TIMEOUT_MS = 30000; // Few enough to leave the connection room for the page being read, more than // one so a slow response does not stall the queue. The old code asked for // every file at once, which is what made a weak connection collapse. const DOWNLOAD_AT_ONCE = 4; // All three are stamped in by server.py from the files actually on disk, so an // edit reaches installed devices with no build step: the worker's bytes // change, the browser sees a new worker, and stale caches go on activate. The // values below are the standalone fallback. // // The two versions are deliberately separate, over the two directories the // server puts together. VERSION covers the hand-written chrome in assets/, so // editing the shell drops and refetches a few hundred kilobytes. The other // covers the generated pages in books/, which change only when // render_books.py runs -- so a change of chrome no longer costs every device // the whole corpus again. const VERSION = 'dev'; const LIBRARY_VERSION = 'dev'; const LIBRARY = []; const SHELL_CACHE = 'bible-shell-' + VERSION; const LIBRARY_CACHE = 'bible-library-' + LIBRARY_VERSION; // Resolved once, and the set is the test for which cache a response belongs // in. Membership, not a path pattern: the split is whichever directory the // file came from, which is a fact only the server has. const LIBRARY_URLS = new Set(LIBRARY.map((path) => new URL(path, location.href).href)); // The shell and what it needs to paint. index.html is in here as well as in // the library -- it is the one page the app falls back to, so it should not // have to wait for the download to reach it. const SHELL = './shell.html'; const PRECACHE = [ SHELL, './index.html', './app.js', './app.css', './htmx.min.js', './bootstrap.min.css', './bootstrap.bundle.min.js', './manifest.webmanifest' ]; function cacheFor(req) { return caches.open(LIBRARY_URLS.has(req.url) ? LIBRARY_CACHE : SHELL_CACHE); } self.addEventListener('install', (event) => { event.waitUntil((async () => { const cache = await caches.open(SHELL_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(PRECACHE.map((url) => cache.add(new Request(url, { cache: 'reload' })).catch(() => { failed++; }))); if (failed) console.warn('precache: ' + failed + ' of ' + PRECACHE.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 !== SHELL_CACHE && k !== LIBRARY_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; // The background download rides along on whatever the page is already // asking for. It gets no event of its own: activate cannot be used, because // waitUntil there holds back activation and every fetch with it, and a // worker is only kept alive while an event of its own is pending. So each // request the reader makes is what carries the download forward. keepFilling(event); // Checked before the method guard: the bookmark API is the one thing here // that answers POST. if (isBookmarkApi(url)) return event.respondWith(bookmarkApi(event)); if (req.method !== 'GET') return; // Every navigation gets the shell, whatever path it asked for. One URL to // cache, and it answers for a book that has never been opened as readily as // for one that has -- what is missing is then the fragment, which is a // message inside the app rather than a browser error page. if (req.mode === 'navigate') return event.respondWith(answer(SHELL, event)); if (isPage(url)) return event.respondWith(page(req, 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, timeout) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout || NETWORK_TIMEOUT_MS); try { return await fetch(req, { signal: controller.signal }); } finally { clearTimeout(timer); } } function cached(req) { return caches.match(req, { ignoreSearch: true }); } // 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 file itself rather than // the shell. async function answer(input, event) { const req = typeof input === 'string' ? new Request(input) : input; 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 = cacheFor(req).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; } } function htmlResponse(body, status) { return new Response(body, { status: status || 200, headers: { 'Content-Type': 'text/html; charset=utf-8', // Built here from live state rather than fetched; storing it would pin // a stale copy over the real thing. 'Cache-Control': 'no-store' } }); } // ------------------------------------------------------------------- pages function isPage(url) { return url.pathname.endsWith('.html'); } // A page that cannot be delivered becomes a fragment saying so, in the same // shape as a real one: a title and one root element. htmx only swaps a 2xx // body, so this is deliberately a 200 -- an error status would leave the // reader looking at the page they came from with no explanation. function notice(heading, text) { return htmlResponse( '' + heading + '\n' + '
\n' + '

' + heading + '

\n' + '

' + text + '

\n' + '

All books

\n' + '
'); } async function page(req, event) { let res; try { res = await answer(req, event); } catch (err) { return notice('Not available offline', 'This page has not been opened on this device, so there is no copy ' + 'stored here. Reconnect and try again, or open one you have read before.'); } if (res.ok) return res; return notice('Not found', 'There is no page at this address.'); } // ----------------------------------------------------------------- library // The background download of the whole corpus. // // There is no progress to keep: the cache is the record. Each run asks it what // is already there, which is what makes this survive the worker being shut // down mid-run, resume on the next visit, and stay honest when a page arrives // some other way -- reading a book puts it in the same cache. let filling = null; // the run in flight, so two do not start at once let complete = false; // a run got through the whole list; stop looking function keepFilling(event) { if (complete) return; // navigator.onLine is only ever trustworthy when it says false, which is the // direction that matters: it stops a doomed run being started, and retried, // on every request while the device is plainly offline. if (!filling && self.navigator.onLine !== false) { filling = fillLibrary().catch(() => {}).finally(() => { filling = null; }); } if (filling) event.waitUntil(filling); } async function fillLibrary() { const cache = await caches.open(LIBRARY_CACHE); const have = new Set((await cache.keys()).map((req) => req.url)); const missing = []; LIBRARY_URLS.forEach((url) => { if (!have.has(url)) missing.push(url); }); if (!missing.length) { complete = true; return; } let stopped = false; const runners = []; for (let i = 0; i < DOWNLOAD_AT_ONCE && i < missing.length; i++) runners.push(run()); await Promise.all(runners); // Only after a run that was never cut short: anything else leaves pages // outstanding, and the next request should look again. if (!stopped) complete = true; async function run() { while (missing.length && !stopped) { const url = missing.pop(); try { const res = await fromNetwork(new Request(url), DOWNLOAD_TIMEOUT_MS); // A response that arrived but is not usable -- a page that has since // been removed, say -- is skipped rather than retried. The connection // is plainly fine, and stopping for it would strand everything after. if (res.ok && res.type === 'basic') await cache.put(url, res); } catch (err) { // Stop the whole run rather than grinding through a hundred certain // failures. The next request starts a fresh one, so reconnecting is // all it takes to carry on. stopped = true; } } } } // ---------------------------------------------------------------- bookmark const DB_NAME = 'bible'; const DB_STORE = 'kv'; const BOOKMARK_KEY = 'bookmark'; // There is no way to clear a bookmark, so there is no empty state: a reader // who has never set one is treated as being at the beginning. Everything // downstream -- the navbar slot and the API -- can assume a mark. const DEFAULT_BOOKMARK = { ref: 'Genesis 1:1', href: 'GEN.html#v1_1' }; 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 the default rather than a broken page, // which is the same fallback as never having set one. function getBookmark() { return withStore('readonly', (s) => s.get(BOOKMARK_KEY)) .catch(() => null) .then((mark) => mark || DEFAULT_BOOKMARK); } function isBookmarkApi(url) { return url.pathname === '/api/bookmark' || url.pathname.endsWith('/api/bookmark'); } function esc(text) { return String(text).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); } // Replaces the empty slot in shell.html, and then replaces itself on every // later read or write. The swap is outerHTML, so this markup has to re-declare // its own htmx attributes: the replacement is what receives the next event. // hx-target is inherited, and the shell points it at #content, so "this" is // not the default here -- it has to be said. function bookmarkHtml(mark) { return '' + // Not boosted: a full load lands on the shell with the fragment // identifier intact, which is what app.js scrolls to. '' + esc(mark.ref) + ''; } // Out of band, so one response updates both the navbar slot and the modal's // status line without the modal having to ask separately. function statusHtml(text) { return '

' + esc(text) + '

'; } async function bookmarkFragment(status) { let html = bookmarkHtml(await getBookmark()); if (status) html += '\n' + statusHtml(status); 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 !== 'GET') { return htmlResponse('', 405); } return htmlResponse(await bookmarkFragment(status)); } catch (err) { return htmlResponse(await bookmarkFragment('Could not save: storage unavailable.')); } }