commit eeb9e73b84f04d69ecd8c9c4400b9c81cd629c60
parent b0f8d2b65d196f89dbff133c639c811bb68b0812
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 12 Sep 2026 03:00:43 -0400
common: one generator for the Cloudflare `_headers` file
compose-site.ts and compose-hub.ts each hand-wrote a `_headers` template
literal — the same "path line, two-space-indented header line" format typed
out twice, once per script, 12 and 9 entries deep. They had already drifted
(the hub's list is missing /posts/*, /archives/*, /search-aliases.json and
/sitemap.xml), which is what two copies of a format always do.
lib/archive/headers.ts is now the one renderer. Each compose step supplies
the surface it serves: SITE_CORS_PATHS or HUB_CORS_PATHS. The hub's list
stays a subset on purpose — it holds no shard data of its own, so its
shard-tree entries are vestigial and adding more would be noise.
No byte moves in this commit. Proven, not asserted:
compose-site over the FACTS.md fixture recipe → `_headers` diffs clean
against plans/tools/compose-fixture-one-youtube-channel/public/_headers,
and the whole composed dir is IDENTICAL modulo the build clock (16 files
under public/, 7 under index/).
compose-hub at ea2d3d0 and at this commit, over the same fixture site as
the pool member → `_headers` byte-identical, hub-sites.json identical,
corpus.json identical modulo generatedAt.
headers.test.ts snapshots both blocks in full, because `_headers` is a wire
artifact whose loss is invisible locally: `serve` blankets `**/*.json` with
CORS (export/serve.json), so only a real CDN deploy can tell.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
4 files changed, 177 insertions(+), 59 deletions(-)
diff --git a/common/bin/compose-hub.ts b/common/bin/compose-hub.ts
@@ -23,30 +23,7 @@ import {
renderRobotsTxt,
type HubMemberInput,
} from "../lib/corpus";
-
-// CORS for the hub's own JSON (hub-sites.json). The hub is primarily a reader,
-// but keeping its endpoints CORS-open lets a hub-of-hubs federate it too. Same
-// rationale as compose-site.ts.
-const CORS_HEADERS = `# Generated by compose-hub.ts — do not edit by hand.
-/hub-sites.json
- Access-Control-Allow-Origin: *
-/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: *
-/corpus.json
- Access-Control-Allow-Origin: *
-/llms.txt
- Access-Control-Allow-Origin: *
-/robots.txt
- Access-Control-Allow-Origin: *
-`;
+import { HUB_CORS_PATHS, renderHeadersFile } from "../lib/archive/headers";
async function exists(p: string): Promise<boolean> {
try {
@@ -108,7 +85,10 @@ async function main(): Promise<void> {
renderRobotsTxt({ siteUrl: hub.siteUrl }),
);
- await writeFile(path.join(publicDir, "_headers"), CORS_HEADERS);
+ await writeFile(
+ path.join(publicDir, "_headers"),
+ renderHeadersFile("compose-hub.ts", HUB_CORS_PATHS),
+ );
// The hub always ships a PWA. Copy the hub service worker into place. Until
// the dedicated hub SW lands (Phase 7), fall back to the site SW so the PWA
diff --git a/common/bin/compose-site.ts b/common/bin/compose-site.ts
@@ -33,6 +33,7 @@ import type { PostsManifest } from "../lib/posts";
import type { DigestsManifest } from "../lib/digests";
import { buildSiteDescriptor, type PublicSiteDescriptor } from "../lib/siteDescriptor";
import { shipsPwa } from "../lib/archive/contract";
+import { renderHeadersFile } from "../lib/archive/headers";
import { effectiveSiteAliases } from "../lib/aliasesStore";
import {
buildSiteCorpus,
@@ -53,39 +54,6 @@ import {
type ArchiveManifestEntry,
} from "../lib/archiveOptions";
-// 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: *
-/search-aliases.json
- Access-Control-Allow-Origin: *
-/summaries/*
- Access-Control-Allow-Origin: *
-/subs/*
- Access-Control-Allow-Origin: *
-/transcripts/*
- Access-Control-Allow-Origin: *
-/posts/*
- Access-Control-Allow-Origin: *
-/stats/*
- Access-Control-Allow-Origin: *
-/archives/*
- Access-Control-Allow-Origin: *
-/corpus.json
- Access-Control-Allow-Origin: *
-/llms.txt
- Access-Control-Allow-Origin: *
-/robots.txt
- Access-Control-Allow-Origin: *
-/sitemap.xml
- 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.
@@ -93,7 +61,10 @@ async function emitFederationFiles(
site: Site,
paths: ReturnType<typeof getPaths>,
): Promise<void> {
- await writeFile(path.join(paths.exportPublicDir, "_headers"), CORS_HEADERS);
+ await writeFile(
+ path.join(paths.exportPublicDir, "_headers"),
+ renderHeadersFile("compose-site.ts"),
+ );
const manifestPath = path.join(paths.exportSummariesDir, "manifest.json");
if (!(await exists(manifestPath))) return; // no composed data → no descriptor
diff --git a/common/lib/archive/headers.test.ts b/common/lib/archive/headers.test.ts
@@ -0,0 +1,92 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ HUB_CORS_PATHS,
+ SITE_CORS_PATHS,
+ renderHeadersFile,
+} from "./headers";
+
+// The whole generated site block, as a snapshot. `_headers` is a wire artifact:
+// a line lost here is a cross-origin reader that stops working on the CDN and
+// nowhere else (local `serve` blankets `**/*.json` with CORS). So the file is
+// asserted in full — an edit has to be deliberate enough to update this string.
+const SITE_HEADERS = `# Generated by compose-site.ts — do not edit by hand.
+/site.json
+ Access-Control-Allow-Origin: *
+/search-aliases.json
+ Access-Control-Allow-Origin: *
+/summaries/*
+ Access-Control-Allow-Origin: *
+/subs/*
+ Access-Control-Allow-Origin: *
+/transcripts/*
+ Access-Control-Allow-Origin: *
+/posts/*
+ Access-Control-Allow-Origin: *
+/stats/*
+ Access-Control-Allow-Origin: *
+/archives/*
+ Access-Control-Allow-Origin: *
+/corpus.json
+ Access-Control-Allow-Origin: *
+/llms.txt
+ Access-Control-Allow-Origin: *
+/robots.txt
+ Access-Control-Allow-Origin: *
+/sitemap.xml
+ Access-Control-Allow-Origin: *
+`;
+
+const HUB_HEADERS = `# Generated by compose-hub.ts — do not edit by hand.
+/hub-sites.json
+ Access-Control-Allow-Origin: *
+/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: *
+/corpus.json
+ Access-Control-Allow-Origin: *
+/llms.txt
+ Access-Control-Allow-Origin: *
+/robots.txt
+ Access-Control-Allow-Origin: *
+`;
+
+test("renderHeadersFile: the site block, in full", () => {
+ assert.equal(renderHeadersFile("compose-site.ts"), SITE_HEADERS);
+ assert.equal(
+ renderHeadersFile("compose-site.ts", SITE_CORS_PATHS),
+ SITE_HEADERS,
+ );
+});
+
+test("renderHeadersFile: the hub block, in full", () => {
+ assert.equal(renderHeadersFile("compose-hub.ts", HUB_CORS_PATHS), HUB_HEADERS);
+});
+
+test("the hub surface is the site surface plus its own pool file", () => {
+ for (const p of HUB_CORS_PATHS) {
+ if (p === "/hub-sites.json") continue;
+ assert.ok(
+ SITE_CORS_PATHS.includes(p),
+ `${p} is declared by the hub but not by a site`,
+ );
+ }
+});
+
+test("every rendered entry is one path line and one indented header", () => {
+ const lines = renderHeadersFile("x.ts").split("\n").slice(0, -1);
+ assert.equal(lines[0], "# Generated by x.ts — do not edit by hand.");
+ const body = lines.slice(1);
+ assert.equal(body.length, SITE_CORS_PATHS.length * 2);
+ for (let i = 0; i < body.length; i += 2) {
+ assert.ok(body[i].startsWith("/"));
+ assert.equal(body[i + 1], " Access-Control-Allow-Origin: *");
+ }
+});
diff --git a/common/lib/archive/headers.ts b/common/lib/archive/headers.ts
@@ -0,0 +1,75 @@
+// The Cloudflare Pages `_headers` file, generated from the contract.
+//
+// A deployed archive is static JSON on a CDN, so the only place CORS can be
+// declared is a `_headers` file at the deploy root. `output: "export"` cannot
+// emit it from next.config, so the compose steps write it — and until now they
+// wrote it TWICE, as two hand-maintained string literals (compose-site.ts and
+// compose-hub.ts) that had already drifted apart. This module is the one
+// renderer; each compose step supplies the surface it actually serves.
+//
+// Browser-safe like the rest of `lib/archive/`: pure string building, no
+// `node:*`.
+
+// The single header every served document gets. All served data is public
+// static JSON with no credentials, so `*` is correct and is what lets a
+// federating hub — or an MCP reader — on any origin read it. A default GET with
+// no custom headers is CORS-simple, so no preflight is involved.
+const CORS_HEADER = "Access-Control-Allow-Origin: *";
+
+// Everything a SITE build serves, in the order the file has always listed it.
+// The order is preserved deliberately: `_headers` is matched top-down by
+// Cloudflare, the file is diffed against a committed fixture, and reordering it
+// would be a wire change with no benefit.
+//
+// NOTE, for the commit that follows this one: this list is today's, verbatim,
+// and it is INCOMPLETE — /digests/* and /duplicates.json are composed into
+// every site's public dir and declared nowhere. That is a wire change and gets
+// its own commit.
+export const SITE_CORS_PATHS: readonly string[] = [
+ "/site.json",
+ "/search-aliases.json",
+ "/summaries/*",
+ "/subs/*",
+ "/transcripts/*",
+ "/posts/*",
+ "/stats/*",
+ "/archives/*",
+ "/corpus.json",
+ "/llms.txt",
+ "/robots.txt",
+ "/sitemap.xml",
+];
+
+// What a HUB build serves. The hub holds no shard data of its own — it reads
+// every member cross-origin at runtime — so this is a subset of the site
+// surface plus `/hub-sites.json`, the built-in trusted pool compose-hub writes.
+// The shard-tree entries it does list are vestigial but harmless: a header rule
+// for a path this origin never serves costs nothing, and a hub-of-hubs should
+// not have to care which of the two shapes it was pointed at. Nothing here
+// moves when the SITE list gains a tree — the hub serves no digest shards and
+// no duplicates report, so it declares neither.
+export const HUB_CORS_PATHS: readonly string[] = [
+ "/hub-sites.json",
+ "/site.json",
+ "/summaries/*",
+ "/subs/*",
+ "/transcripts/*",
+ "/stats/*",
+ "/corpus.json",
+ "/llms.txt",
+ "/robots.txt",
+];
+
+// Render a `_headers` file: a banner naming the generator, then one path /
+// indented-header pair per served surface. `generator` is the script name so a
+// reader of a deploy artifact knows what to edit instead of the file.
+export function renderHeadersFile(
+ generator: string,
+ paths: readonly string[] = SITE_CORS_PATHS,
+): string {
+ const out = [`# Generated by ${generator} — do not edit by hand.`];
+ for (const p of paths) {
+ out.push(p, ` ${CORS_HEADER}`);
+ }
+ return `${out.join("\n")}\n`;
+}