From 1f3d56ca4a9a5cf40d711af87290920022144dfb Mon Sep 17 00:00:00 2001 From: Bruno Perez Date: Tue, 20 Oct 2026 08:45:03 +0200 Subject: [PATCH] chore: serve static files from Cloudflare assets without the Worker run_worker_first: true counted every JSON API request against the Workers free-plan quota (58k/day). Only the dynamic endpoints and the clean-URL redirects now run the Worker; the API headers move to a generated _headers file, API misses get a JSON 404 from api/v1/404.html, and the alias-host redirects are zone Redirect Rules. --- src/build/build.ts | 9 +++++++++ src/data/headers.ts | 34 ++++++++++++++++++++++++++++++++++ tests/headers.test.ts | 22 ++++++++++++++++++++++ wrangler.jsonc | 8 +++++--- 4 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 src/data/headers.ts create mode 100644 tests/headers.test.ts diff --git a/src/build/build.ts b/src/build/build.ts index bc893a96..1389ad58 100644 --- a/src/build/build.ts +++ b/src/build/build.ts @@ -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, @@ -66,6 +67,14 @@ async function writeLlmsFiles(models: Model[]): Promise { async function writeRobotsAndSitemap(models: Model[]): Promise { 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). diff --git a/src/data/headers.ts b/src/data/headers.ts new file mode 100644 index 00000000..fb5067cb --- /dev/null +++ b/src/data/headers.ts @@ -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]> = [ + [ + "/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] => [ + 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"); +} diff --git a/tests/headers.test.ts b/tests/headers.test.ts new file mode 100644 index 00000000..234031e4 --- /dev/null +++ b/tests/headers.test.ts @@ -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`); + }); +}); diff --git a/wrangler.jsonc b/wrangler.jsonc index 40a72c65..af1af9de 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -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.