"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 { manifestUrl, rootFileUrl } from "../lib/archive/contract"; import { SITE_DESCRIPTOR_VERSION } from "../lib/siteDescriptor"; import type { PublicSiteDescriptor } from "../lib/siteDescriptor"; // Normalize a raw URL into a trimmed absolute http(s) URL with no trailing // slash, or undefined. Inlined (rather than importing from lib/site.ts) to keep // this module free of the fs-backed site loader — it runs in the client bundle. function parseSiteUrl(input: unknown): string | undefined { if (typeof input !== "string") return undefined; const trimmed = input.trim().replace(/\/+$/, ""); if (!/^https?:\/\/\S+/i.test(trimmed)) return undefined; return trimmed; } 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; // How many channels the archive exposes (from its descriptor), when known — // shown on the shelf spine. Absent for built-ins seeded from hub-sites.json. channelCount?: 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>(); // `/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; 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; } // 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 // cached" warning. 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; 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; 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, ...(typeof r.channelCount === "number" ? { channelCount: r.channelCount } : {}), addedAt: typeof r.addedAt === "number" ? r.addedAt : 0, }; } // ---- builtins (trusted pool, emitted by compose-hub) -------------------- // 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 { try { const res = await fetch(HUB_SITES_URL); if (!res.ok) return []; const data: unknown = await res.json(); return Array.isArray(data) ? data : []; } catch { 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 { 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) { 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. 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 // 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; 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 this hub."; 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 { 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(rootFileUrl("site.json", origin)); 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(manifestUrl("summaries", undefined, origin)); 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, channelCount: descriptor.channels.length, 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[]; // 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; }; export function useRegistry(): RegistryApi { const snapshot = useSyncExternalStore( subscribe, getSnapshot, getServerSnapshot, ); const loaded = useSyncExternalStore( subscribe, getBuiltinsLoaded, getServerBuiltinsLoaded, ); useEffect(() => { // Client-only initialization; both are idempotent (guarded internally). hydrateFromStorage(); void loadBuiltins(); }, []); return { sites: snapshot, builtinsLoaded: loaded, 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 = []; builtinsRequested = false; builtinsLoaded = false; hydratedFromStorage = false; emit(); }