diff --git a/src/presets/cloudflare/utils.ts b/src/presets/cloudflare/utils.ts index 47e8661e7b..397271b0da 100644 --- a/src/presets/cloudflare/utils.ts +++ b/src/presets/cloudflare/utils.ts @@ -322,16 +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) { - if (key in userConfig || key in ctxConfig) { + 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.` ); } } @@ -402,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 new file mode 100644 index 0000000000..c813df4dc7 --- /dev/null +++ b/test/unit/cloudflare-config.test.ts @@ -0,0 +1,129 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { createNitro } from "nitropack/core"; +import { join, resolve } 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: "./.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: ".\\.output\\public" }, + contextAssets: {}, + warns: false, + }, + { + title: "unset asset fields from JSON", + fileAssets: { directory: null, binding: null }, + contextAssets: {}, + warns: false, + }, + { + title: "conflicting file binding with context routing policy", + fileAssets: { binding: "CUSTOM" }, + contextAssets: { html_handling: "drop-trailing-slash" as const }, + warns: "file", + }, + { + title: "context binding precedence", + fileAssets: { binding: "CUSTOM" }, + contextAssets: { binding: "ASSETS" }, + warns: false, + }, + { + title: "conflicting context binding", + fileAssets: {}, + contextAssets: { binding: "CUSTOM" }, + warns: "context", + }, + { + title: "conflicting asset directory", + fileAssets: { directory: "./custom" }, + contextAssets: {}, + 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", + 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( + resolve(nitro.options.output.serverDir, generated.assets.directory) + ).toBe(resolve(nitro.options.output.publicDir)); + 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"); + } + } finally { + warn.mockRestore(); + await nitro.close(); + await rm(rootDir, { recursive: true, force: true }); + } + } + ); +});