Archilyzer · Source

archilyzer

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

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:
Mcommon/bin/compose-hub.ts | 30+++++-------------------------
Mcommon/bin/compose-site.ts | 39+++++----------------------------------
Acommon/lib/archive/headers.test.ts | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/archive/headers.ts | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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`; +}