Files
ipx/web/sw/sw.ts
rays 373f862588 A page older than its script is loaded again, not run with it (#449)
Safari on a Mac threw at load on 2026-11-10: the script wired #sleep,
which the page it ran in did not have. #sleep and its script came in
together on 11-08, so the page was older than the script. The service
worker answers a navigation it cannot fetch with the page it kept last,
here one from before 11-08, and that page asked for its script by the old
hash; by then the network answered, and Caddy, which ignores ?v=, sent
today's app.js. The worker even kept it under the old name.

Every ?v= is the first 12 hex digits of the file's SHA-256, so the worker
now checks what it fetched against the name. On a mismatch it does not
keep or give the file, and loads the page again from the network, at most
once in 30 seconds so a page still unreachable is not reloaded round and
round.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 03:46:49 +00:00

260 lines
13 KiB
TypeScript

// iPX offline (#389): the page and what it last read from the API, kept so iPX opens and reads
// with no connection; pictures in items' text, through ipx's /api/art, to see them too; and the
// reads, flags and places in an episode made offline, kept and sent when the connection is back.
//
// Online, everything goes to the network first and only its answers are kept, so nothing seen
// online is ever an old copy, and a deploy's new page arrives as it would without this (#388).
// Offline, the kept copy answers. Cleared on sign-out and on a change of account (the page).
//
// Not part of app.js: a service worker is a script of its own, at the top of the site so it sees
// every request, and typed against the worker's globals, not the page's (tsconfig.sw.json). Its
// scope is the origin's; it never sees another site's.
// The worker's own global: the WebWorker library declares `self` for workers of every kind.
const sw = self as unknown as ServiceWorkerGlobalScope;
/// A write kept for later, as the outbox holds it.
type Saved = {url: string, body: string, type: string};
const PAGE = 'ipx-page', API = 'ipx-api', PICS = 'ipx-pics', OUTBOX = 'ipx-outbox', KEPT = 'ipx-episodes';
// shortcut: a byte budget over pictures counted when kept, oldest out first; nothing tracks
// which were looked at since. Raise it, or prune by use, if 200 MB proves too little or too much.
const PICS_BUDGET = 200 << 20;
// The writes made offline that are worth keeping for later: an item read or flagged, and a place
// in an episode. Anything else (adding a feed, settings) fails offline as it always has.
const QUEUED = /^\/api\/entries\/[^/]+\/[^/]+\/(flags|position)$/;
sw.addEventListener('install', () => { void sw.skipWaiting(); });
sw.addEventListener('activate', e => e.waitUntil(sw.clients.claim()));
sw.addEventListener('fetch', e => {
const req = e.request, url = new URL(req.url);
if (url.origin !== location.origin) {
// A picture in an item's text, asked for from its publisher: the publisher while online, the
// copy ipx kept for it while not.
if (req.method === 'GET' && req.destination === 'image') e.respondWith(picture(req));
return;
}
// An episode kept on this device (#412), which the page asks for as such.
if (req.method === 'GET' && url.pathname.startsWith('/media/') && url.searchParams.has('offline')) {
e.respondWith(keptEpisode(req, url.pathname));
return;
}
// The live stream, audio and video pass by: a stream cannot be kept, a file is too big to
// unless someone keeps it, above.
if (url.pathname === '/api/events' || url.pathname.startsWith('/media/') || url.pathname === '/sw.js') return;
if (req.method !== 'GET') {
if (req.method === 'POST' && QUEUED.test(url.pathname)) e.respondWith(write(req));
return;
}
if (req.mode === 'navigate') e.respondWith(page(req));
// Named by a hash of their contents: what is kept is what the network would send.
else if (url.searchParams.has('v') && !url.pathname.startsWith('/api/')) e.respondWith(hashed(req, e.clientId));
else e.respondWith(networkFirst(req, url.pathname.startsWith('/api/') ? API : PAGE));
});
sw.addEventListener('message', e => {
const m = e.data as {flush?: boolean, pictures?: string[]} | null;
if (m?.flush) e.waitUntil(flush());
if (Array.isArray(m?.pictures)) e.waitUntil(keepPictures(m.pictures));
});
/// The page itself, under one name whatever its query: a redirect to sign in, or to Cloudflare
/// Access, is not kept, and the browser follows it as it would.
async function page(req: Request): Promise<Response> {
try {
const r = await fetch(req);
if (r.ok && r.type === 'basic' && new URL(req.url).pathname === '/') (await caches.open(PAGE)).put('/', r.clone());
return r;
} catch {
return (await caches.match('/', { cacheName: PAGE })) || Response.error();
}
}
async function hashed(req: Request, client: string): Promise<Response> {
const kept = await caches.match(req, { cacheName: PAGE });
if (kept) return kept;
const r = await fetch(req);
if (r.ok) {
const url = new URL(req.url);
// The server sends what it has now, whatever version the name asks for. A page kept from
// before a deploy, shown on waking before the network was back, got the new script under its
// old name, died at the first element it lacked (#449), and the new script was kept under the
// old name. Such a page is stale: it is loaded again, from the network, and not given the file.
if (await sha12(await r.clone().arrayBuffer()) !== url.searchParams.get('v')) {
void reloadStale(client);
return Response.error();
}
const c = await caches.open(PAGE), path = url.pathname;
// The version before this one is no use once this one is here.
for (const k of await c.keys()) if (new URL(k.url).pathname === path) await c.delete(k);
await c.put(req, r.clone());
}
return r;
}
/// What web/build.mts names a file by: the first 12 hex digits of its SHA-256.
async function sha12(body: ArrayBuffer): Promise<string> {
const d = new Uint8Array(await crypto.subtle.digest('SHA-256', body));
return Array.from(d.slice(0, 6), b => b.toString(16).padStart(2, '0')).join('');
}
// Once in a while at most: if the page itself still cannot be had from the network, the kept one
// comes back, asks for its old files again, and would be reloaded round and round.
let reloadedAt = 0;
async function reloadStale(id: string) {
if (Date.now() - reloadedAt < 30_000) return;
const c = await sw.clients.get(id);
if (!c || !('navigate' in c)) return;
reloadedAt = Date.now();
await (c as WindowClient).navigate(c.url);
}
async function networkFirst(req: Request, name: string): Promise<Response> {
try {
const r = await fetch(req);
if (r.ok && r.type === 'basic') (await caches.open(name)).put(req, r.clone());
// Back online: whatever waited to be sent goes now.
if (name === API) void flush();
return r;
} catch {
const kept = await caches.match(req, { cacheName: name });
if (!kept) return Response.error();
return name === API ? withQueuedReads(kept) : kept;
}
}
/// A list kept before reads made offline still has those items unread: they are marked as the
/// outbox has them, so a page opened again offline shows what was read.
async function withQueuedReads(kept: Response): Promise<Response> {
const reads = new Map<string, boolean>();
for (const w of await queued()) {
const m = new URL(w.url, location.origin).pathname.match(/^\/api\/entries\/([^/]+)\/([^/]+)\/flags$/);
if (!m) continue;
const read: unknown = (JSON.parse(w.body || '{}') as {read?: unknown}).read;
if (typeof read === 'boolean') reads.set(decodeURIComponent(m[1]!) + '\n' + decodeURIComponent(m[2]!), read);
}
if (!reads.size || !(kept.headers.get('Content-Type') || '').includes('json')) return kept;
// Only the fields read here: a page of items, or one item, as the API sends them.
type Item = {feed_id: string, guid: string, read: boolean};
const body = await kept.clone().json().catch((): null => null) as {entries?: Item[]} & Partial<Item> | null;
const mark = (e: Partial<Item>) => { const r = reads.get(e.feed_id + '\n' + e.guid); if (r !== undefined) e.read = r; };
if (Array.isArray(body?.entries)) body.entries.forEach(mark);
else if (body?.guid) mark(body);
else return kept;
// Its own headers only: the kept one's length and encoding are not this body's.
return new Response(JSON.stringify(body), { status: kept.status, headers: { 'Content-Type': 'application/json' } });
}
// ---- writes made offline -------------------------------------------------------------------
let seq = 0;
async function write(req: Request): Promise<Response> {
const saved: Saved = { url: req.url, body: await req.clone().text(), type: req.headers.get('Content-Type') || 'application/json' };
// One already waiting goes first: sent now, this one would land before it and be undone by it.
if ((await queued()).length === 0) {
try { return await fetch(req); } catch { /* offline: kept below */ }
}
await (await caches.open(OUTBOX)).put(`/__outbox/${Date.now()}-${seq++}`, new Response(JSON.stringify(saved)));
void flush();
// What the server answers for a write the page does not need back: it carries on as if sent.
return new Response(null, { status: 204, headers: { 'X-Ipx-Queued': '1' } });
}
async function queued(): Promise<(Saved & {key: Request})[]> {
const c = await caches.open(OUTBOX);
const all = await Promise.all((await c.keys()).map(async k => {
const r = await c.match(k);
return r ? { key: k, ...(await r.json() as Saved) } : null;
}));
return all.filter(w => w !== null);
}
let flushing: Promise<void> | null = null;
/// Sends the writes in the order they were made; the first that cannot reach the server stops
/// it for the next try. One the server answered at all is done, whatever it said: a refusal now
/// would be a refusal again.
function flush(): Promise<void> {
flushing ||= (async () => {
const c = await caches.open(OUTBOX);
for (const w of await queued()) {
try {
await fetch(w.url, { method: 'POST', body: w.body, headers: { 'Content-Type': w.type }, credentials: 'same-origin' });
} catch { break; }
await c.delete(w.key);
}
})().finally(() => { flushing = null; });
return flushing;
}
// ---- episodes kept on the device (#412) ------------------------------------------------------
/// The kept copy, or the byte range of it a player asks for: an audio element asks for ranges and
/// Safari plays nothing it cannot have them for. Not kept (cleared, or another device's page), the
/// server's own, as without `?offline`.
async function keptEpisode(req: Request, path: string): Promise<Response> {
const kept = await caches.match(path, { cacheName: KEPT });
const range = req.headers.get('Range');
if (!kept) return fetch(path, { headers: range ? { Range: range } : {}, credentials: 'same-origin' });
const blob = await kept.blob();
return ranged(blob, range, kept.headers.get('Content-Type') || 'audio/mpeg');
}
/// `blob` answered as a server answers a Range request: 206 with the part asked for, 416 for a
/// part past its end, 200 with all of it when no range is asked for or the one asked is not
/// understood.
function ranged(blob: Blob, range: string | null, type: string): Response {
const size = blob.size;
const m = range?.match(/^bytes=(\d*)-(\d*)$/);
const head = { 'Content-Type': type, 'Accept-Ranges': 'bytes' };
if (!m || (m[1] === '' && m[2] === '')) return new Response(blob, { headers: { ...head, 'Content-Length': String(size) } });
// `bytes=-N` is the last N bytes.
const start = m[1] === '' ? Math.max(0, size - Number(m[2])) : Number(m[1]);
const end = m[1] === '' || m[2] === '' ? size - 1 : Math.min(Number(m[2]), size - 1);
if (start >= size || start > end) {
return new Response(null, { status: 416, headers: { ...head, 'Content-Range': `bytes */${size}` } });
}
return new Response(blob.slice(start, end + 1), { status: 206, headers: {
...head, 'Content-Length': String(end - start + 1), 'Content-Range': `bytes ${start}-${end}/${size}`,
} });
}
// ---- pictures ----------------------------------------------------------------------------------
const artFor = (src: string) => `/api/art?u=${encodeURIComponent(src)}`;
async function picture(req: Request): Promise<Response> {
try {
return await fetch(req);
} catch {
return (await caches.match(artFor(req.url), { cacheName: PICS })) || Response.error();
}
}
/// Fetches through ipx, three at a time, the pictures not kept yet, then trims to the budget,
/// oldest kept first out.
async function keepPictures(srcs: string[], budget = PICS_BUDGET) {
const c = await caches.open(PICS);
const todo: string[] = [];
// Kept under the address the browser will ask for, which it normalises (a space is %20); asked
// of ipx as the text names it, which is what ipx knows it by.
const key = (s: string) => { try { return artFor(new URL(s).href); } catch { return null; } };
for (const s of srcs) { const k = key(s); if (k && !(await c.match(k))) todo.push(s); }
const one = async () => {
for (let s; (s = todo.shift()) !== undefined;) {
const k = key(s);
if (!k) continue;
try {
const r = await fetch(artFor(s));
if (!r.ok) continue;
const blob = await r.blob();
await c.put(k, new Response(blob, { headers: { 'Content-Type': r.headers.get('Content-Type') || '', 'X-Size': String(blob.size) } }));
} catch { return; } // offline: the rest wait for the next list
}
};
await Promise.all([one(), one(), one()]);
const keys = await c.keys();
const sizes = await Promise.all(keys.map(async k => Number((await c.match(k))?.headers.get('X-Size')) || 0));
let total = sizes.reduce((a, b) => a + b, 0);
for (let i = 0; i < keys.length && total > budget; i++) { await c.delete(keys[i]!); total -= sizes[i]!; }
}