Archilyzer · Source

archilyzer

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

commit d16fd247d51adf3e8846909def06ba8cde22c28a
parent 9d10cfef2849532bb1e6b0e0687580288221cf38
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Wed,  1 Jul 2026 23:22:51 -0400

Phase 1: serve /site.json federation contract + CORS headers

Every export bundle now serves a public /site.json descriptor (branding,
channels with slugs, groups, generatedAt, pwa flag, resolved hubUrl) plus a
Cloudflare _headers file with Access-Control-Allow-Origin:* on all JSON data
paths. This is the contract a federating hub reads from any export-site origin;
it is emitted regardless of whether the instance ships a PWA (a dumb instance
is still federatable).

- common/lib/siteDescriptor.ts: PublicSiteDescriptor + buildSiteDescriptor
- common/lib/manifest.ts: ChannelEntry.slug (collision-free channel identity)
- common/controller/buildIndex.ts: populate channel slug in summaries manifest
- common/lib/site.ts: Site.pwa + Site.hubUrl fields, resolveHubUrl (per-site
  override of the existing family homepageUrl default)
- common/bin/compose-site.ts: emit /site.json + /_headers
- export/serve.json: CORS on JSON for local/e2e federation peers

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

Diffstat:
Mcommon/bin/compose-site.ts | 54+++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcommon/controller/buildIndex.ts | 4++--
Mcommon/lib/manifest.ts | 5+++++
Mcommon/lib/site.ts | 24++++++++++++++++++++++++
Acommon/lib/siteDescriptor.ts | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/serve.json | 4++++
6 files changed, 185 insertions(+), 3 deletions(-)

