292 lines
10 KiB
JavaScript
292 lines
10 KiB
JavaScript
// 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
|
|
// <title> 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
|
|
}));
|
|
}
|