Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 3ccdc7280d42a9eb8aa97516838584af35c0a7f4
parent 24294e60f987bc3c979a77856c1e8b7789545f0a
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  2 Jul 2026 02:28:09 -0400

Phase 7: hub service worker + origin-aware offline cache

- New export/service-worker/sw-hub.js: cross-origin variant of the site SW.
  Caches transcript/subs/summaries shards from ANY origin (federation), always
  fetching readable `cors` responses (the added origins are CORS-open). Staleness
  (generatedAt) META, channel eviction, and status are scoped per (origin, slug)
  keyed off the full request URL; the app shell stays same-origin. compose-hub
  already prefers sw-hub.js → the hub build now ships it (verified in out/sw.js).
- export/app/lib/offlineCache.ts is origin-aware: downloadChannelOffline/
  evictChannelOffline/channelCachedPages take an origin ("" = same-origin);
  shard URLs are idBaseUrl(origin)-prefixed; the pinned registry keys on
  makeId(origin, slug); SW messages carry origin + absolute URLs. OfflineManager
  passes "" at every call site.

Single-site path is byte-identical (idBaseUrl("")==="" → root-relative URLs;
makeId("",slug)===slug → same pinned keys; the site SW ignores the origin field).
Type-checks clean; hub build ships the hub SW.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Diffstat:
Mexport/app/components/OfflineManager.tsx | 8++++----
Mexport/app/lib/offlineCache.ts | 65+++++++++++++++++++++++++++++++++++++++++++++--------------------
Aexport/service-worker/sw-hub.js | 223+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 272 insertions(+), 24 deletions(-)

