Files
bible/assets/sw.js
T
2026-10-21 15:33:08 -04:00

195 lines
5.4 KiB
JavaScript

importScripts('./js/nunjucks.min.js', './js/router.js', './js/render.js');
const NETWORK_TIMEOUT_MS = 3000;
const DOWNLOAD_TIMEOUT_MS = 30000;
const DOWNLOAD_AT_ONCE = 4;
const VERSION = 'dev';
const LIBRARY_VERSION = 'dev';
const LIBRARY = [];
const SHELL_CACHE = 'bible-shell-' + VERSION;
const LIBRARY_CACHE = 'bible-library-' + LIBRARY_VERSION;
const VERSIONS = { shell: VERSION, library: LIBRARY_VERSION };
const LIBRARY_URLS = new Set(LIBRARY.map((path) => new URL(path, location.href).href));
const PRECACHE = [
'./boot.html',
'./',
'./manifest.webmanifest',
'./css/app.css',
'./fonts/DayLeafInitials.woff2',
'./ornaments/group-l-49.svg',
'./js/htmx.min.js'
].concat(TEMPLATE_URLS);
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);
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();
})());
});
function isFile(url) {
return url.pathname.split('/').pop().indexOf('.') !== -1;
}
self.addEventListener('fetch', (event) => {
const req = event.request;
const url = new URL(req.url);
if (url.origin !== location.origin) return;
keepFilling(event);
if (!isFile(url)) {
const routed = router.handle(event, url);
if (routed) {
return event.respondWith(routed.then((res) => res || answer(req, event)));
}
}
if (req.method !== 'GET') return;
event.respondWith(answer(req, event));
});
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 });
}
async function answer(input, event) {
const req = typeof input === 'string' ? new Request(input) : input;
const hit = await cached(req);
if (hit) return hit;
const res = await fromNetwork(req);
if (res && res.ok && res.type === 'basic') {
const copy = res.clone();
const write = cacheFor(req).then((c) => c.put(req, copy));
if (event) event.waitUntil(write);
else write.catch(() => {});
}
return res;
}
function pageRequest(url) {
return new Request(url, { cache: 'no-cache' });
}
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();
}
async function readTemplate(path) {
const hit = await cached(path);
return hit ? hit.text() : readText(path);
}
const view = createRenderer(readTemplate);
const router = new Router(new URL('./', location.href).pathname);
const OFFLINE_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.'];
const MISSING_NOTICE = ['Not found', 'There is no page at this address.'];
async function fragmentFor(path, event) {
let res;
try {
res = await answer(pageRequest(path), event);
} catch (err) {
return view.notice(...OFFLINE_NOTICE);
}
return res.ok ? res.text() : view.notice(...MISSING_NOTICE);
}
async function servePage(path, code, call) {
let fragment = await fragmentFor(path, call.event);
if (!code) {
fragment += '\n' + await view.render('version.njk', { version: VERSIONS });
}
if (!call.navigate && !call.restore) return fragment;
try {
return await view.compose(fragment);
} catch (err) {
console.error('compose failed: ' + err.message);
return fragment;
}
}
router.get('/', (call) => servePage('./', '', call));
router.get('/:page', (call) =>
servePage('./' + call.params.page, call.params.page, call));
let filling = null;
let complete = false;
function keepFilling(event) {
if (complete) return;
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);
if (!stopped) complete = true;
async function run() {
while (missing.length && !stopped) {
const url = missing.pop();
try {
const res = await fromNetwork(pageRequest(url), DOWNLOAD_TIMEOUT_MS);
if (res.ok && res.type === 'basic') await cache.put(url, res);
} catch (err) {
stopped = true;
}
}
}
}