Archilyzer · Source

archilyzer

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

commit cd6ec28eae72d7ad2eedc92406f55c4421753eb1
parent c3643bfab19a32005315c5af5478fba18e4c8118
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  2 Jul 2026 01:38:58 -0400

Phase 5: client site registry for the federated hub

New common/components/siteRegistry.ts: the hub's runtime list of federated
archives. RegisteredSite {origin, siteId, siteTitle, accent?, siteUrl?, hubUrl?,
pwa, kind builtin|external, contract, addedAt}; useRegistry() over a shared
useSyncExternalStore module store; validateSite(url) normalizes to an origin,
guards mixed content (https hub can't read an http origin), dedupes, fetches and
validates /site.json (contract === SITE_DESCRIPTOR_VERSION) + /summaries/
manifest.json shape, and returns a ready-to-add external site. Built-ins load
once from /hub-sites.json (absent until compose-hub lands → degrades to an empty
pool); external adds persist to localStorage ytdlp-tb:hub-sites.

Hub-only and unimported in site mode, so this commit is site-mode-inert.

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

Diffstat:
Acommon/components/siteRegistry.ts | 357+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 357 insertions(+), 0 deletions(-)

diff --git a/common/components/siteRegistry.ts b/common/components/siteRegistry.ts @@ -0,0 +1,357 @@ +"use client"; + +// The hub's client-side registry of federated archives. +// +// A "registered site" is one origin the hub reads content from. There are two +// kinds: +// - builtin: a trusted pool site the hub ships knowledge of (emitted by +// compose-hub into `/hub-sites.json`). Loaded at boot. +// - external: an origin the user added by URL at runtime, validated against +// the federation contract and persisted to localStorage. +// +// This module is HUB-ONLY: nothing in single-site mode imports it, so it is +// entirely inert on a plain export site. It manages the LIST of origins; the +// actual per-origin data fetching lives in the origin-aware caches +// (summariesCache/subsCache/...) driven by MultiSiteDataProvider. +// +// State lives in a module-level external store shared via useSyncExternalStore, +// so the shelf, the channel filter, and the offline manager all see one list +// and stay in sync when a site is added or removed. + +import { useEffect, useSyncExternalStore } from "react"; +import { SITE_DESCRIPTOR_VERSION } from "../lib/siteDescriptor"; +import type { PublicSiteDescriptor } from "../lib/siteDescriptor"; +import { parseSiteUrl } from "../lib/site"; + +export type RegisteredSite = { + // "" = the hub's own same-origin pool; otherwise a full origin + // ("https://x.com"). Root-relative resource paths are prefixed with this + // (see idBaseUrl). + origin: string; + siteId: string; + siteTitle: string; + // Per-site provenance accent, carried through results/filters in the UI. + accent?: string; + // Absolute public URL of the site (for outbound "open the original" links). + siteUrl?: string; + // The hub this site names as its parent, if any. Lets the hub tell a member + // (names THIS hub) from an arbitrary added origin — the member-vs-added badge. + hubUrl?: string; + // Whether the site ships an installable PWA (the installable-vs-dumb badge). + pwa: boolean; + kind: "builtin" | "external"; + // Contract version the descriptor declared (already validated to match). + contract: number; + // ms epoch when added; 0 for builtins (loaded at boot, order-stable). + addedAt: number; +}; + +const STORAGE_KEY = "ytdlp-tb:hub-sites"; +const HUB_SITES_URL = "/hub-sites.json"; + +// ---- module store ------------------------------------------------------- + +let sites: RegisteredSite[] = []; +const EMPTY: readonly RegisteredSite[] = Object.freeze([]); +const listeners = new Set<() => void>(); +let builtinsLoaded = false; +let hydratedFromStorage = false; + +function emit() { + for (const l of listeners) l(); +} + +function subscribe(cb: () => void): () => void { + listeners.add(cb); + return () => listeners.delete(cb); +} + +function getSnapshot(): readonly RegisteredSite[] { + return sites; +} + +function getServerSnapshot(): readonly RegisteredSite[] { + // No registry on the server (localStorage/builtins are client-only). A stable + // frozen constant avoids the useSyncExternalStore "getServerSnapshot should be + // cached" warning. + return EMPTY; +} + +// Replace the store with a new immutable array (stable ref for the snapshot). +function setSites(next: RegisteredSite[]) { + sites = next; + emit(); +} + +// ---- persistence (external adds only) ----------------------------------- + +function persistExternals() { + if (typeof window === "undefined") return; + try { + const externals = sites.filter((s) => s.kind === "external"); + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(externals)); + } catch { + // Storage full / disabled — the in-memory list still works this session. + } +} + +function hydrateFromStorage() { + if (hydratedFromStorage || typeof window === "undefined") return; + hydratedFromStorage = true; + let stored: unknown; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + stored = raw ? JSON.parse(raw) : null; + } catch { + stored = null; + } + if (!Array.isArray(stored)) return; + const loaded: RegisteredSite[] = []; + const seen = new Set(sites.map((s) => s.origin)); + for (const raw of stored) { + const rs = coerceStored(raw); + if (!rs || seen.has(rs.origin)) continue; + seen.add(rs.origin); + loaded.push(rs); + } + if (loaded.length) setSites([...sites, ...loaded]); +} + +// Rebuild a RegisteredSite from a persisted record, tolerating older shapes. +function coerceStored(raw: unknown): RegisteredSite | null { + if (!raw || typeof raw !== "object") return null; + const r = raw as Record<string, unknown>; + if (typeof r.origin !== "string" || !r.origin) return null; + if (typeof r.siteId !== "string" || typeof r.siteTitle !== "string") + return null; + return { + origin: r.origin, + siteId: r.siteId, + siteTitle: r.siteTitle, + accent: typeof r.accent === "string" ? r.accent : undefined, + siteUrl: typeof r.siteUrl === "string" ? r.siteUrl : undefined, + hubUrl: typeof r.hubUrl === "string" ? r.hubUrl : undefined, + pwa: r.pwa === true, + kind: "external", + contract: typeof r.contract === "number" ? r.contract : 0, + addedAt: typeof r.addedAt === "number" ? r.addedAt : 0, + }; +} + +// ---- 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; + try { + const res = await fetch(HUB_SITES_URL); + if (!res.ok) return; + data = await res.json(); + } catch { + return; + } + if (!Array.isArray(data)) return; + const seen = new Set(sites.map((s) => s.origin)); + const builtins: RegisteredSite[] = []; + for (const raw of data) { + const rs = coerceBuiltin(raw); + if (!rs || seen.has(rs.origin)) continue; + seen.add(rs.origin); + builtins.push(rs); + } + if (builtins.length) { + // Builtins sort first (addedAt 0), then externals by add time. + setSites( + [...sites, ...builtins].sort((a, b) => a.addedAt - b.addedAt), + ); + } +} + +// A hub-sites.json entry describes a pool site by its public URL. Its origin is +// derived from siteUrl; entries without a usable URL can't be federated. +function coerceBuiltin(raw: unknown): RegisteredSite | null { + if (!raw || typeof raw !== "object") return null; + const r = raw as Record<string, unknown>; + const url = parseSiteUrl(r.siteUrl); + let origin: string; + try { + origin = url ? new URL(url).origin : ""; + } catch { + return null; + } + if (!origin) return null; + if (typeof r.siteId !== "string" || typeof r.siteTitle !== "string") + return null; + return { + origin, + siteId: r.siteId, + siteTitle: r.siteTitle, + accent: typeof r.accent === "string" ? r.accent : undefined, + siteUrl: url, + hubUrl: parseSiteUrl(r.hubUrl), + pwa: r.pwa === true, + kind: "builtin", + contract: + typeof r.contract === "number" ? r.contract : SITE_DESCRIPTOR_VERSION, + addedAt: 0, + }; +} + +// ---- add-by-URL validation ---------------------------------------------- + +export type ValidateResult = + | { ok: true; site: RegisteredSite } + | { ok: false; reason: string }; + +// Human-facing failure copy — written as direction, not blame. See the plan's +// add-flow states. +const BAD_URL = "Enter a full site URL, like https://archive.example.com."; +const MIXED = "That site is served over http, so a secure hub can't read it."; +const ALREADY = "That archive is already on your shelf."; +const UNREADABLE = + "Couldn't read that site — it may not be an Archilyzer archive, or it isn't sharing its data."; +const WRONG_CONTRACT = + "That archive speaks a version this hub doesn't understand yet."; + +// Validate a user-entered URL and, on success, return a ready-to-add external +// RegisteredSite. Does NOT mutate the store — the caller commits via addSite so +// the UI can show a validating → success/failure flow first. +export async function validateSite(input: string): Promise<ValidateResult> { + const normalized = parseSiteUrl(input); + if (!normalized) return { ok: false, reason: BAD_URL }; + + let origin: string; + try { + origin = new URL(normalized).origin; + } catch { + return { ok: false, reason: BAD_URL }; + } + + // Mixed-content guard: an https hub can't fetch an http origin (the browser + // blocks it). Allow http targets only when the hub itself is served over http + // (local dev / e2e). + if (typeof window !== "undefined") { + const hubHttps = window.location.protocol === "https:"; + if (hubHttps && origin.startsWith("http://")) { + return { ok: false, reason: MIXED }; + } + } + + if (sites.some((s) => s.origin === origin)) { + return { ok: false, reason: ALREADY }; + } + + let descriptor: PublicSiteDescriptor; + try { + const res = await fetch(`${origin}/site.json`); + if (!res.ok) return { ok: false, reason: UNREADABLE }; + descriptor = (await res.json()) as PublicSiteDescriptor; + } catch { + return { ok: false, reason: UNREADABLE }; + } + + if (!descriptor || typeof descriptor !== "object") { + return { ok: false, reason: UNREADABLE }; + } + if (descriptor.contract !== SITE_DESCRIPTOR_VERSION) { + return { ok: false, reason: WRONG_CONTRACT }; + } + if ( + typeof descriptor.siteId !== "string" || + typeof descriptor.siteTitle !== "string" || + !Array.isArray(descriptor.channels) + ) { + return { ok: false, reason: UNREADABLE }; + } + + // Confirm the summaries manifest is present and shaped as expected — a site + // whose descriptor is fine but whose data isn't reachable/valid would fail + // silently later, so reject it now with a clear message. + try { + const res = await fetch(`${origin}/summaries/manifest.json`); + if (!res.ok) return { ok: false, reason: UNREADABLE }; + const manifest = (await res.json()) as { version?: unknown; channels?: unknown }; + if ( + typeof manifest?.version !== "number" || + !Array.isArray(manifest?.channels) + ) { + return { ok: false, reason: UNREADABLE }; + } + } catch { + return { ok: false, reason: UNREADABLE }; + } + + const site: RegisteredSite = { + origin, + siteId: descriptor.siteId, + siteTitle: descriptor.siteTitle, + accent: descriptor.accent, + siteUrl: descriptor.siteUrl ?? origin, + hubUrl: descriptor.hubUrl, + pwa: descriptor.pwa === true, + kind: "external", + contract: descriptor.contract, + addedAt: Date.now(), + }; + return { ok: true, site }; +} + +// ---- mutations ---------------------------------------------------------- + +function addSite(site: RegisteredSite) { + if (sites.some((s) => s.origin === site.origin)) return; + setSites([...sites, site]); + if (site.kind === "external") persistExternals(); +} + +// Only external adds are removable; builtins are part of the shipped pool. +function removeSite(origin: string) { + const next = sites.filter( + (s) => !(s.origin === origin && s.kind === "external"), + ); + if (next.length === sites.length) return; + setSites(next); + persistExternals(); +} + +// ---- hook --------------------------------------------------------------- + +export type RegistryApi = { + sites: readonly RegisteredSite[]; + addSite: (site: RegisteredSite) => void; + removeSite: (origin: string) => void; + hasOrigin: (origin: string) => boolean; +}; + +export function useRegistry(): RegistryApi { + const snapshot = useSyncExternalStore( + subscribe, + getSnapshot, + getServerSnapshot, + ); + + useEffect(() => { + // Client-only initialization; both are idempotent (guarded internally). + hydrateFromStorage(); + void loadBuiltins(); + }, []); + + return { + sites: snapshot, + addSite, + removeSite, + hasOrigin: (origin) => sites.some((s) => s.origin === origin), + }; +} + +// Test/reset seam — clears the in-memory store and re-arms the one-shot loaders +// so a fresh mount re-hydrates. Not used in production paths. +export function __resetRegistryForTests() { + sites = []; + builtinsLoaded = false; + hydratedFromStorage = false; + emit(); +}