diff --git a/export/app/components/OfflineManager.tsx b/export/app/components/OfflineManager.tsx @@ -76,8 +76,8 @@ export function OfflineManager({ channels }: { channels: OfflineChannel[] }) { let cancelled = false; (async () => { for (const c of channels) { - if (!isPinned(c.slug)) continue; - const cached = await channelCachedPages(c.slug); + if (!isPinned("", c.slug)) continue; + const cached = await channelCachedPages("", c.slug); if (!cancelled && cached > 0) { setState(c.slug, { download: "cached", done: cached, total: cached }); } @@ -90,14 +90,14 @@ export function OfflineManager({ channels }: { channels: OfflineChannel[] }) { async function download(slug: string) { setState(slug, { download: "downloading", done: 0, total: 0 }); - const ok = await downloadChannelOffline(slug, ({ done, total }) => + const ok = await downloadChannelOffline("", slug, ({ done, total }) => setState(slug, { download: "downloading", done, total }), ).catch(() => false); setState(slug, { download: ok ? "cached" : "error" }); } async function remove(slug: string) { - await evictChannelOffline(slug); + await evictChannelOffline("", slug); await dropChannelIndex(slug).catch(() => {}); setState(slug, { ...DEFAULT }); } diff --git a/export/app/lib/offlineCache.ts b/export/app/lib/offlineCache.ts @@ -3,11 +3,19 @@ // Client helpers for the opt-in "download this channel for offline" feature and // the pinned-channel registry. Talks to the service worker (public/sw.js) over a // MessageChannel; degrades gracefully where no SW is controlling the page. +// +// Origin-aware: every entry point takes an `origin` ("" = same-origin, the +// single-site case; a full origin like "https://x.com" for a federated hub +// channel). idBaseUrl("") === "" so same-origin URLs stay root-relative and the +// pinned-registry key stays the bare slug — the single-site path is unchanged. +// The hub SW (sw-hub.js) reads the `origin` in each message to scope caching +// per (origin, slug); the site SW ignores it (same-origin only). import { transcriptPageFileName, type ChannelTranscriptsManifest, } from "yt-dlp-transcript-common/lib/manifest"; +import { idBaseUrl, makeId } from "yt-dlp-transcript-common/components/originId"; const PINNED_KEY = "ytdlp-tb:offline-channels"; @@ -49,10 +57,11 @@ function sendToSw( } async function fetchManifest( + origin: string, slug: string, ): Promise<ChannelTranscriptsManifest | null> { try { - const res = await fetch(`/transcripts/${slug}/manifest.json`, { + const res = await fetch(`${idBaseUrl(origin)}/transcripts/${slug}/manifest.json`, { cache: "no-store", }); if (!res.ok) return null; @@ -65,40 +74,51 @@ async function fetchManifest( export type DownloadProgress = { done: number; total: number }; // Download every shard of a channel into the SW cache, reporting progress. The -// URL list is the channel manifest plus each page-NNNN.json. +// URL list is the channel manifest plus each page-NNNN.json, prefixed with the +// channel's origin so a hub can cache cross-origin channels. export async function downloadChannelOffline( + origin: string, slug: string, onProgress?: (p: DownloadProgress) => void, ): Promise<boolean> { const sw = await controller(); if (!sw) return false; - const manifest = await fetchManifest(slug); + const manifest = await fetchManifest(origin, slug); if (!manifest) return false; - const urls = [`/transcripts/${slug}/manifest.json`]; + const base = idBaseUrl(origin); + const urls = [`${base}/transcripts/${slug}/manifest.json`]; for (let p = 0; p < manifest.pageCount; p++) { - urls.push(`/transcripts/${slug}/${transcriptPageFileName(p)}`); + urls.push(`${base}/transcripts/${slug}/${transcriptPageFileName(p)}`); } - await sendToSw(sw, { type: "CACHE_URLS", slug, urls }, (data) => { + await sendToSw(sw, { type: "CACHE_URLS", origin, slug, urls }, (data) => { if (data.type === "progress") { onProgress?.({ done: data.done, total: data.total }); } }); - pin(slug); + pin(origin, slug); return true; } -export async function evictChannelOffline(slug: string): Promise<void> { +export async function evictChannelOffline( + origin: string, + slug: string, +): Promise<void> { const sw = await controller(); - if (sw) await sendToSw(sw, { type: "EVICT_CHANNEL", slug }).catch(() => {}); - unpin(slug); + if (sw) { + await sendToSw(sw, { type: "EVICT_CHANNEL", origin, slug }).catch(() => {}); + } + unpin(origin, slug); } // How many page shards of a channel are currently cached (0 = not offline). -export async function channelCachedPages(slug: string): Promise<number> { +export async function channelCachedPages( + origin: string, + slug: string, +): Promise<number> { const sw = await controller(); if (!sw) return 0; try { - const data = await sendToSw(sw, { type: "CHANNEL_STATUS", slug }); + const data = await sendToSw(sw, { type: "CHANNEL_STATUS", origin, slug }); return typeof data.cachedPages === "number" ? data.cachedPages : 0; } catch { return 0; @@ -106,6 +126,10 @@ export async function channelCachedPages(slug: string): Promise<number> { } // ---- Pinned registry (which channels the user chose to keep offline) ---- +// Entries are origin-qualified ids (makeId): a bare slug for same-origin, +// "origin\tslug" for a federated channel — so a hub can pin the same channel +// name from different sites without collision, and single-site pins are +// unchanged (bare slugs). export function pinnedChannels(): string[] { if (typeof window === "undefined") return []; try { @@ -117,20 +141,21 @@ export function pinnedChannels(): string[] { return []; } } -export function isPinned(slug: string): boolean { - return pinnedChannels().includes(slug); +export function isPinned(origin: string, slug: string): boolean { + return pinnedChannels().includes(makeId(origin, slug)); } -function writePinned(slugs: string[]): void { +function writePinned(ids: string[]): void { if (typeof window === "undefined") return; try { - window.localStorage.setItem(PINNED_KEY, JSON.stringify([...new Set(slugs)])); + window.localStorage.setItem(PINNED_KEY, JSON.stringify([...new Set(ids)])); } catch { /* quota — non-fatal */ } } -function pin(slug: string): void { - writePinned([...pinnedChannels(), slug]); +function pin(origin: string, slug: string): void { + writePinned([...pinnedChannels(), makeId(origin, slug)]); } -function unpin(slug: string): void { - writePinned(pinnedChannels().filter((s) => s !== slug)); +function unpin(origin: string, slug: string): void { + const id = makeId(origin, slug); + writePinned(pinnedChannels().filter((s) => s !== id)); } diff --git a/export/service-worker/sw-hub.js b/export/service-worker/sw-hub.js @@ -0,0 +1,223 @@ +/* Service worker for the Archilyzer HUB (INSTANCE_MODE=hub). + * + * Same offline model as the site SW (browse-cache + opt-in per-channel + * download), but CROSS-ORIGIN: the hub federates content from many origins, so + * this worker caches transcript/subs/summaries shards from ANY origin — not + * just its own. The only origins it ever reaches are ones the user added, whose + * JSON is CORS-open (Access-Control-Allow-Origin: *); a cross-origin fetch is + * therefore a readable `cors` response that can be cached (never `no-cors` — + * opaque responses are unreadable and quota-toxic). + * + * Differences from the site SW: + * - no same-origin early-return: shard requests to any origin are cached; + * - staleness (generatedAt) META and channel eviction/status are scoped per + * (origin, slug), keyed off the FULL request URL, so same-named channels on + * different sites don't collide; + * - the page's message API carries `origin` + absolute shard URLs. + */ + +const VERSION = "v1"; +const SHELL = `shell-${VERSION}`; +const PAGES = `pages-${VERSION}`; +const META = `meta-${VERSION}`; + +// Matches /transcripts/<slug>/... and the parallel /subs, /summaries trees, on +// any origin (we test url.pathname, so it's origin-independent). +const SHARD_RE = /^\/(transcripts|subs|summaries)\/([^/]+)\/(.+)$/; + +self.addEventListener("install", () => { + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + (async () => { + const keep = new Set([SHELL, PAGES, META]); + const names = await caches.keys(); + await Promise.all(names.map((n) => (keep.has(n) ? null : caches.delete(n)))); + await self.clients.claim(); + })(), + ); +}); + +self.addEventListener("fetch", (event) => { + const req = event.request; + if (req.method !== "GET") return; + const url = new URL(req.url); + + // Shard requests are cached for ANY origin (this is the federation change). + const shard = SHARD_RE.exec(url.pathname); + if (shard) { + const [, , slug, rest] = shard; + if (rest === "manifest.json") { + event.respondWith(handleManifest(req, url.origin, slug)); + } else { + event.respondWith(cacheFirst(req, PAGES)); + } + return; + } + + // App shell is same-origin only (the hub's own bundle). + if (url.origin !== self.location.origin) return; + if (url.pathname.startsWith("/_next/static/") || url.pathname.startsWith("/icons/")) { + event.respondWith(cacheFirst(req, SHELL)); + return; + } + if (req.mode === "navigate" || req.destination === "document") { + event.respondWith(networkFirstDoc(req)); + return; + } +}); + +// Cache-first: serve from cache, else fetch (cors) and store. +async function cacheFirst(req, cacheName) { + const cache = await caches.open(cacheName); + const hit = await cache.match(req); + if (hit) return hit; + try { + const res = await fetch(req); + if (res.ok) cache.put(req, res.clone()); + return res; + } catch (err) { + return hit || Response.error(); + } +} + +async function networkFirstDoc(req) { + const cache = await caches.open(SHELL); + try { + const res = await fetch(req); + if (res.ok) cache.put(req, res.clone()); + return res; + } catch (err) { + const hit = await cache.match(req); + if (hit) return hit; + const root = await cache.match("/"); + if (root) return root; + return Response.error(); + } +} + +// Channel manifest: network-first. On a fresh manifest, compare generatedAt to +// the stored value (keyed per origin) and evict that (origin, slug)'s page +// shards if it changed. Offline → serve the cached manifest. +async function handleManifest(req, origin, slug) { + const pages = await caches.open(PAGES); + try { + const res = await fetch(req); + if (res.ok) { + const clone = res.clone(); + try { + const body = await clone.json(); + const gen = String(body?.generatedAt ?? ""); + const prev = await readMeta(origin, slug); + if (prev !== null && prev !== gen) { + await evictChannelPages(origin, slug); + } + await writeMeta(origin, slug, gen); + } catch { + // Unparseable manifest — still serve it. + } + pages.put(req, res.clone()); + } + return res; + } catch (err) { + const hit = await pages.match(req); + return hit || Response.error(); + } +} + +// True when a cached entry belongs to (origin, slug) and is a page shard (not a +// manifest). origin === "" means the hub's own same-origin content. +function belongsToChannel(entryUrl, origin, slug) { + const u = new URL(entryUrl); + const wantOrigin = origin || self.location.origin; + if (u.origin !== wantOrigin) return false; + const prefixes = [ + `/transcripts/${slug}/`, + `/subs/${slug}/`, + `/summaries/${slug}/`, + ]; + return prefixes.some( + (pre) => u.pathname.startsWith(pre) && !u.pathname.endsWith("manifest.json"), + ); +} + +async function evictChannelPages(origin, slug) { + const pages = await caches.open(PAGES); + const keys = await pages.keys(); + await Promise.all( + keys.map((k) => (belongsToChannel(k.url, origin, slug) ? pages.delete(k) : null)), + ); +} + +// Per-(origin, channel) generatedAt stored as a synthetic Response in META. +function metaKey(origin, slug) { + return `/__gen__/${encodeURIComponent(origin || "")}/${slug}`; +} +async function readMeta(origin, slug) { + const meta = await caches.open(META); + const hit = await meta.match(metaKey(origin, slug)); + return hit ? await hit.text() : null; +} +async function writeMeta(origin, slug, gen) { + const meta = await caches.open(META); + await meta.put(metaKey(origin, slug), new Response(gen)); +} + +// ---- Message API (the page drives bulk download / eviction / status) ---- +self.addEventListener("message", (event) => { + const data = event.data || {}; + const port = event.ports && event.ports[0]; + const origin = data.origin || ""; + if (data.type === "CACHE_URLS") { + event.waitUntil(cacheUrls(data.slug, data.urls || [], port)); + } else if (data.type === "EVICT_CHANNEL") { + event.waitUntil( + evictChannelPages(origin, data.slug).then( + () => port && port.postMessage({ ok: true }), + ), + ); + } else if (data.type === "CHANNEL_STATUS") { + event.waitUntil(channelStatus(origin, data.slug, port)); + } +}); + +// Fetch + cache a list of (absolute or root-relative) shard URLs. +async function cacheUrls(slug, urls, port) { + const cache = await caches.open(PAGES); + let done = 0; + for (const u of urls) { + try { + const res = await fetch(u, { cache: "reload" }); + if (res.ok) await cache.put(u, res.clone()); + } catch { + // Skip failures; the channel just won't be fully offline. + } + done++; + if (port) port.postMessage({ type: "progress", slug, done, total: urls.length }); + } + if (port) port.postMessage({ type: "done", slug, done, total: urls.length }); +} + +// Report how many of a channel's transcript page shards are cached, scoped to +// (origin, slug). +async function channelStatus(origin, slug, port) { + const cache = await caches.open(PAGES); + const keys = await cache.keys(); + const wantOrigin = origin || self.location.origin; + const prefix = `/transcripts/${slug}/`; + let cached = 0; + for (const k of keys) { + const u = new URL(k.url); + if ( + u.origin === wantOrigin && + u.pathname.startsWith(prefix) && + u.pathname.endsWith(".json") && + !u.pathname.endsWith("manifest.json") + ) { + cached++; + } + } + if (port) port.postMessage({ type: "status", slug, cachedPages: cached }); +}