Files
ipx/web/sw/sw.ts
rays b509ea4b35 Coverage for the Rust and the page, and tests for the offline gaps (#390, #391)
Nothing measured what the tests reach. scripts/coverage.sh runs the
cargo tests under cargo-llvm-cov in the ipx-coverage image
(Dockerfile.coverage, built by build-image.sh coverage), in a container
held to the image builder's 6 GB and six threads, one crate at a time,
with the same room and load checks: an instrumented build rebuilds every
dependency, the kind of build that took Tower down on 2026-11-04. The
first run stayed under load 8 with 11 GB free; 69% of lines. web.rs and
daemon.rs read low because the browser suite drives them against a
daemon that is not instrumented.

npm run coverage:page runs the browser suite on a build of the page
(IPX_COVERAGE=1) whose script keeps a source map to each web/src file:
web/concat-map.mts maps the joined files line by line and swc takes it
as its input map. Each test's page records V8's coverage
(tests/ui/coverage.ts, which the specs now take test and expect from),
and monocart-coverage-reports writes it file by file: 73% of lines.
build.mjs requires the map helper only for that build, since
Dockerfile.web's Node does not run TypeScript, and not with a top-level
await, which stopped the CommonJS tests requiring build.mjs.

The offline tests move to offline.spec.ts, in TypeScript, and stand
alone (they add their feed and set the read state they need), with what
#391 found untested: another account signing in clears what was kept, a
position saved offline waits and is sent, and pictures past the budget
go oldest first (keepPictures takes the budget, and trims whether or
not anything new came). The fixture JPEGs were a few bytes no browser
could draw; they are real now, and the artwork test checks one draws.
tsconfig.tools.json type-checks the tests' and build's TypeScript, in
npm run typecheck.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 01:55:19 +00:00

196 lines
9.3 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';
// 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;
}
// The live stream, audio and video pass by: a stream cannot be kept, a file is too big to.
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));
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): Promise<Response> {
const kept = await caches.match(req, { cacheName: PAGE });
if (kept) return kept;
const r = await fetch(req);
if (r.ok) {
const c = await caches.open(PAGE), path = new URL(req.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;
}
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;
}
// ---- 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]!; }
}