381 lines
15 KiB
JavaScript
381 lines
15 KiB
JavaScript
// 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(
|
|
'<title>' + heading + '</title>\n' +
|
|
'<div class="notice">\n' +
|
|
' <h1>' + heading + '</h1>\n' +
|
|
' <p>' + text + '</p>\n' +
|
|
' <p><a href="index.html">All books</a></p>\n' +
|
|
'</div>');
|
|
}
|
|
|
|
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 '<span class="navbar-text d-flex align-items-center gap-2"' +
|
|
' id="bookmark" hx-get="api/bookmark"' +
|
|
' hx-trigger="worker-ready from:body"' +
|
|
' hx-target="this" hx-swap="outerHTML">' +
|
|
// Not boosted: a full load lands on the shell with the fragment
|
|
// identifier intact, which is what app.js scrolls to.
|
|
'<a class="link-light text-truncate"' +
|
|
' href="' + esc(mark.href) + '" hx-boost="false">' +
|
|
esc(mark.ref) + '</a></span>';
|
|
}
|
|
|
|
// 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 '<p class="verse-dialog-status form-text m-0" id="verse-dialog-status"' +
|
|
' role="status" aria-live="polite" hx-swap-oob="true">' +
|
|
esc(text) + '</p>';
|
|
}
|
|
|
|
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.'));
|
|
}
|
|
}
|