Archilyzer · Source

archilyzer

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

commit 4527c33ee662add7bb6c0ff40c6ea962c5ecc0f6
parent eb677ac7cafc1d44165d44c41a26cc197d68484f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 28 Sep 2026 02:34:01 -0400

common: siteRegistry says when the hub's built-ins have loaded — builtinsLoaded (useRegistry, getBuiltinsLoaded) turns true once /hub-sites.json has been answered, found, empty, missing or unreadable, in the same update that adds them; loadBuiltins exported; +8 unit tests

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

Diffstat:
Acommon/components/siteRegistry.test.ts | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/components/siteRegistry.ts | 65++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
2 files changed, 171 insertions(+), 13 deletions(-)

diff --git a/common/components/siteRegistry.test.ts b/common/components/siteRegistry.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, test } from "node:test"; +import assert from "node:assert/strict"; +import { + __resetRegistryForTests, + getBuiltinsLoaded, + getRegisteredSites, + loadBuiltins, + subscribeRegistry, +} from "./siteRegistry"; + +// ─── the built-ins' "loaded" flag ─── +// +// The hub's /ask counts as ready only once `/hub-sites.json` has been ANSWERED: +// before that, the list may hold only the archives this browser added, and a +// question would ground in half the hub. So the flag must turn true for every +// answer — a list, an empty list, a 404, a network error, bad JSON — and never +// before the built-ins are in the list (release 11, O1). + +const A = { + siteId: "a", + siteTitle: "Archive A", + siteUrl: "https://a.example.com", + pwa: true, + contract: 1, +}; + +type Answer = { status: number; body: string } | "throw"; + +let restore: (() => void) | null = null; + +// A client-side module: it only loads with a `window` to load into. +function install(answer: Answer): { release: () => void; asked: string[] } { + const asked: string[] = []; + const realFetch = globalThis.fetch; + const hadWindow = "window" in globalThis; + (globalThis as { window?: unknown }).window = globalThis; + let release!: () => void; + const gate = new Promise<void>((r) => (release = r)); + globalThis.fetch = (async (input: RequestInfo | URL) => { + asked.push(String(input)); + await gate; + if (answer === "throw") throw new TypeError("Failed to fetch"); + return new Response(answer.body, { status: answer.status }); + }) as typeof fetch; + restore = () => { + globalThis.fetch = realFetch; + if (!hadWindow) delete (globalThis as { window?: unknown }).window; + }; + return { release, asked }; +} + +beforeEach(() => __resetRegistryForTests()); +afterEach(() => { + restore?.(); + restore = null; + __resetRegistryForTests(); +}); + +test("a listed built-in: loaded turns true only once it is in the list, in one update", async () => { + const { release, asked } = install({ status: 200, body: JSON.stringify([A]) }); + const seen: { loaded: boolean; origins: string[] }[] = []; + const unsubscribe = subscribeRegistry(() => + seen.push({ + loaded: getBuiltinsLoaded(), + origins: getRegisteredSites().map((s) => s.origin), + }), + ); + const pending = loadBuiltins(); + assert.equal(getBuiltinsLoaded(), false, "not loaded while the file is in flight"); + release(); + await pending; + unsubscribe(); + assert.deepEqual(asked, ["/hub-sites.json"]); + assert.equal(getBuiltinsLoaded(), true); + assert.deepEqual( + getRegisteredSites().map((s) => [s.origin, s.kind]), + [["https://a.example.com", "builtin"]], + ); + // One notification, and it already carries the built-in: no reader sees + // "loaded" over a list that lacks it. + assert.deepEqual(seen, [{ loaded: true, origins: ["https://a.example.com"] }]); +}); + +for (const [name, answer] of [ + ["an empty list ([])", { status: 200, body: "[]" }], + ["a 404 (no hub-sites.json)", { status: 404, body: "" }], + ["a 500", { status: 500, body: "" }], + ["a network error", "throw"], + ["unreadable JSON", { status: 200, body: "{not json" }], + ["JSON that is not a list", { status: 200, body: '{"sites":[]}' }], +] as [string, Answer][]) { + test(`${name}: loaded turns true with no built-ins`, async () => { + const { release } = install(answer); + let notified = 0; + const unsubscribe = subscribeRegistry(() => notified++); + const pending = loadBuiltins(); + assert.equal(getBuiltinsLoaded(), false); + release(); + await pending; + unsubscribe(); + assert.equal(getBuiltinsLoaded(), true); + assert.deepEqual(getRegisteredSites(), []); + // Subscribers hear it even though the list did not change. + assert.equal(notified, 1); + }); +} + +test("it is asked for once: a second load neither refetches nor re-notifies", async () => { + const { release, asked } = install({ status: 200, body: JSON.stringify([A]) }); + release(); + await loadBuiltins(); + let notified = 0; + const unsubscribe = subscribeRegistry(() => notified++); + await loadBuiltins(); + unsubscribe(); + assert.equal(asked.length, 1); + assert.equal(notified, 0); + assert.equal(getBuiltinsLoaded(), true); +}); diff --git a/common/components/siteRegistry.ts b/common/components/siteRegistry.ts @@ -67,6 +67,12 @@ const HUB_SITES_URL = "/hub-sites.json"; let sites: RegisteredSite[] = []; const EMPTY: readonly RegisteredSite[] = Object.freeze([]); const listeners = new Set<() => void>(); +// `/hub-sites.json` asked for (the one-shot guard) and ANSWERED — found, empty, +// missing or unreadable. Until it has been answered the list is not the hub's +// whole list: a surface that must not act on half of it (the /ask chat) waits +// for `builtinsLoaded`. It is never retried, so a failure settles it too: the +// hub then lists no built-ins, and says so, rather than loading forever. +let builtinsRequested = false; let builtinsLoaded = false; let hydratedFromStorage = false; @@ -83,6 +89,11 @@ function getSnapshot(): readonly RegisteredSite[] { return sites; } +// The store outside React (useRegistry is the hook): its current list, and a +// subscription to every change of it or of `getBuiltinsLoaded()`. +export const getRegisteredSites = getSnapshot; +export const subscribeRegistry = subscribe; + function getServerSnapshot(): readonly RegisteredSite[] { // No registry on the server (localStorage/builtins are client-only). A stable // frozen constant avoids the useSyncExternalStore "getServerSnapshot should be @@ -90,6 +101,16 @@ function getServerSnapshot(): readonly RegisteredSite[] { return EMPTY; } +// Whether `/hub-sites.json` has been answered (see `builtinsLoaded`). Never on +// the server: the prerendered page is the "not yet" state. +export function getBuiltinsLoaded(): boolean { + return builtinsLoaded; +} + +function getServerBuiltinsLoaded(): boolean { + return false; +} + // Replace the store with a new immutable array (stable ref for the snapshot). function setSites(next: RegisteredSite[]) { sites = next; @@ -154,20 +175,27 @@ function coerceStored(raw: unknown): RegisteredSite | null { // ---- builtins (trusted pool, emitted by compose-hub) -------------------- -// Load `/hub-sites.json` once. It is absent on non-hub builds and pre-Phase-6 -// hub builds, so a 404/parse failure degrades silently to an empty pool. -async function loadBuiltins() { - if (builtinsLoaded || typeof window === "undefined") return; - builtinsLoaded = true; - let data: unknown; +// The entries of `/hub-sites.json`, or none. It is absent on non-hub builds and +// pre-Phase-6 hub builds, so a 404/parse failure degrades silently to an empty +// pool. +async function fetchBuiltins(): Promise<unknown[]> { try { const res = await fetch(HUB_SITES_URL); - if (!res.ok) return; - data = await res.json(); + if (!res.ok) return []; + const data: unknown = await res.json(); + return Array.isArray(data) ? data : []; } catch { - return; + return []; } - if (!Array.isArray(data)) return; +} + +// Load `/hub-sites.json` once, then mark the built-ins loaded — whatever the +// answer was. The built-ins join the list and the flag turns true in ONE +// update, so no reader ever sees "loaded" over a list that lacks them. +export async function loadBuiltins(): Promise<void> { + if (builtinsRequested || typeof window === "undefined") return; + builtinsRequested = true; + const data = await fetchBuiltins(); const seen = new Set(sites.map((s) => s.origin)); const builtins: RegisteredSite[] = []; for (const raw of data) { @@ -178,10 +206,10 @@ async function loadBuiltins() { } if (builtins.length) { // Builtins sort first (addedAt 0), then externals by add time. - setSites( - [...sites, ...builtins].sort((a, b) => a.addedAt - b.addedAt), - ); + sites = [...sites, ...builtins].sort((a, b) => a.addedAt - b.addedAt); } + builtinsLoaded = true; + emit(); } // A hub-sites.json entry describes a pool site by its public URL. Its origin is @@ -336,6 +364,10 @@ function removeSite(origin: string) { export type RegistryApi = { sites: readonly RegisteredSite[]; + // True once `/hub-sites.json` has been answered (found, empty, missing or + // unreadable): `sites` then holds every built-in this hub has. Until then it + // may hold only the archives this browser added. + builtinsLoaded: boolean; addSite: (site: RegisteredSite) => void; removeSite: (origin: string) => void; hasOrigin: (origin: string) => boolean; @@ -347,6 +379,11 @@ export function useRegistry(): RegistryApi { getSnapshot, getServerSnapshot, ); + const loaded = useSyncExternalStore( + subscribe, + getBuiltinsLoaded, + getServerBuiltinsLoaded, + ); useEffect(() => { // Client-only initialization; both are idempotent (guarded internally). @@ -356,6 +393,7 @@ export function useRegistry(): RegistryApi { return { sites: snapshot, + builtinsLoaded: loaded, addSite, removeSite, hasOrigin: (origin) => sites.some((s) => s.origin === origin), @@ -366,6 +404,7 @@ export function useRegistry(): RegistryApi { // so a fresh mount re-hydrates. Not used in production paths. export function __resetRegistryForTests() { sites = []; + builtinsRequested = false; builtinsLoaded = false; hydratedFromStorage = false; emit();