Skip to content
Closed
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
4 changes: 4 additions & 0 deletions src/presets/cloudflare/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,8 +281,12 @@ export async function writeWranglerConfig(nitro: Nitro, cfTarget: "pages" | "mod

// Compatibility flags
wranglerConfig.compatibility_flags ??= [];
// From compatibility date 2026-08-04 workerd enables nodejs_compat by
// default and rejects an explicit flag, so only add it when the resolved
// date still needs it (nitrojs/nitro#4527).
if (
nitro.options.cloudflare?.nodeCompat &&
(wranglerConfig.compatibility_date ?? "") < "2026-08-04" &&
!wranglerConfig.compatibility_flags.includes("nodejs_compat")
) {
wranglerConfig.compatibility_flags.push("nodejs_compat");
Expand Down
4 changes: 2 additions & 2 deletions src/runtime/internal/error/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { resolve, dirname } from "pathe";
import consola from "consola";
import type { ErrorParser } from "youch-core";
import type { SourceMapConsumer } from "source-map";
import { defineNitroErrorHandler } from "./utils.ts";
import { createErrorHeaders, defineNitroErrorHandler } from "./utils.ts";
import type { InternalHandlerResponse } from "./utils.ts";
import { FastResponse } from "srvx";
import type { NitroErrorHandler } from "nitro/types";
Expand Down Expand Up @@ -61,7 +61,7 @@ export async function defaultHandler(
// Use HTML response only when user-agent expects it (browsers)
const useJSON = opts?.json ?? !event.req.headers.get("accept")?.includes("text/html");

const headers = new Headers(unhandled ? {} : error.headers);
const headers = createErrorHeaders(event, unhandled ? undefined : error.headers);

if (useJSON) {
headers.set("Content-Type", "application/json; charset=utf-8");
Expand Down
4 changes: 2 additions & 2 deletions src/runtime/internal/error/prod.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { HTTPError, type H3Event, type HTTPEvent } from "h3";
import type { InternalHandlerResponse } from "./utils.ts";
import { createErrorHeaders, type InternalHandlerResponse } from "./utils.ts";
import { FastResponse } from "srvx";
import type { NitroErrorHandler } from "nitro/types";

Expand Down Expand Up @@ -28,7 +28,7 @@ export function defaultHandler(error: HTTPError, event: HTTPEvent): InternalHand
}
}

const headers = new Headers(unhandled ? {} : error.headers);
const headers = createErrorHeaders(event, unhandled ? undefined : error.headers);
headers.set("content-type", "application/json; charset=utf-8");

const jsonBody = unhandled
Expand Down
17 changes: 17 additions & 0 deletions src/runtime/internal/error/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { H3Event, HTTPEvent } from "h3";
import type { NitroErrorHandler } from "nitro/types";

export function defineNitroErrorHandler(handler: NitroErrorHandler): NitroErrorHandler {
Expand All @@ -10,3 +11,19 @@ export type InternalHandlerResponse = {
headers?: HeadersInit;
body?: string | Record<string, any>;
};

// h3 does not merge `event.res.errHeaders` into responses returned by `onError`
export function createErrorHeaders(event: HTTPEvent, errorHeaders?: HeadersInit): Headers {
const headers = new Headers(errorHeaders);
const errHeaders = (event as H3Event).res?.errHeaders;
if (errHeaders) {
for (const [name, value] of errHeaders) {
if (name === "set-cookie") {
headers.append(name, value);
} else {
headers.set(name, value);
}
}
}
return headers;
}
16 changes: 16 additions & 0 deletions test/fixture/server/routes/errors/staged-headers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { defineHandler, handleCors, HTTPError } from "nitro/h3";

export default defineHandler((event) => {
handleCors(event, { origin: "*" });
event.res.headers.set("x-success-only", "true");
event.res.errHeaders.set("x-error-precedence", "staged");
event.res.errHeaders.append("set-cookie", "staged=1; Path=/");
throw new HTTPError({
status: 401,
message: "unauthorized",
headers: {
"x-error-precedence": "error",
"set-cookie": "error=1; Path=/",
},
});
});
12 changes: 12 additions & 0 deletions test/tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,18 @@ export function testNitro(
expect(data.stack).toMatch("test/fixture/server/routes/errors/stack.ts");
});

it("error responses keep error headers staged on the event", async () => {
const res = await callHandler({ url: "/errors/staged-headers" });
expect(res.status).toBe(401);
expect(res.headers["content-type"]).toBe("application/json; charset=utf-8");
expect(res.headers["access-control-allow-origin"]).toBe("*");
expect(res.headers["x-test"]).toBe("test");
expect(res.headers["x-success-only"]).toBeUndefined();
expect(res.headers["x-error-precedence"]).toBe("staged");
expect(res.headers["set-cookie"]).toEqual(["error=1; Path=/", "staged=1; Path=/"]);
expect(res.data).toMatchObject({ message: "unauthorized" });
});

for (const errorAction of ["throw", "return"]) {
it(`handled errors (${errorAction})`, async () => {
const res = await callHandler({ url: `/errors/throw?handled&action=${errorAction}` });
Expand Down
25 changes: 23 additions & 2 deletions test/unit/cloudflare.utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import { join } from "pathe";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { writeWranglerConfig } from "../../src/presets/cloudflare/utils.ts";

function createNitroStub(overrides: { static?: boolean } = {}) {
function createNitroStub(overrides: { static?: boolean; compatibilityDate?: string } = {}) {
const root = mkdtempSync(join(tmpdir(), "nitro-cf-wrangler-"));
const date = overrides.compatibilityDate ?? "2025-10-24";
return {
root,
nitro: {
Expand All @@ -19,7 +20,7 @@ function createNitroStub(overrides: { static?: boolean } = {}) {
serverDir: join(root, ".output/server"),
publicDir: join(root, ".output/public"),
},
compatibilityDate: { cloudflare: "2025-10-24", default: "2025-10-24" },
compatibilityDate: { cloudflare: date, default: date },
cloudflare: { deployConfig: true, nodeCompat: true },
experimental: {},
scheduledTasks: {},
Expand Down Expand Up @@ -69,4 +70,24 @@ describe("writeWranglerConfig (cloudflare-module)", () => {
expect(config.no_bundle).toBeUndefined();
expect(config.rules).toBeUndefined();
});

it("adds nodejs_compat while the flag still needs to be explicit", async () => {
const { root, nitro } = createNitroStub({ compatibilityDate: "2026-08-03" });
cleanup.push(root);
await writeWranglerConfig(nitro, "module");
const config = JSON.parse(readFileSync(join(root, ".output/server/wrangler.json"), "utf8"));
expect(config.compatibility_flags).toContain("nodejs_compat");
});

it("omits nodejs_compat once workerd enables it by default", async () => {
// Regression test for nitrojs/nitro#4527 — from compatibility date
// 2026-08-04 workerd enables nodejs_compat by default and rejects an
// explicit flag as a hard error, so the generated config must omit it.
const { root, nitro } = createNitroStub({ compatibilityDate: "2026-08-04" });
cleanup.push(root);
await writeWranglerConfig(nitro, "module");
const config = JSON.parse(readFileSync(join(root, ".output/server/wrangler.json"), "utf8"));
expect(config.compatibility_date).toBe("2026-08-04");
expect(config.compatibility_flags ?? []).not.toContain("nodejs_compat");
});
});
44 changes: 44 additions & 0 deletions test/unit/error-headers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { mockEvent } from "h3";
import { createErrorHeaders } from "../../src/runtime/internal/error/utils.ts";

describe("createErrorHeaders", () => {
it("preserves staged error precedence without merging success headers", () => {
const event = mockEvent("/");
event.res.headers.set("x-success-only", "success");
event.res.errHeaders.set("x-shared", "staged");
event.res.errHeaders.set("x-staged-only", "staged");
const errorHeaders = new Headers({ "x-shared": "error", "x-error-only": "error" });

const headers = createErrorHeaders(event, errorHeaders);

expect(headers.get("x-shared")).toBe("staged");
expect(headers.get("x-staged-only")).toBe("staged");
expect(headers.get("x-error-only")).toBe("error");
expect(headers.has("x-success-only")).toBe(false);
expect(errorHeaders.get("x-shared")).toBe("error");
});

it("appends cookies from both sources without combining them", () => {
const event = mockEvent("/");
event.res.errHeaders.append("set-cookie", "staged=1; Path=/");
const errorHeaders = new Headers();
errorHeaders.append("set-cookie", "first=1; Path=/");
errorHeaders.append("set-cookie", "second=2; Path=/");

expect(createErrorHeaders(event, errorHeaders).getSetCookie()).toEqual([
"first=1; Path=/",
"second=2; Path=/",
"staged=1; Path=/",
]);
expect(errorHeaders.getSetCookie()).toHaveLength(2);
expect(event.res.errHeaders.getSetCookie()).toEqual(["staged=1; Path=/"]);
});

it("preserves staged headers without explicit error headers", () => {
const event = mockEvent("/");
event.res.errHeaders.set("x-staged", "value");

expect(createErrorHeaders(event).get("x-staged")).toBe("value");
});
});