From 798b63a699b6c6b6b63632d818a661bf45f24668 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 30 Sep 2026 16:52:20 +1000 Subject: [PATCH 1/5] fix(cloudflare): warn only about conflicting asset overrides --- src/presets/cloudflare/utils.ts | 10 +++- test/unit/cloudflare-config.test.ts | 87 +++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 test/unit/cloudflare-config.test.ts diff --git a/src/presets/cloudflare/utils.ts b/src/presets/cloudflare/utils.ts index 47e8661e7b..2663540147 100644 --- a/src/presets/cloudflare/utils.ts +++ b/src/presets/cloudflare/utils.ts @@ -329,7 +329,15 @@ export async function writeWranglerConfig( // Validate and warn about overrides for (const key in overrides) { - if (key in userConfig || key in ctxConfig) { + const hasConflict = + key === "assets" + ? (["binding", "directory"] as const).some((field) => { + const value = + ctxConfig.assets?.[field] ?? userConfig.assets?.[field]; + return value !== undefined && value !== overrides.assets?.[field]; + }) + : key in userConfig || key in ctxConfig; + if (hasConflict) { nitro.logger.warn( `[cloudflare] Wrangler config \`${key}\`${key in ctxConfig ? "set by config or modules" : ""} is overridden and will be ignored.` ); diff --git a/test/unit/cloudflare-config.test.ts b/test/unit/cloudflare-config.test.ts new file mode 100644 index 0000000000..43a3b84a4d --- /dev/null +++ b/test/unit/cloudflare-config.test.ts @@ -0,0 +1,87 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { createNitro } from "nitropack/core"; +import { join } from "pathe"; +import { describe, expect, it, vi } from "vitest"; + +describe("Cloudflare asset configuration diagnostics", () => { + it.each([ + { + title: "routing policy", + fileAssets: { html_handling: "drop-trailing-slash" as const }, + contextAssets: {}, + warns: false, + }, + { + title: "matching generated values", + fileAssets: { binding: "ASSETS", directory: "../public" }, + contextAssets: {}, + warns: false, + }, + { + title: "conflicting file binding with context routing policy", + fileAssets: { binding: "CUSTOM" }, + contextAssets: { html_handling: "drop-trailing-slash" as const }, + warns: true, + }, + { + title: "context binding precedence", + fileAssets: { binding: "CUSTOM" }, + contextAssets: { binding: "ASSETS" }, + warns: false, + }, + { + title: "conflicting context binding", + fileAssets: {}, + contextAssets: { binding: "CUSTOM" }, + warns: true, + }, + { + title: "conflicting asset directory", + fileAssets: { directory: "../custom" }, + contextAssets: {}, + warns: true, + }, + ])( + "reports $title correctly", + async ({ fileAssets, contextAssets, warns }) => { + const rootDir = await mkdtemp( + join(tmpdir(), "nitro-assets-diagnostics-") + ); + await writeFile( + join(rootDir, "wrangler.json"), + JSON.stringify({ name: "test-worker", assets: fileAssets }) + ); + const nitro = await createNitro({ + rootDir, + preset: "cloudflare-module", + compatibilityDate: "2026-09-30", + cloudflare: { deployConfig: true, wrangler: { assets: contextAssets } }, + }); + const warn = vi.spyOn(nitro.logger, "warn"); + try { + await nitro.hooks.callHook("compiled", nitro); + const generated = JSON.parse( + await readFile( + join(nitro.options.output.serverDir, "wrangler.json"), + "utf8" + ) + ); + expect(generated.assets.binding).toBe("ASSETS"); + expect(generated.assets.directory).toBe("../public"); + expect( + warn.mock.calls.some(([message]) => + String(message).includes("Wrangler config `assets`") + ) + ).toBe(warns); + if ("html_handling" in contextAssets || "html_handling" in fileAssets) { + expect(generated.assets.html_handling).toBe("drop-trailing-slash"); + } + } finally { + warn.mockRestore(); + await nitro.close(); + await rm(rootDir, { recursive: true, force: true }); + } + } + ); +}); From a965ee5d0012270d77dbf6ba6d89d7f769a8f06d Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 30 Sep 2026 17:00:51 +1000 Subject: [PATCH 2/5] fix(cloudflare): compare asset directories across platforms --- src/presets/cloudflare/utils.ts | 10 ++++++++-- test/unit/cloudflare-config.test.ts | 12 ++++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/presets/cloudflare/utils.ts b/src/presets/cloudflare/utils.ts index 2663540147..4edf9f3bc0 100644 --- a/src/presets/cloudflare/utils.ts +++ b/src/presets/cloudflare/utils.ts @@ -10,7 +10,7 @@ import { readGitConfig, readPackageJSON, findNearestFile } from "pkg-types"; import { defu } from "defu"; import { globby } from "globby"; import { provider } from "std-env"; -import { join, resolve } from "pathe"; +import { join, normalize, resolve } from "pathe"; import { joinURL, hasProtocol, @@ -334,7 +334,13 @@ export async function writeWranglerConfig( ? (["binding", "directory"] as const).some((field) => { const value = ctxConfig.assets?.[field] ?? userConfig.assets?.[field]; - return value !== undefined && value !== overrides.assets?.[field]; + const expected = overrides.assets?.[field]; + return ( + value !== undefined && + (field === "directory" && expected !== undefined + ? normalize(value) !== normalize(expected) + : value !== expected) + ); }) : key in userConfig || key in ctxConfig; if (hasConflict) { diff --git a/test/unit/cloudflare-config.test.ts b/test/unit/cloudflare-config.test.ts index 43a3b84a4d..b0f4cb1bc6 100644 --- a/test/unit/cloudflare-config.test.ts +++ b/test/unit/cloudflare-config.test.ts @@ -1,7 +1,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { createNitro } from "nitropack/core"; -import { join } from "pathe"; +import { join, resolve } from "pathe"; import { describe, expect, it, vi } from "vitest"; describe("Cloudflare asset configuration diagnostics", () => { @@ -18,6 +18,12 @@ describe("Cloudflare asset configuration diagnostics", () => { contextAssets: {}, warns: false, }, + { + title: "matching directory with Windows separators", + fileAssets: { directory: "..\\public" }, + contextAssets: {}, + warns: false, + }, { title: "conflicting file binding with context routing policy", fileAssets: { binding: "CUSTOM" }, @@ -68,7 +74,9 @@ describe("Cloudflare asset configuration diagnostics", () => { ) ); expect(generated.assets.binding).toBe("ASSETS"); - expect(generated.assets.directory).toBe("../public"); + expect( + resolve(nitro.options.output.serverDir, generated.assets.directory) + ).toBe(resolve(nitro.options.output.publicDir)); expect( warn.mock.calls.some(([message]) => String(message).includes("Wrangler config `assets`") From 9475593ca7fe1708a55a96678ec28b6695c8eaaf Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 30 Sep 2026 17:04:03 +1000 Subject: [PATCH 3/5] fix(cloudflare): ignore unset JSON asset fields --- src/presets/cloudflare/utils.ts | 8 ++++++-- test/unit/cloudflare-config.test.ts | 6 ++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/presets/cloudflare/utils.ts b/src/presets/cloudflare/utils.ts index 4edf9f3bc0..1d7c526ef3 100644 --- a/src/presets/cloudflare/utils.ts +++ b/src/presets/cloudflare/utils.ts @@ -333,11 +333,15 @@ export async function writeWranglerConfig( key === "assets" ? (["binding", "directory"] as const).some((field) => { const value = - ctxConfig.assets?.[field] ?? userConfig.assets?.[field]; + ctxConfig.assets?.[field] ?? + userConfig.assets?.[field] ?? + undefined; const expected = overrides.assets?.[field]; return ( value !== undefined && - (field === "directory" && expected !== undefined + (field === "directory" && + typeof value === "string" && + expected !== undefined ? normalize(value) !== normalize(expected) : value !== expected) ); diff --git a/test/unit/cloudflare-config.test.ts b/test/unit/cloudflare-config.test.ts index b0f4cb1bc6..51398cb7b5 100644 --- a/test/unit/cloudflare-config.test.ts +++ b/test/unit/cloudflare-config.test.ts @@ -24,6 +24,12 @@ describe("Cloudflare asset configuration diagnostics", () => { contextAssets: {}, warns: false, }, + { + title: "unset directory from JSON", + fileAssets: { directory: null }, + contextAssets: {}, + warns: false, + }, { title: "conflicting file binding with context routing policy", fileAssets: { binding: "CUSTOM" }, From 6dcd150ef67b5b8e6349e53c311884e423d49ee7 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 30 Sep 2026 17:12:07 +1000 Subject: [PATCH 4/5] test(cloudflare): cover unset JSON asset binding --- test/unit/cloudflare-config.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/unit/cloudflare-config.test.ts b/test/unit/cloudflare-config.test.ts index 51398cb7b5..75b8f752cc 100644 --- a/test/unit/cloudflare-config.test.ts +++ b/test/unit/cloudflare-config.test.ts @@ -25,8 +25,8 @@ describe("Cloudflare asset configuration diagnostics", () => { warns: false, }, { - title: "unset directory from JSON", - fileAssets: { directory: null }, + title: "unset asset fields from JSON", + fileAssets: { directory: null, binding: null }, contextAssets: {}, warns: false, }, From 02345c6123ad72ff2ee7b9f77291210b31857495 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Sat, 3 Oct 2026 16:50:57 +0000 Subject: [PATCH 5/5] fix(cloudflare): resolve asset directories against their own config file User wrangler config paths are relative to that file, while context config paths end up in the generated config. Compare resolved paths instead of normalized strings, and attribute the warning to the source that conflicts. --- src/presets/cloudflare/utils.ts | 71 ++++++++++++++++++++--------- test/unit/cloudflare-config.test.ts | 50 +++++++++++++++----- 2 files changed, 88 insertions(+), 33 deletions(-) diff --git a/src/presets/cloudflare/utils.ts b/src/presets/cloudflare/utils.ts index 1d7c526ef3..397271b0da 100644 --- a/src/presets/cloudflare/utils.ts +++ b/src/presets/cloudflare/utils.ts @@ -10,7 +10,7 @@ import { readGitConfig, readPackageJSON, findNearestFile } from "pkg-types"; import { defu } from "defu"; import { globby } from "globby"; import { provider } from "std-env"; -import { join, normalize, resolve } from "pathe"; +import { join, resolve } from "pathe"; import { joinURL, hasProtocol, @@ -322,34 +322,31 @@ export async function writeWranglerConfig( } // Read user config - const { config: userConfig = {} } = await readWranglerConfig(nitro); + const { configPath: userConfigPath, config: userConfig = {} } = + await readWranglerConfig(nitro); // Nitro context config (from frameworks and modules) const ctxConfig = nitro.options.cloudflare?.wrangler || {}; // Validate and warn about overrides for (const key in overrides) { - const hasConflict = - key === "assets" - ? (["binding", "directory"] as const).some((field) => { - const value = - ctxConfig.assets?.[field] ?? - userConfig.assets?.[field] ?? - undefined; - const expected = overrides.assets?.[field]; - return ( - value !== undefined && - (field === "directory" && - typeof value === "string" && - expected !== undefined - ? normalize(value) !== normalize(expected) - : value !== expected) - ); - }) - : key in userConfig || key in ctxConfig; - if (hasConflict) { + let conflict: "ctx" | "user" | undefined; + if (key === "assets") { + conflict = findAssetsConflict( + overrides.assets!, + ctxConfig.assets, + userConfig.assets, + wranglerConfigDir, + userConfigPath ? dirname(userConfigPath) : nitro.options.rootDir + ); + } else if (key in ctxConfig) { + conflict = "ctx"; + } else if (key in userConfig) { + conflict = "user"; + } + if (conflict) { nitro.logger.warn( - `[cloudflare] Wrangler config \`${key}\`${key in ctxConfig ? "set by config or modules" : ""} is overridden and will be ignored.` + `[cloudflare] Wrangler config \`${key}\`${conflict === "ctx" ? " set by config or modules" : ""} is overridden and will be ignored.` ); } } @@ -420,6 +417,36 @@ export async function writeWranglerConfig( ); } +type WranglerAssets = NonNullable; + +// Returns the source of an asset `binding` or `directory` that differs from the generated one +// Context config is copied into the generated config (relative to `generatedDir`), +// while user config paths are relative to the user wrangler config (`userDir`) +function findAssetsConflict( + expected: WranglerAssets, + ctxAssets: Partial | undefined, + userAssets: Partial | undefined, + generatedDir: string, + userDir: string +): "ctx" | "user" | undefined { + for (const field of ["binding", "directory"] as const) { + // Context config takes precedence over user config (null means unset in JSON) + const source = ctxAssets?.[field] == null ? "user" : "ctx"; + const value = (source === "ctx" ? ctxAssets : userAssets)?.[field]; + if (value == null) { + continue; + } + const isConflict = + field === "directory" + ? resolve(source === "ctx" ? generatedDir : userDir, value) !== + resolve(generatedDir, expected.directory!) + : value !== expected.binding; + if (isConflict) { + return source; + } + } +} + async function generateWorkerName(nitro: Nitro) { const gitConfig = await readGitConfig(nitro.options.rootDir).catch( () => undefined diff --git a/test/unit/cloudflare-config.test.ts b/test/unit/cloudflare-config.test.ts index 75b8f752cc..c813df4dc7 100644 --- a/test/unit/cloudflare-config.test.ts +++ b/test/unit/cloudflare-config.test.ts @@ -14,13 +14,19 @@ describe("Cloudflare asset configuration diagnostics", () => { }, { title: "matching generated values", - fileAssets: { binding: "ASSETS", directory: "../public" }, + fileAssets: { binding: "ASSETS", directory: "./.output/public" }, + contextAssets: {}, + warns: false, + }, + { + title: "matching directory with trailing slash", + fileAssets: { directory: ".output/public/" }, contextAssets: {}, warns: false, }, { title: "matching directory with Windows separators", - fileAssets: { directory: "..\\public" }, + fileAssets: { directory: ".\\.output\\public" }, contextAssets: {}, warns: false, }, @@ -34,7 +40,7 @@ describe("Cloudflare asset configuration diagnostics", () => { title: "conflicting file binding with context routing policy", fileAssets: { binding: "CUSTOM" }, contextAssets: { html_handling: "drop-trailing-slash" as const }, - warns: true, + warns: "file", }, { title: "context binding precedence", @@ -46,13 +52,31 @@ describe("Cloudflare asset configuration diagnostics", () => { title: "conflicting context binding", fileAssets: {}, contextAssets: { binding: "CUSTOM" }, - warns: true, + warns: "context", }, { title: "conflicting asset directory", - fileAssets: { directory: "../custom" }, + fileAssets: { directory: "./custom" }, contextAssets: {}, - warns: true, + warns: "file", + }, + { + title: "file directory relative to generated config", + fileAssets: { directory: "../public" }, + contextAssets: {}, + warns: "file", + }, + { + title: "matching context directory", + fileAssets: {}, + contextAssets: { directory: "../public" }, + warns: false, + }, + { + title: "conflicting context directory", + fileAssets: {}, + contextAssets: { directory: "./.output/public" }, + warns: "context", }, ])( "reports $title correctly", @@ -83,11 +107,15 @@ describe("Cloudflare asset configuration diagnostics", () => { expect( resolve(nitro.options.output.serverDir, generated.assets.directory) ).toBe(resolve(nitro.options.output.publicDir)); - expect( - warn.mock.calls.some(([message]) => - String(message).includes("Wrangler config `assets`") - ) - ).toBe(warns); + const message = warn.mock.calls + .map(([message]) => String(message)) + .find((message) => message.includes("Wrangler config `assets`")); + expect(Boolean(message)).toBe(Boolean(warns)); + if (message) { + expect(message.includes(" set by config or modules")).toBe( + warns === "context" + ); + } if ("html_handling" in contextAssets || "html_handling" in fileAssets) { expect(generated.assets.html_handling).toBe("drop-trailing-slash"); }