Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/build/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from "../data/paths.js";
import { SITE_URL } from "../data/site.js";
import { buildRobotsTxt } from "../data/robots.js";
import { buildHeadersFile } from "../data/headers.js";
import {
API_PATH,
DISAMBIGUATION_PATH,
Expand Down Expand Up @@ -66,6 +67,14 @@ async function writeLlmsFiles(models: Model[]): Promise<void> {

async function writeRobotsAndSitemap(models: Model[]): Promise<void> {
await fs.writeFile(path.join(DIST_DIR, "robots.txt"), buildRobotsTxt(SITE_URL), "utf8");
await fs.writeFile(path.join(DIST_DIR, "_headers"), buildHeadersFile(), "utf8");
// The asset layer answers a miss with the nearest 404.html, so API callers
// get a JSON body (typed by _headers) instead of the HTML not-found page.
await fs.writeFile(
path.join(DIST_API_DIR, "404.html"),
JSON.stringify({ error: "not_found" }) + "\n",
"utf8",
);

// Sitemaps list canonical, indexable HTML pages only — the JSON API and the
// .txt agent files are intentionally excluded (they're not search results).
Expand Down
34 changes: 34 additions & 0 deletions src/data/headers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Cloudflare `_headers` builder. Static files are served straight from the
// asset layer without invoking the Worker (Worker requests count against the
// free plan's daily quota; asset requests do not), so the headers vercel.json
// set on the JSON API and llms*.txt are declared here instead.

const CATALOG_CACHE = "public, max-age=300, s-maxage=3600";

const RULES: Array<[path: string, headers: Record<string, string>]> = [
[
"/api/v1/*",
{
"Content-Type": "application/json; charset=utf-8",
"Access-Control-Allow-Origin": "*",
"Cache-Control": CATALOG_CACHE,
"X-Robots-Tag": "noindex",
},
],
...["/llms.txt", "/llms-full.txt"].map((path): [string, Record<string, string>] => [
path,
{
"Access-Control-Allow-Origin": "*",
"Content-Type": "text/plain; charset=utf-8",
"Cache-Control": CATALOG_CACHE,
},
]),
];

export function buildHeadersFile(): string {
return RULES.map(
([path, headers]) =>
[path, ...Object.entries(headers).map(([key, value]) => ` ${key}: ${value}`)].join("\n") +
"\n",
).join("\n");
}
22 changes: 22 additions & 0 deletions tests/headers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { buildHeadersFile } from "../src/data/headers.js";

describe("buildHeadersFile", () => {
const file = buildHeadersFile();

it("types and caches the static JSON API", () => {
expect(file).toContain(
[
"/api/v1/*",
" Content-Type: application/json; charset=utf-8",
" Access-Control-Allow-Origin: *",
" Cache-Control: public, max-age=300, s-maxage=3600",
" X-Robots-Tag: noindex",
].join("\n"),
);
});

it.each(["/llms.txt", "/llms-full.txt"])("serves %s as plain text with CORS", (path) => {
expect(file).toContain(`${path}\n Access-Control-Allow-Origin: *\n Content-Type: text/plain`);
});
});
8 changes: 5 additions & 3 deletions wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
"assets": {
"directory": "./dist",
"binding": "ASSETS",
// The Worker runs first: it redirects the alias hosts, answers /mcp and
// /api/v1/validate, and adds the headers vercel.json used to set.
"run_worker_first": true,
// Only the dynamic endpoints and the clean-URL redirects (307 → 308) run
// the Worker. Every other file is served straight from assets, which does
// not count against the Workers request quota; its headers come from
// dist/_headers and the alias-host redirects are zone Redirect Rules.
"run_worker_first": ["/mcp", "/api/mcp", "/api/v1/validate", "/*.html", "/*/"],
// Same URLs as Vercel's cleanUrls + trailingSlash: false.
"html_handling": "drop-trailing-slash",
// Unknown paths get the branded dist/404.html with a 404 status.
Expand Down
Loading