Skip to content
Open
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
7 changes: 7 additions & 0 deletions src/presets/cloudflare/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,13 @@ export interface CloudflareOptions {

type DurableObjectState = ConstructorParameters<typeof DurableObject>[0];

export type CloudflareHeaderTreeNode = {
path: string;
wildcardHeaders: Record<string, string> | undefined;
headers: Record<string, string> | undefined;
children: Record<string, CloudflareHeaderTreeNode>;
};

declare module "nitropack/types" {
export interface NitroRuntimeHooks {
// https://developers.cloudflare.com/workers/runtime-apis/handlers/scheduled/
Expand Down
135 changes: 117 additions & 18 deletions src/presets/cloudflare/utils.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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];
Comment on lines +130 to +134
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | πŸ”΄ Critical

Initialize required fields for child nodes.

CloudflareHeaderTreeNode requires wildcardHeaders and headers; omitting them will fail type-checking in strict TS setups.

πŸ› οΈ Proposed fix
       currentNode.children[part] = currentNode.children[part] || {
         path: (currentNode.path == "/" ? "" : currentNode.path) + "/" + part,
+        wildcardHeaders: undefined,
+        headers: undefined,
         children: {},
       };
πŸ“ Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
currentNode.children[part] = currentNode.children[part] || {
path: (currentNode.path == "/" ? "" : currentNode.path) + "/" + part,
children: {},
};
currentNode = currentNode.children[part];
currentNode.children[part] = currentNode.children[part] || {
path: (currentNode.path == "/" ? "" : currentNode.path) + "/" + part,
wildcardHeaders: undefined,
headers: undefined,
children: {},
};
currentNode = currentNode.children[part];
πŸ€– Prompt for AI Agents
In `@src/presets/cloudflare/utils.ts` around lines 130 - 134, When creating a new
child node in the header tree, initialize all required CloudflareHeaderTreeNode
fields: include wildcardHeaders and headers (both as empty arrays/objects as
appropriate) along with path and children; update the node creation at
currentNode.children[part] to set path, children, wildcardHeaders, and headers
so the new node conforms to CloudflareHeaderTreeNode and passes strict TS
type-checking.

}
if (pathParts.at(-1) === "**") {
currentNode.wildcardHeaders = routeRules.headers;
} else {
currentNode.headers = routeRules.headers;
}
}

return tree;
}
function processHeaderValues(
parentHeaders: Record<string, string>,
headers: Record<string, string>
) {
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<string, Record<string, string>>
) {
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"
Expand All @@ -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)) {
Expand Down
101 changes: 99 additions & 2 deletions test/presets/cloudflare-module.test.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -40,4 +97,44 @@ 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
x-build-header: works
/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"
`);
});
});