diff --git a/common/bin/compose-site.ts b/common/bin/compose-site.ts @@ -16,12 +16,61 @@ import path from "node:path"; import { cp, mkdir, rm, readdir, access, readFile, writeFile } from "node:fs/promises"; import { getPaths } from "../lib/paths"; -import { getSite } from "../lib/site"; +import { getSite, resolveSocialLinks, resolveHubUrl, type Site } from "../lib/site"; import { DUPLICATES_FILENAME, filterClusterToChannels, type DuplicateReport, } from "../lib/duplicates"; +import type { Manifest } from "../lib/manifest"; +import { buildSiteDescriptor } from "../lib/siteDescriptor"; + +// CORS + cache headers for Cloudflare Pages (a static `_headers` file at the +// deploy root). All served data is public static JSON with no credentials, so +// `Access-Control-Allow-Origin: *` is correct and lets a federating hub on any +// origin read it. Default GETs with no custom headers are CORS-simple → no +// preflight needed. `output: "export"` can't emit these via next.config, and +// `serve`/`next dev` ignore this file (local/e2e CORS lives in serve.json). +const CORS_HEADERS = `# Generated by compose-site.ts — do not edit by hand. +/site.json + Access-Control-Allow-Origin: * +/summaries/* + Access-Control-Allow-Origin: * +/subs/* + Access-Control-Allow-Origin: * +/transcripts/* + Access-Control-Allow-Origin: * +/stats/* + Access-Control-Allow-Origin: * +`; + +// Emit the public federation contract: /site.json (branding + channels + +// freshness) and the CORS _headers file. Emitted for EVERY site regardless of +// whether it ships a PWA — a dumb instance is still federatable. +async function emitFederationFiles( + site: Site, + paths: ReturnType<typeof getPaths>, +): Promise<void> { + await writeFile(path.join(paths.exportPublicDir, "_headers"), CORS_HEADERS); + + const manifestPath = path.join(paths.exportSummariesDir, "manifest.json"); + if (!(await exists(manifestPath))) return; // no composed data → no descriptor + const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as Manifest; + const descriptor = buildSiteDescriptor(site, manifest, resolveSocialLinks(site), { + pwa: shipsPwa(), + hubUrl: resolveHubUrl(site), + }); + await writeFile( + path.join(paths.exportPublicDir, "site.json"), + JSON.stringify(descriptor), + ); +} + +// Whether this build ships an installable PWA. Site builds default to a dumb +// instance unless SHIP_PWA is set (see export/app/lib/mode.ts shipsPwa()). +function shipsPwa(): boolean { + return process.env.SHIP_PWA === "1" || process.env.INSTANCE_MODE === "hub"; +} async function exists(p: string): Promise<boolean> { try { @@ -130,6 +179,9 @@ async function main(): Promise<void> { await writeFile(dupDest, JSON.stringify(filtered)); } + // --- federation contract: /site.json descriptor + CORS _headers --- + await emitFederationFiles(site, paths); + const channelDirs = (await readdir(paths.exportTranscriptsDir).catch( () => [] as string[], )).length; diff --git a/common/controller/buildIndex.ts b/common/controller/buildIndex.ts @@ -1132,8 +1132,8 @@ export async function buildIndex({ } } - const channelList: ChannelEntry[] = Array.from(chan.values()) - .map(({ name, count, groupId }) => ({ name, count, groupId })) + const channelList: ChannelEntry[] = Array.from(chan.entries()) + .map(([slug, { name, count, groupId }]) => ({ slug, name, count, groupId })) .sort((a, b) => a.name.localeCompare(b.name)); const summariesManifest: Manifest = { diff --git a/common/lib/manifest.ts b/common/lib/manifest.ts @@ -3,6 +3,11 @@ import type { ChannelGroup } from "./channelGroups"; export type ChannelEntry = { name: string; count: number; + // Channel slug (directory name under transcripts/channels/). Optional for + // backwards compat with older manifests. Carried so a federating hub can key + // channels by (origin, slug) without collisions — display names collide + // across sites, slugs don't. + slug?: string; // Group this channel belongs to (resolved against SiteSettings.groups at // index-build time). Optional for backwards compat with older manifests: // a missing groupId implies the default group. diff --git a/common/lib/site.ts b/common/lib/site.ts @@ -70,6 +70,17 @@ export type Site = { // sibling. siteIds are resolved against the live pool at render time, so an // id for a site that doesn't exist (yet) is harmless — it's just skipped. relatedSites?: RelatedSiteGroup[]; + // Whether this site ships an installable PWA (service worker + web manifest). + // Default (undefined/false) = a "dumb instance": it serves the CORS-enabled + // JSON federation contract but is not independently installable, so a visitor + // trusts only the hub PWA. Set true to make this site its own installable PWA. + // See export/app/lib/mode.ts (shipsPwa) and common/lib/siteDescriptor.ts. + pwa?: boolean; + // Per-site override for the hub this site belongs under (the PWA it points + // visitors toward). Absent = inherit the family default SiteSettings.homepageUrl. + // Resolve with resolveHubUrl(). Surfaced on /site.json so a hub can tell member + // sites (that name it) from arbitrary added origins. + hubUrl?: string; }; export type RelatedSiteGroup = { @@ -234,9 +245,20 @@ export function parseSite(siteId: string, raw: unknown): Site { accent: parseAccent(r.accent), siteUrl: parseSiteUrl(r.siteUrl), relatedSites: parseRelatedSites(r.relatedSites), + pwa: r.pwa === true, + hubUrl: parseSiteUrl(r.hubUrl), }; } +// The hub URL this site points visitors toward: its own override, else the +// family default (SiteSettings.homepageUrl). Undefined when neither is set. +export function resolveHubUrl( + site: Site, + settings: SiteSettings = getSettings(), +): string | undefined { + return site.hubUrl ?? parseSiteUrl(settings.homepageUrl); +} + // The set of channel slugs a site exposes. Used to scope the editor's // site-specific views (dashboard, channels) down to one site's membership. export function siteChannelSlugs(site: Site): Set<string> { @@ -385,6 +407,8 @@ export async function writeSite( ...(parseRelatedSites(site.relatedSites).length > 0 ? { relatedSites: parseRelatedSites(site.relatedSites) } : {}), + ...(site.pwa ? { pwa: true } : {}), + ...(parseSiteUrl(site.hubUrl) ? { hubUrl: parseSiteUrl(site.hubUrl) } : {}), }; const dir = siteDir(paths, site.siteId); await fs.promises.mkdir(dir, { recursive: true }); diff --git a/common/lib/siteDescriptor.ts b/common/lib/siteDescriptor.ts @@ -0,0 +1,97 @@ +import type { ChannelGroup } from "./channelGroups"; +import type { Manifest } from "./manifest"; +import type { Site } from "./site"; +import type { SocialLink } from "./settings"; + +// The public site descriptor served at `/site.json` on every export bundle. +// +// This is the federation contract: a hub PWA reads this from any export-site +// origin (same-origin for the built-in pool, CORS cross-origin for user-added +// externals) to learn a site's branding, channels, and freshness WITHOUT a +// second roundtrip. It is emitted regardless of whether the instance ships a +// PWA — a "dumb instance" (no service worker / not installable) is still fully +// federatable through this file. +// +// Deliberately excludes operational/internal config (cloudflareProject, +// relatedSites): only public presentation fields belong here. + +export const SITE_DESCRIPTOR_VERSION = 1; + +// One channel a site exposes, with the slug that gives it a collision-free +// identity across sites (display names collide, slugs don't). +export type PublicChannel = { + slug: string; + name: string; + count: number; + groupId?: string; +}; + +export type PublicSiteDescriptor = { + // Contract version — a hub rejects a descriptor whose contract it doesn't + // understand. Bump when the shape changes incompatibly. + contract: number; + siteId: string; + siteTitle: string; + siteDescription: string; + headerTitle: string; + homeTagline: string; + accent?: string; + // Absolute public URL of this deployment (self-reference), when configured. + siteUrl?: string; + // The intended hub parent this site belongs under (resolved: site override + // ?? family default). Lets a standalone site link to its hub and lets a hub + // tell member sites (that name it) from arbitrary added origins. + hubUrl?: string; + // Whether this instance ships an installable PWA (service worker + manifest). + // A hub can badge federated sites that are independently installable. + pwa: boolean; + // Resolved social links (site override ?? global default). + socialLinks: SocialLink[]; + groups: ChannelGroup[]; + defaultGroupId: string; + channels: PublicChannel[]; + // Mirror of the summaries manifest's generatedAt — the freshness/staleness + // signal a hub uses to invalidate cached cross-origin shards. + generatedAt: string; + // The summaries MANIFEST_VERSION at build time, so a hub can detect a shard + // shape it doesn't understand. + summariesVersion: number; +}; + +// Build the public descriptor from a Site plus the site's just-composed +// summaries manifest (source of channels/groups/generatedAt) and the resolved +// social links. `pwa`/`hubUrl` are resolved by the caller (they depend on +// build-time config + settings) and passed in. +export function buildSiteDescriptor( + site: Site, + manifest: Manifest, + socialLinks: SocialLink[], + opts: { pwa: boolean; hubUrl?: string }, +): PublicSiteDescriptor { + const channels: PublicChannel[] = manifest.channels + .filter((c): c is typeof c & { slug: string } => typeof c.slug === "string") + .map((c) => ({ + slug: c.slug, + name: c.name, + count: c.count, + ...(c.groupId ? { groupId: c.groupId } : {}), + })); + return { + contract: SITE_DESCRIPTOR_VERSION, + siteId: site.siteId, + siteTitle: site.siteTitle, + siteDescription: site.siteDescription, + headerTitle: site.headerTitle, + homeTagline: site.homeTagline, + ...(site.accent ? { accent: site.accent } : {}), + ...(site.siteUrl ? { siteUrl: site.siteUrl } : {}), + ...(opts.hubUrl ? { hubUrl: opts.hubUrl } : {}), + pwa: opts.pwa, + socialLinks, + groups: site.groups, + defaultGroupId: site.defaultGroupId, + channels, + generatedAt: manifest.generatedAt, + summariesVersion: manifest.version, + }; +} diff --git a/export/serve.json b/export/serve.json @@ -6,6 +6,10 @@ { "key": "Cache-Control", "value": "public, max-age=3600" + }, + { + "key": "Access-Control-Allow-Origin", + "value": "*" } ] }