| 1 | /** |
| 2 | * Config synthesis for driving SolidStart programmatically while keeping the |
| 3 | * project's own `vite.config.*` authoritative. |
| 4 | * |
| 5 | * SolidStart 2's `solidStart()` is a plain Vite plugin: it declares the |
| 6 | * `client` / `ssr` environments and a `builder.buildApp` that builds both. |
| 7 | * The server half — turning the SSR bundle into a deployable server — is |
| 8 | * `@solidjs/vite-plugin-nitro-2`, a second Vite plugin whose `buildApp` |
| 9 | * replaces SolidStart's and runs nitro over the emitted SSR bundle. |
| 10 | * |
| 11 | * That plugin takes its nitro configuration as a plain argument and always |
| 12 | * passes a `preset` down to nitro (its own default is `"node-server"`), so |
| 13 | * neither `NITRO_PRESET` nor a `nitro.config.ts` can override it. The only |
| 14 | * seam that gets the last word is the plugin instance itself — so this |
| 15 | * integration constructs it: |
| 16 | * |
| 17 | * 1. The project's `vite.config.*` loads natively (its `solidStart()` and any |
| 18 | * other plugins are untouched). |
| 19 | * 2. The integration APPENDS its own `nitroV2Plugin(...)` instance — loaded |
| 20 | * from the *project's* `node_modules` — through vite's inline config. |
| 21 | * Inline plugins are concatenated after the config file's, and vite merges |
| 22 | * plugin `config()` results in order, so the appended instance's |
| 23 | * `builder.buildApp` (and therefore its nitro config) wins. |
| 24 | * 3. {@link resolveNitroConfig} assembles that nitro config: the deploy |
| 25 | * target's preset is enforced last, after the caller's `nitro` overrides |
| 26 | * and the target's own {@link NitroConfigSlice} pass. |
| 27 | * |
| 28 | * Conflict policy: a project that registers `nitroV2Plugin` in its own |
| 29 | * `vite.config.*` would have its nitro options silently dropped (our |
| 30 | * appended instance replaces its `buildApp`), so the build fails with |
| 31 | * {@link pluginConflictMessage} instead. Nitro options belong on the |
| 32 | * integration (`nitro: { ... }`), which forwards them. |
| 33 | * |
| 34 | * The preset-name helpers are duplicated from the Nuxt integration rather |
| 35 | * than shared: they are pure string functions, and a shared module would |
| 36 | * couple two independent framework packages through framework-core's public |
| 37 | * surface for ~20 lines. |
| 38 | */ |
| 39 | |
| 40 | /** A minimal structural slice of a nitro config (`NitroConfig`). */ |
| 41 | export interface NitroConfigSlice extends Record<string, unknown> { |
| 42 | preset?: string | undefined; |
| 43 | rootDir?: string | undefined; |
| 44 | /** Nitro's work directory. Forced under `rootDir` so fixture clones don't share a parent-workspace cache. */ |
| 45 | buildDir?: string | undefined; |
| 46 | awsLambda?: Record<string, unknown> | undefined; |
| 47 | output?: NitroOutputConfigSlice | undefined; |
| 48 | } |
| 49 | |
| 50 | /** The `output` slice of a nitro config this integration reads. */ |
| 51 | export interface NitroOutputConfigSlice { |
| 52 | dir?: string | undefined; |
| 53 | serverDir?: string | undefined; |
| 54 | publicDir?: string | undefined; |
| 55 | } |
| 56 | |
| 57 | /** The plugin name `@solidjs/vite-plugin-nitro-2` registers. */ |
| 58 | export const NITRO_PLUGIN_NAME = "solid-start-vite-plugin-nitro"; |
| 59 | |
| 60 | /** |
| 61 | * Normalize a nitro preset name for comparison: nitro treats `aws_lambda`, |
| 62 | * `aws-lambda`, and `awsLambda` as the same preset (kebab/snake/camel |
| 63 | * insensitive). |
| 64 | */ |
| 65 | export const normalizePresetName = (name: string): string => |
| 66 | name |
| 67 | .replace(/([a-z0-9])([A-Z])/g, "$1-$2") |
| 68 | .replace(/[_-]+/g, "-") |
| 69 | .toLowerCase(); |
| 70 | |
| 71 | /** Whether two preset names identify the same nitro preset. */ |
| 72 | export const isSamePreset = (a: string, b: string): boolean => |
| 73 | normalizePresetName(a) === normalizePresetName(b); |
| 74 | |
| 75 | /** The actionable error message for a caller-configured foreign preset. */ |
| 76 | export const presetConflictMessage = ( |
| 77 | userPreset: string, |
| 78 | targetPreset: string, |
| 79 | ): string => |
| 80 | `The integration was given \`nitro.preset: "${userPreset}"\`, but this deploy target ` + |
| 81 | `builds through the "${targetPreset}" preset — a "${userPreset}" build would never be ` + |
| 82 | "deployed. Remove `nitro.preset` and let the deploy target select it, or run the " + |
| 83 | `framework's own CLI for a "${userPreset}" build outside this integration.`; |
| 84 | |
| 85 | /** The actionable error message for a project-registered nitro plugin. */ |
| 86 | export const pluginConflictMessage = (): string => |
| 87 | "The project's vite config registers `nitroV2Plugin()` from " + |
| 88 | "`@solidjs/vite-plugin-nitro-2`. This integration appends its own instance so the " + |
| 89 | "deploy target owns the nitro preset, which would silently discard the project's " + |
| 90 | "nitro options. Remove `nitroV2Plugin()` from the vite config and pass its options " + |
| 91 | "to the integration instead (`nitro: { ... }`, e.g. `nitro: { prerender: { crawlLinks: true } }`)."; |
| 92 | |
| 93 | export interface ResolveNitroConfigInput { |
| 94 | /** The deploy target's nitro preset — always the last word. */ |
| 95 | readonly preset: string; |
| 96 | /** Absolute project root, forwarded to nitro as `rootDir`. */ |
| 97 | readonly rootDir: string; |
| 98 | /** Caller-supplied nitro overrides (the integration's `nitro` option). */ |
| 99 | readonly nitro?: Record<string, unknown> | undefined; |
| 100 | /** |
| 101 | * Extra last-word mutation contributed by the deploy target (e.g. the AWS |
| 102 | * target's `awsLambda.streaming`). Runs after the preset is enforced. |
| 103 | */ |
| 104 | readonly configure?: ((nitroConfig: NitroConfigSlice) => void) | undefined; |
| 105 | } |
| 106 | |
| 107 | /** |
| 108 | * Assemble the nitro config handed to the appended `nitroV2Plugin(...)` |
| 109 | * instance: the caller's overrides first, then the owned keys (`preset`, |
| 110 | * `rootDir`) enforced over them, then the deploy target's pass. |
| 111 | */ |
| 112 | export const resolveNitroConfig = ( |
| 113 | input: ResolveNitroConfigInput, |
| 114 | ): NitroConfigSlice => { |
| 115 | const config: NitroConfigSlice = { ...input.nitro }; |
| 116 | config.preset = input.preset; |
| 117 | config.rootDir = input.rootDir; |
| 118 | // Keep nitro's `.nitro` cache inside this project. A fixture clone |
| 119 | // resolves nitro from the parent workspace; nitro's default cache then |
| 120 | // lands in that workspace and leaks other tests' traced modules. |
| 121 | config.buildDir = `${input.rootDir}/.nitro`; |
| 122 | input.configure?.(config); |
| 123 | return config; |
| 124 | }; |
| 125 | |
| 126 | /** |
| 127 | * The caller's foreign preset, or `undefined` when none was given or it is |
| 128 | * (an alias of) the target's own. |
| 129 | */ |
| 130 | export const findPresetConflict = ( |
| 131 | nitro: Record<string, unknown> | undefined, |
| 132 | targetPreset: string, |
| 133 | ): string | undefined => { |
| 134 | const preset = nitro?.["preset"]; |
| 135 | return typeof preset === "string" && |
| 136 | preset.length > 0 && |
| 137 | !isSamePreset(preset, targetPreset) |
| 138 | ? preset |
| 139 | : undefined; |
| 140 | }; |
| 141 | |
| 142 | /** |
| 143 | * Resolve the absolute nitro output directories for a build at `root`, |
| 144 | * honoring an `output` override on the nitro config. Mirrors nitro's own |
| 145 | * defaults (`.output`, `.output/server`, `.output/public`). |
| 146 | */ |
| 147 | export const resolveNitroOutputDirs = ( |
| 148 | config: NitroConfigSlice, |
| 149 | root: string, |
| 150 | resolve: (...segments: Array<string>) => string, |
| 151 | ): { dir: string; serverDir: string; publicDir: string } => { |
| 152 | const dir = resolve(root, config.output?.dir ?? ".output"); |
| 153 | return { |
| 154 | dir, |
| 155 | serverDir: resolve(dir, config.output?.serverDir ?? "server"), |
| 156 | publicDir: resolve(dir, config.output?.publicDir ?? "public"), |
| 157 | }; |
| 158 | }; |
| 159 | |
| 160 | /** Marker set on the plugin instances this integration appends. */ |
| 161 | const INJECTED = Symbol.for( |
| 162 | "@alchemy.run/frontend-frameworks/solidstart/injected", |
| 163 | ); |
| 164 | |
| 165 | /** Flatten a vite `plugins` value (nested arrays, falsy entries) to objects. */ |
| 166 | const flattenPlugins = ( |
| 167 | value: unknown, |
| 168 | out: Array<Record<string | symbol, unknown>>, |
| 169 | ): void => { |
| 170 | if (Array.isArray(value)) { |
| 171 | for (const entry of value) flattenPlugins(entry, out); |
| 172 | return; |
| 173 | } |
| 174 | if (value !== null && typeof value === "object") { |
| 175 | out.push(value as Record<string | symbol, unknown>); |
| 176 | } |
| 177 | }; |
| 178 | |
| 179 | /** Stamp every plugin object this integration created so it is recognizable. */ |
| 180 | export const markInjectedPlugins = <T>(plugins: T): T => { |
| 181 | const flat: Array<Record<string | symbol, unknown>> = []; |
| 182 | flattenPlugins(plugins, flat); |
| 183 | for (const plugin of flat) { |
| 184 | plugin[INJECTED] = true; |
| 185 | } |
| 186 | return plugins; |
| 187 | }; |
| 188 | |
| 189 | /** |
| 190 | * Whether a vite `plugins` value contains a nitro plugin instance this |
| 191 | * integration did NOT create (see {@link pluginConflictMessage}). |
| 192 | */ |
| 193 | export const hasForeignNitroPlugin = (plugins: unknown): boolean => { |
| 194 | const flat: Array<Record<string | symbol, unknown>> = []; |
| 195 | flattenPlugins(plugins, flat); |
| 196 | return flat.some( |
| 197 | (plugin) => |
| 198 | plugin["name"] === NITRO_PLUGIN_NAME && plugin[INJECTED] !== true, |
| 199 | ); |
| 200 | }; |