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:
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": "*"
}
]
}