1/**
2 * Config synthesis for driving Nuxt programmatically while keeping the
3 * project's own `nuxt.config.ts` authoritative.
4 *
5 * A Nuxt project's `nuxt.config.ts` loads natively through `loadNuxt` (c12
6 * resolves the file, its layers, and its modules exactly as `nuxi` would).
7 * This package injects its configuration through two seams:
8 *
9 * 1. **`loadNuxt` overrides** — the highest-priority config layer. We put the
10 * deploy target's nitro preset and integration-level Nuxt overrides here.
11 * 2. **The `nitro:config` hook** — runs when Nuxt initializes Nitro, after
12 * every module and layer has contributed. {@link enforceNitroConfig} is
13 * registered there and gets the last word on the keys this integration
14 * owns (the preset, and `cloudflare.deployConfig: false` — we never write
15 * a wrangler config into user projects).
16 *
17 * Hooks MUST be registered before `nuxt.ready()`: with `ready: true` the
18 * hooks fire inside `loadNuxt` itself and a late registration misses them.
19 * `Nuxt.ts` therefore always loads with `ready: false`.
20 *
21 * Conflict policy: the user's `nuxt.config.ts` may not select a different
22 * deployment preset. Because `loadNuxt` overrides shadow the user's value in
23 * the merged config, the user's raw value is read from the project's own
24 * config layer (`nuxt.options._layers[0].config`) and a foreign preset fails
25 * the build with an actionable error ({@link findPresetConflict}) instead of
26 * being silently replaced — the user's nitro output would never be deployed.
27 */
28
29/** A minimal structural slice of a resolved nitro config (`NitroConfig`). */
30export interface NitroConfigSlice extends Record<string, unknown> {
31 preset?: string | undefined;
32 cloudflare?: Record<string, unknown> | undefined;
33 plugins?: Array<string> | undefined;
34}
35
36/** The structural slice of `nuxt.options._layers` entries this package reads. */
37export interface NuxtConfigLayer {
38 readonly cwd?: string | undefined;
39 readonly config?:
40 | {
41 readonly nitro?: { readonly preset?: string | undefined } | undefined;
42 }
43 | undefined;
44}
45
46/**
47 * Normalize a nitro preset name for comparison: nitro treats
48 * `cloudflare_module`, `cloudflare-module`, and `cloudflareModule` as the
49 * same preset (kebab/snake/camel-insensitive).
50 */
51export const normalizePresetName = (name: string): string =>
52 name
53 .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
54 .replace(/[_-]+/g, "-")
55 .toLowerCase();
56
57/** Whether two preset names identify the same nitro preset. */
58export const isSamePreset = (a: string, b: string): boolean =>
59 normalizePresetName(a) === normalizePresetName(b);
60
61export interface NuxtOverridesInput {
62 /**
63 * The deploy target's nitro preset (e.g. `"cloudflare_module"`), injected
64 * as the highest-priority config layer AND re-enforced by
65 * {@link enforceNitroConfig} after modules run. Omitted for the dev
66 * server: nitro's dev flow runs its own Node dev preset — the deploy
67 * preset only matters for `build`.
68 */
69 readonly nitroPreset?: string | undefined;
70 /**
71 * Integration-level Nuxt config overrides (`NuxtOptions.nuxtConfig`),
72 * merged over the user's `nuxt.config.ts` (integration wins; c12/defu
73 * performs the deep merge).
74 */
75 readonly nuxtConfig?: Record<string, unknown> | undefined;
76 /** Absolute paths of nitro plugins to append (the dev-mode platform bridge). */
77 readonly nitroPlugins?: ReadonlyArray<string> | undefined;
78 /**
79 * Paths nitro's bundler must INLINE (`nitro.externals.inline`). Injected
80 * plugins that live under `node_modules` need this: nitro's externals
81 * pass would otherwise keep them external, and an externalized plugin
82 * resolves `nitropack/runtime` against the raw package — whose
83 * `#nitro-internal-virtual/*` imports only exist inside the bundle.
84 */
85 readonly nitroExternalsInline?: ReadonlyArray<string> | undefined;
86 /**
87 * `runtimeConfig` injected over the integration overrides (the dev
88 * platform's connect info rides here). Merged over any `runtimeConfig`
89 * in `nuxtConfig`; c12 deep-merges the result with the user's own
90 * `nuxt.config.ts` runtime config.
91 */
92 readonly runtimeConfig?: Record<string, unknown> | undefined;
93}
94
95/**
96 * Synthesize the `overrides` object for `loadNuxt({ cwd, ready: false,
97 * overrides })`. The user's `nuxt.config.ts` remains authoritative for
98 * everything this integration does not own.
99 */
100export const makeNuxtOverrides = (
101 input: NuxtOverridesInput,
102): Record<string, unknown> => {
103 const userNitro =
104 input.nuxtConfig !== undefined && isPlainObject(input.nuxtConfig["nitro"])
105 ? (input.nuxtConfig["nitro"] as Record<string, unknown>)
106 : undefined;
107 const userRuntimeConfig =
108 input.nuxtConfig !== undefined &&
109 isPlainObject(input.nuxtConfig["runtimeConfig"])
110 ? (input.nuxtConfig["runtimeConfig"] as Record<string, unknown>)
111 : undefined;
112 const userExternals = isPlainObject(userNitro?.["externals"])
113 ? (userNitro["externals"] as Record<string, unknown>)
114 : undefined;
115 const userVite =
116 input.nuxtConfig !== undefined && isPlainObject(input.nuxtConfig["vite"])
117 ? (input.nuxtConfig["vite"] as Record<string, unknown>)
118 : undefined;
119 return {
120 ...input.nuxtConfig,
121 // Warn-level vite default: rolldown-vite's native progress reporter
122 // ("transforming...") writes straight to the fd, which corrupts
123 // hosting-process reporters (e.g. alchemy-test) that can only intercept
124 // JS-level writers. Integration-level `vite.logLevel` still wins.
125 vite: { logLevel: "warn", ...userVite },
126 nitro: {
127 ...userNitro,
128 ...(input.nitroPreset !== undefined
129 ? { preset: input.nitroPreset }
130 : undefined),
131 ...(input.nitroPlugins !== undefined && input.nitroPlugins.length > 0
132 ? {
133 plugins: [
134 ...(Array.isArray(userNitro?.["plugins"])
135 ? (userNitro["plugins"] as Array<string>)
136 : []),
137 ...input.nitroPlugins,
138 ],
139 }
140 : undefined),
141 ...(input.nitroExternalsInline !== undefined &&
142 input.nitroExternalsInline.length > 0
143 ? {
144 externals: {
145 ...userExternals,
146 inline: [
147 ...(Array.isArray(userExternals?.["inline"])
148 ? (userExternals["inline"] as Array<unknown>)
149 : []),
150 ...input.nitroExternalsInline,
151 ],
152 },
153 }
154 : undefined),
155 },
156 ...(input.runtimeConfig !== undefined
157 ? { runtimeConfig: { ...userRuntimeConfig, ...input.runtimeConfig } }
158 : undefined),
159 };
160};
161
162const isPlainObject = (value: unknown): value is Record<string, unknown> =>
163 value !== null && typeof value === "object" && !Array.isArray(value);
164
165/**
166 * Find a preset conflict in the project's own config layers: returns the
167 * user's foreign preset name, or `undefined` when the user set no preset or
168 * set (an alias of) the target's own. Layers are the project's config plus
169 * any extended layers; only values the *project tree* contributed are
170 * consulted — the merged `nuxt.options.nitro.preset` is already ours because
171 * overrides shadow it.
172 */
173export const findPresetConflict = (
174 layers: ReadonlyArray<NuxtConfigLayer> | undefined,
175 targetPreset: string,
176): string | undefined => {
177 for (const layer of layers ?? []) {
178 const preset = layer.config?.nitro?.preset;
179 if (
180 typeof preset === "string" &&
181 preset.length > 0 &&
182 !isSamePreset(preset, targetPreset)
183 ) {
184 return preset;
185 }
186 }
187 return undefined;
188};
189
190/** The actionable error message for a user-configured foreign preset. */
191export const presetConflictMessage = (
192 userPreset: string,
193 targetPreset: string,
194): string =>
195 `The project's nuxt.config sets \`nitro.preset: "${userPreset}"\`, but this integration ` +
196 `deploys through the "${targetPreset}" preset — a "${userPreset}" build would never be ` +
197 "deployed. Remove the `nitro.preset` (and any `NITRO_PRESET` env var) from the project " +
198 "config and let the deploy target select the preset, or run the framework's own CLI for " +
199 `a "${userPreset}" build outside this integration.`;
200
201export interface EnforceNitroConfigOptions {
202 /** The deploy target's nitro preset — always wins. */
203 readonly preset: string;
204 /**
205 * Extra last-word mutation contributed by the deploy target (e.g. the
206 * Cloudflare target's `cloudflare.deployConfig: false` and the user-entry
207 * wiring). Runs after the preset is enforced.
208 */
209 readonly configure?: ((nitroConfig: NitroConfigSlice) => void) | undefined;
210}
211
212/**
213 * The `nitro:config` hook body: enforce the keys this integration owns as
214 * the last word, after user config, layers, and modules have all
215 * contributed. Mutates `nitroConfig` in place (the hook contract).
216 */
217export const enforceNitroConfig = (
218 nitroConfig: NitroConfigSlice,
219 options: EnforceNitroConfigOptions,
220): void => {
221 nitroConfig.preset = options.preset;
222 options.configure?.(nitroConfig);
223};