| 1 | import fs from "node:fs"; |
| 2 | import path from "node:path"; |
| 3 | import type * as vite from "vite"; |
| 4 | import { createPlugin } from "../factory.ts"; |
| 5 | import { parseViteEnvironments, type BasePluginOptions } from "../options.ts"; |
| 6 | import { hasNodejsCompat } from "../utils.ts"; |
| 7 | import { workerEntryId } from "./virtual-modules.ts"; |
| 8 | |
| 9 | const DEFAULT_RESOLVE_CONDITION_NAMES = [ |
| 10 | "workerd", |
| 11 | "worker", |
| 12 | "module", |
| 13 | "browser", |
| 14 | ]; |
| 15 | const DEFAULT_RESOLVE_MAIN_FIELDS = [ |
| 16 | "browser", |
| 17 | "module", |
| 18 | "jsnext:main", |
| 19 | "jsnext", |
| 20 | "main", |
| 21 | ]; |
| 22 | const DEFAULT_RESOLVE_EXTENSIONS = [ |
| 23 | ".mjs", |
| 24 | ".js", |
| 25 | ".mts", |
| 26 | ".ts", |
| 27 | ".jsx", |
| 28 | ".tsx", |
| 29 | ".json", |
| 30 | ".cjs", |
| 31 | ".cts", |
| 32 | ".ctx", |
| 33 | ]; |
| 34 | |
| 35 | const TARGET = "es2024"; |
| 36 | |
| 37 | export interface OptionsApi { |
| 38 | input: () => Record<string, string>; |
| 39 | } |
| 40 | |
| 41 | export const optionsPlugin = createPlugin<"options", OptionsApi>( |
| 42 | "options", |
| 43 | (pluginOptions) => { |
| 44 | let input: Record<string, string> = {}; |
| 45 | return { |
| 46 | shared: { |
| 47 | api: { |
| 48 | input: () => input, |
| 49 | }, |
| 50 | }, |
| 51 | rolldown: { |
| 52 | options(options) { |
| 53 | input = normalizeInput(pluginOptions.main ?? options.input ?? {}); |
| 54 | options.input = wrapInput(input); |
| 55 | options.preserveEntrySignatures ??= "strict"; |
| 56 | options.platform ??= "neutral"; |
| 57 | options.resolve ??= {}; |
| 58 | options.resolve.conditionNames ??= [ |
| 59 | ...DEFAULT_RESOLVE_CONDITION_NAMES, |
| 60 | "production", |
| 61 | ]; |
| 62 | options.resolve.mainFields ??= DEFAULT_RESOLVE_MAIN_FIELDS; |
| 63 | options.resolve.extensions ??= DEFAULT_RESOLVE_EXTENSIONS; |
| 64 | options.transform ??= {}; |
| 65 | options.transform.target ??= TARGET; |
| 66 | options.transform.define ??= {}; |
| 67 | Object.assign( |
| 68 | options.transform.define, |
| 69 | getDefine(pluginOptions, "production"), |
| 70 | ); |
| 71 | return options; |
| 72 | }, |
| 73 | }, |
| 74 | vite: { |
| 75 | async config(userConfig) { |
| 76 | const vite = await import("vite"); |
| 77 | const isRolldown = "rolldownVersion" in this.meta; |
| 78 | const environmentNames = parseViteEnvironments(pluginOptions); |
| 79 | const root = path.resolve(userConfig.root ?? "."); |
| 80 | input = resolveInputPaths( |
| 81 | normalizeInput( |
| 82 | pluginOptions.main ?? |
| 83 | defaultEnvironmentEntries(environmentNames[0], userConfig) ?? |
| 84 | {}, |
| 85 | ), |
| 86 | root, |
| 87 | ); |
| 88 | const rollupOptions: vite.Rollup.RollupOptions = { |
| 89 | input: wrapInput(input), |
| 90 | preserveEntrySignatures: "strict", |
| 91 | }; |
| 92 | const define = getDefine( |
| 93 | pluginOptions, |
| 94 | process.env.NODE_ENV || userConfig.mode || "production", |
| 95 | ); |
| 96 | const makeEnvironment = ({ |
| 97 | name, |
| 98 | isEntry, |
| 99 | }: { |
| 100 | name: string; |
| 101 | isEntry: boolean; |
| 102 | }): vite.EnvironmentOptions => { |
| 103 | const entries = isEntry |
| 104 | ? (pluginOptions.main ?? |
| 105 | defaultEnvironmentEntries(name, userConfig)) |
| 106 | : (defaultEnvironmentEntries(name, userConfig) ?? |
| 107 | pluginOptions.main); |
| 108 | return { |
| 109 | // Bake `process.env.NODE_ENV` (and friends) into the worker build. |
| 110 | // workerd has no value for it at runtime, so without this libraries |
| 111 | // like React fall back to their development builds. Setting `define` |
| 112 | // at the environment level applies it even though `keepProcessEnv` |
| 113 | // stays `true` (so other `process.env` access is preserved at |
| 114 | // runtime for `nodejs_compat`). |
| 115 | define, |
| 116 | resolve: { |
| 117 | noExternal: true, |
| 118 | conditions: [ |
| 119 | ...DEFAULT_RESOLVE_CONDITION_NAMES, |
| 120 | "development|production", |
| 121 | ], |
| 122 | }, |
| 123 | optimizeDeps: { |
| 124 | noDiscovery: false, |
| 125 | ignoreOutdatedRequests: true, |
| 126 | entries: asArray(entries)?.map(vite.normalizePath), |
| 127 | ...(isRolldown |
| 128 | ? { |
| 129 | rolldownOptions: { |
| 130 | platform: "neutral", |
| 131 | resolve: { |
| 132 | conditionNames: [ |
| 133 | ...DEFAULT_RESOLVE_CONDITION_NAMES, |
| 134 | "development|production", |
| 135 | ], |
| 136 | mainFields: DEFAULT_RESOLVE_MAIN_FIELDS, |
| 137 | extensions: DEFAULT_RESOLVE_EXTENSIONS, |
| 138 | }, |
| 139 | transform: { |
| 140 | target: TARGET, |
| 141 | define, |
| 142 | }, |
| 143 | }, |
| 144 | } |
| 145 | : { |
| 146 | esbuildOptions: { |
| 147 | platform: "neutral", |
| 148 | conditions: [ |
| 149 | ...DEFAULT_RESOLVE_CONDITION_NAMES, |
| 150 | "development|production", |
| 151 | ], |
| 152 | resolveExtensions: DEFAULT_RESOLVE_EXTENSIONS, |
| 153 | mainFields: DEFAULT_RESOLVE_MAIN_FIELDS, |
| 154 | target: TARGET, |
| 155 | define, |
| 156 | }, |
| 157 | }), |
| 158 | }, |
| 159 | keepProcessEnv: true, |
| 160 | // The entry environment owns the Worker's build input and server |
| 161 | // output directory; children (e.g. `ssr`) keep their own build config |
| 162 | // from the framework plugin (`@vitejs/plugin-rsc`). |
| 163 | ...(isEntry |
| 164 | ? { |
| 165 | build: { |
| 166 | ssr: true, |
| 167 | target: TARGET, |
| 168 | emitAssets: true, |
| 169 | copyPublicDir: false, |
| 170 | outDir: getOutputDirectory(userConfig, name), |
| 171 | ...(isRolldown |
| 172 | ? { |
| 173 | rolldownOptions: { |
| 174 | ...rollupOptions, |
| 175 | platform: "neutral", |
| 176 | resolve: { |
| 177 | mainFields: DEFAULT_RESOLVE_MAIN_FIELDS, |
| 178 | extensions: DEFAULT_RESOLVE_EXTENSIONS, |
| 179 | }, |
| 180 | }, |
| 181 | } |
| 182 | : { rollupOptions }), |
| 183 | }, |
| 184 | } |
| 185 | : {}), |
| 186 | }; |
| 187 | }; |
| 188 | const appType = |
| 189 | userConfig.appType ?? |
| 190 | (Object.keys(input).length === 0 ? "spa" : "custom"); |
| 191 | return { |
| 192 | appType, |
| 193 | builder: |
| 194 | // Vite merges plugin `config` results over the user config, so an |
| 195 | // unconditional default here would clobber a framework's own |
| 196 | // `buildApp` orchestrator (e.g. Astro's prerender -> ssr -> client |
| 197 | // pipeline). Only default it when the resolved user config doesn't |
| 198 | // already define one. |
| 199 | appType === "spa" || userConfig.builder?.buildApp !== undefined |
| 200 | ? undefined |
| 201 | : { |
| 202 | // We need to include this for `builder.buildApp()` to work |
| 203 | // because not all frameworks provide it... |
| 204 | buildApp: async (builder) => { |
| 205 | for (const environment of Object.values( |
| 206 | builder.environments, |
| 207 | )) { |
| 208 | // Framework-owned environments we were told to leave |
| 209 | // alone may have no build input; never drive them from |
| 210 | // our default orchestrator. |
| 211 | if ( |
| 212 | pluginOptions.skipEnvironments?.includes( |
| 213 | environment.name, |
| 214 | ) |
| 215 | ) |
| 216 | continue; |
| 217 | // ...however, if your framework does provide a `buildApp` hook, |
| 218 | // this check prevents us from building the environment twice. |
| 219 | if (environment.isBuilt) continue; |
| 220 | // `skipEnvironments` opts out of environments the app |
| 221 | // declared; this covers the one Vite creates unasked. |
| 222 | if (isEntrylessClientEnvironment(environment)) continue; |
| 223 | await builder.build(environment); |
| 224 | } |
| 225 | }, |
| 226 | }, |
| 227 | ssr: { |
| 228 | noExternal: true, |
| 229 | resolve: { |
| 230 | conditions: [ |
| 231 | ...DEFAULT_RESOLVE_CONDITION_NAMES, |
| 232 | "development|production", |
| 233 | ], |
| 234 | }, |
| 235 | }, |
| 236 | environments: { |
| 237 | client: { |
| 238 | build: { |
| 239 | outDir: getOutputDirectory(userConfig, "client"), |
| 240 | }, |
| 241 | }, |
| 242 | ...Object.fromEntries( |
| 243 | environmentNames.map((name, index) => [ |
| 244 | name, |
| 245 | makeEnvironment({ name, isEntry: index === 0 }), |
| 246 | ]), |
| 247 | ), |
| 248 | }, |
| 249 | }; |
| 250 | }, |
| 251 | }, |
| 252 | }; |
| 253 | }, |
| 254 | ); |
| 255 | |
| 256 | /** |
| 257 | * Vite always creates a default `client` environment, even for a pure Worker |
| 258 | * app that has no browser bundle. Building it makes Vite fall back to its |
| 259 | * default `index.html` entry, so the build fails on a file the app never |
| 260 | * referenced. Treat the environment as absent unless the app gave it an entry |
| 261 | * or serves an HTML shell. |
| 262 | */ |
| 263 | const isEntrylessClientEnvironment = (environment: { |
| 264 | name: string; |
| 265 | config: { root: string; build: { rollupOptions?: { input?: unknown } } }; |
| 266 | }): boolean => { |
| 267 | if (environment.name !== "client") return false; |
| 268 | const build = environment.config.build as { |
| 269 | rollupOptions?: { input?: unknown }; |
| 270 | rolldownOptions?: { input?: unknown }; |
| 271 | }; |
| 272 | if (build.rollupOptions?.input || build.rolldownOptions?.input) return false; |
| 273 | return !fs.existsSync(path.join(environment.config.root, "index.html")); |
| 274 | }; |
| 275 | |
| 276 | const defaultEnvironmentEntries = ( |
| 277 | environmentName: string, |
| 278 | userConfig: vite.UserConfig, |
| 279 | ): vite.Rolldown.InputOption | undefined => { |
| 280 | const environment = userConfig.environments?.[environmentName]; |
| 281 | return ( |
| 282 | environment?.build?.rolldownOptions?.input ?? |
| 283 | environment?.build?.rollupOptions?.input |
| 284 | ); |
| 285 | }; |
| 286 | |
| 287 | const asArray = ( |
| 288 | input: string | Array<string> | Record<string, string> | undefined, |
| 289 | ): Array<string> | undefined => { |
| 290 | if (!input) return; |
| 291 | if (typeof input === "string") return [input]; |
| 292 | if (Array.isArray(input) && input.length > 0) return input; |
| 293 | return Object.values(input); |
| 294 | }; |
| 295 | |
| 296 | const normalizeInput = ( |
| 297 | input: string | Array<string> | Record<string, string>, |
| 298 | ): Record<string, string> => { |
| 299 | if (typeof input === "string") { |
| 300 | return { [path.parse(input).name || "index"]: input }; |
| 301 | } else if (Array.isArray(input)) { |
| 302 | return Object.fromEntries(input.map((p) => [path.parse(p).name, p])); |
| 303 | } else { |
| 304 | return input; |
| 305 | } |
| 306 | }; |
| 307 | |
| 308 | /** |
| 309 | * Resolve path-like input entries against the Vite root. Worker entries are |
| 310 | * round-tripped through a synthetic `\0distilled:user-entry:` id and resolved |
| 311 | * with no importer, so a relative input (`./workers/app.ts`) would otherwise |
| 312 | * resolve against `process.cwd()` — the wrong base whenever the build runs |
| 313 | * outside the project root (e.g. from a monorepo infra package). A bare |
| 314 | * specifier that names a real file under the root (`workers/app.ts`) is |
| 315 | * treated as a path input, matching Vite's own input semantics; anything else |
| 316 | * (virtual modules, package specifiers) passes through and resolves via |
| 317 | * plugins. |
| 318 | */ |
| 319 | const resolveInputPaths = ( |
| 320 | input: Record<string, string>, |
| 321 | root: string, |
| 322 | ): Record<string, string> => |
| 323 | Object.fromEntries( |
| 324 | Object.entries(input).map(([key, id]) => [key, resolveInputPath(id, root)]), |
| 325 | ); |
| 326 | |
| 327 | const resolveInputPath = (id: string, root: string): string => { |
| 328 | if (path.isAbsolute(id)) return id; |
| 329 | if (id.startsWith("./") || id.startsWith("../")) |
| 330 | return path.resolve(root, id); |
| 331 | const resolved = path.resolve(root, id); |
| 332 | return fs.existsSync(resolved) ? resolved : id; |
| 333 | }; |
| 334 | |
| 335 | const wrapInput = (input: Record<string, string>) => |
| 336 | Object.fromEntries( |
| 337 | Object.entries(input).map(([key, id]) => [key, workerEntryId(id)]), |
| 338 | ); |
| 339 | |
| 340 | const getDefine = ( |
| 341 | options: BasePluginOptions, |
| 342 | nodeEnv: string, |
| 343 | ): Record<string, string> => { |
| 344 | return { |
| 345 | "process.env.NODE_ENV": JSON.stringify(nodeEnv), |
| 346 | "global.process.env.NODE_ENV": JSON.stringify(nodeEnv), |
| 347 | "globalThis.process.env.NODE_ENV": JSON.stringify(nodeEnv), |
| 348 | ...(hasNodejsCompat(options.compatibilityFlags, options.compatibilityDate) |
| 349 | ? {} |
| 350 | : { |
| 351 | "process.env": "{}", |
| 352 | "global.process.env": "{}", |
| 353 | "globalThis.process.env": "{}", |
| 354 | }), |
| 355 | ...(options.compatibilityDate && options.compatibilityDate >= "2022-03-21" |
| 356 | ? { |
| 357 | "navigator.userAgent": '"Cloudflare-Workers"', |
| 358 | } |
| 359 | : {}), |
| 360 | ...(nodeEnv === "production" |
| 361 | ? { |
| 362 | "import.meta.hot": "false", |
| 363 | } |
| 364 | : {}), |
| 365 | }; |
| 366 | }; |
| 367 | |
| 368 | const getOutputDirectory = ( |
| 369 | userConfig: vite.UserConfig, |
| 370 | environmentName: string, |
| 371 | ) => { |
| 372 | const rootOutputDirectory = userConfig.build?.outDir ?? "dist"; |
| 373 | |
| 374 | return ( |
| 375 | userConfig.environments?.[environmentName]?.build?.outDir ?? |
| 376 | path.join(rootOutputDirectory, environmentName) |
| 377 | ); |
| 378 | }; |