From 9b618244b8f49cd94e23b2a905a2c3540ee3c0e8 Mon Sep 17 00:00:00 2001 From: Geoff Appleby Date: Sat, 10 Jan 2026 20:51:35 -0800 Subject: [PATCH 1/2] Filter inherited values from Cloudflare preset _headers file --- src/presets/cloudflare/types.ts | 7 ++ src/presets/cloudflare/utils.ts | 135 +++++++++++++++++++++---- test/presets/cloudflare-module.test.ts | 100 +++++++++++++++++- 3 files changed, 222 insertions(+), 20 deletions(-) diff --git a/src/presets/cloudflare/types.ts b/src/presets/cloudflare/types.ts index 7f9d7ee451..000dd48a87 100644 --- a/src/presets/cloudflare/types.ts +++ b/src/presets/cloudflare/types.ts @@ -96,6 +96,13 @@ export interface CloudflareOptions { type DurableObjectState = ConstructorParameters[0]; +export type CloudflareHeaderTreeNode = { + path: string; + wildcardHeaders: Record | undefined; + headers: Record | undefined; + children: Record; +}; + declare module "nitropack/types" { export interface NitroRuntimeHooks { // https://developers.cloudflare.com/workers/runtime-apis/handlers/scheduled/ diff --git a/src/presets/cloudflare/utils.ts b/src/presets/cloudflare/utils.ts index b78c89d582..efd0d72699 100644 --- a/src/presets/cloudflare/utils.ts +++ b/src/presets/cloudflare/utils.ts @@ -1,6 +1,10 @@ -import type { Nitro } from "nitropack/types"; +import type { Nitro, NitroRouteRules } from "nitropack/types"; import type { Plugin } from "rollup"; -import type { WranglerConfig, CloudflarePagesRoutes } from "./types"; +import type { + WranglerConfig, + CloudflarePagesRoutes, + CloudflareHeaderTreeNode, +} from "./types"; import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { relative, dirname, extname } from "node:path"; @@ -105,6 +109,114 @@ function comparePaths(a: string, b: string) { return a.split("/").length - b.split("/").length || a.localeCompare(b); } +function buildHeaderTree(rules: { + [p: string]: NitroRouteRules; +}): CloudflareHeaderTreeNode { + const tree: CloudflareHeaderTreeNode = { + path: "/", + wildcardHeaders: undefined, + headers: undefined, + children: {}, + }; + + for (const [path, routeRules] of Object.entries(rules).filter( + ([_, routeRules]) => routeRules.headers + )) { + const pathParts = path == "/" ? [] : path.slice(1).split("/"); + let currentNode = tree; + for (const part of pathParts) { + if (part === "**") break; + + currentNode.children[part] = currentNode.children[part] || { + path: (currentNode.path == "/" ? "" : currentNode.path) + "/" + part, + children: {}, + }; + currentNode = currentNode.children[part]; + } + if (pathParts.at(-1) === "**") { + currentNode.wildcardHeaders = routeRules.headers; + } else { + currentNode.headers = routeRules.headers; + } + } + + return tree; +} +function processHeaderValues( + parentHeaders: Record, + headers: Record +) { + return Object.entries(headers) + .filter( + ([header, value]) => + !parentHeaders[header.toLowerCase()] || + parentHeaders[header.toLowerCase()] !== value + ) + .map(([header, value]) => { + const values = [` ${header}: ${value}`]; + if (parentHeaders[header.toLowerCase()]) { + values.unshift(` ! ${header}`); + } + return values.join("\n"); + }); +} +function processHeaderTree( + baseURL: string, + tree: CloudflareHeaderTreeNode, + parentHeaders?: Record> +) { + parentHeaders = parentHeaders || { "/": {} }; + let contents: string[] = []; + + let closestWildcard = tree.path; + do { + closestWildcard = closestWildcard.split("/").slice(0, -1).join("/") || "/"; + } while (closestWildcard !== "/" && !parentHeaders[closestWildcard]); + + if (tree.wildcardHeaders) { + const path = joinURL(baseURL, (tree.path == "/" ? "" : tree.path) + "/*"); + const headers = processHeaderValues( + parentHeaders[closestWildcard], + tree.wildcardHeaders + ); + + if (headers.length > 0) { + contents.push([path, ...headers].join("\n")); + + parentHeaders[tree.path] = { + ...parentHeaders[closestWildcard], + ...Object.fromEntries( + Object.entries(tree.wildcardHeaders).map(([header, value]) => [ + header.toLowerCase(), + value, + ]) + ), + }; + } + } + + if (tree.headers) { + const headers = processHeaderValues( + parentHeaders[closestWildcard], + tree.headers + ); + if (headers.length > 0) { + contents.push([joinURL(baseURL, tree.path), ...headers].join("\n")); + } + } + + for (const [_, child] of Object.entries(tree.children).sort((a, b) => + a[0].localeCompare(b[0]) + )) { + contents = [ + ...contents, + ...processHeaderTree(baseURL, child, parentHeaders), + ]; + } + + return contents; +} + export async function writeCFHeaders( nitro: Nitro, outdir: "public" | "output" @@ -115,25 +227,12 @@ export async function writeCFHeaders( : nitro.options.output.dir, "_headers" ); - const contents = []; - const rules = Object.entries(nitro.options.routeRules).sort( - (a, b) => b[0].split(/\/(?!\*)/).length - a[0].split(/\/(?!\*)/).length + const contents = processHeaderTree( + nitro.options.baseURL, + buildHeaderTree(nitro.options.routeRules) ); - for (const [path, routeRules] of rules.filter( - ([_, routeRules]) => routeRules.headers - )) { - const headers = [ - joinURL(nitro.options.baseURL, path.replace("/**", "/*")), - ...Object.entries({ ...routeRules.headers }).map( - ([header, value]) => ` ${header}: ${value}` - ), - ].join("\n"); - - contents.push(headers); - } - if (existsSync(headersPath)) { const currentHeaders = await readFile(headersPath, "utf8"); if (/^\/\* /m.test(currentHeaders)) { diff --git a/test/presets/cloudflare-module.test.ts b/test/presets/cloudflare-module.test.ts index 5961ed6754..7851eca89e 100644 --- a/test/presets/cloudflare-module.test.ts +++ b/test/presets/cloudflare-module.test.ts @@ -1,12 +1,69 @@ import { Miniflare } from "miniflare"; import { resolve } from "pathe"; import { Response as _Response } from "undici"; -import { describe } from "vitest"; +import { describe, expect, it } from "vitest"; import { setupTest, testNitro } from "../tests"; +import { promises as fsp } from "node:fs"; describe("nitro:preset:cloudflare-module", async () => { - const ctx = await setupTest("cloudflare-module"); + const ctx = await setupTest("cloudflare-module", { + config: { + routeRules: { + "/": { + headers: { + "x-test": "test", + "x-cf-test-root": "test", + }, + }, + "/cf/_header/**": { + headers: { + "x-cf-test": "0", + }, + }, + "/cf/_header/0": { + headers: { + "x-cf-test": "0", + }, + }, + "/cf/_header/a": { + headers: { + "x-cf-test": "a", + }, + }, + "/cf/_header/B": { + headers: { + "X-CF-Test": "0", + }, + }, + "/cf/_header/1/**": { + headers: { + "x-cf-test": "1", + }, + }, + "/cf/_header/1/2/**": { + headers: { + "x-cf-test": "2", + }, + }, + "/cf/_header/1/2/1": { + headers: { + "x-cf-test": "1", + }, + }, + "/cf/_header/1/2/2": { + headers: { + "x-cf-test": "2", + }, + }, + "/cf/_header/2/2/0": { + headers: { + "x-cf-test": "0", + }, + }, + }, + }, + }); testNitro(ctx, () => { const mf = new Miniflare({ @@ -40,4 +97,43 @@ describe("nitro:preset:cloudflare-module", async () => { return res as unknown as Response; }; }); + + it("should generate a _headers file", async () => { + const config = await fsp.readFile( + resolve( + ctx.nitro?.options.output.publicDir || ctx.outDir + "/public", + "_headers" + ), + "utf8" + ); + expect(config).toMatchInlineSnapshot(` + "/* + x-test: test + / + x-cf-test-root: test + /build/* + cache-control: public, max-age=3600, immutable + /cf/_header/* + x-cf-test: 0 + /cf/_header/1/* + ! x-cf-test + x-cf-test: 1 + /cf/_header/1/2/* + ! x-cf-test + x-cf-test: 2 + /cf/_header/1/2/1 + ! x-cf-test + x-cf-test: 1 + /cf/_header/a + ! x-cf-test + x-cf-test: a + /rules/cors + access-control-allow-origin: * + access-control-allow-methods: GET + access-control-allow-headers: * + access-control-max-age: 0 + /rules/headers + cache-control: s-maxage=60" + `); + }); }); From ff077305a27483233985b207761bf6f507fb9091 Mon Sep 17 00:00:00 2001 From: Geoff Appleby Date: Mon, 12 Jan 2026 16:30:36 -0800 Subject: [PATCH 2/2] Update test expectation --- test/presets/cloudflare-module.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/presets/cloudflare-module.test.ts b/test/presets/cloudflare-module.test.ts index 7851eca89e..038f37fbee 100644 --- a/test/presets/cloudflare-module.test.ts +++ b/test/presets/cloudflare-module.test.ts @@ -113,6 +113,7 @@ describe("nitro:preset:cloudflare-module", async () => { x-cf-test-root: test /build/* cache-control: public, max-age=3600, immutable + x-build-header: works /cf/_header/* x-cf-test: 0 /cf/_header/1/*