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:
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();
+}