1import fs from "node:fs";
2import path from "node:path";
3import type * as vite from "vite";
4import { createPlugin } from "../factory.ts";
5import { parseViteEnvironments, type BasePluginOptions } from "../options.ts";
6import { hasNodejsCompat } from "../utils.ts";
7import { workerEntryId } from "./virtual-modules.ts";
8
9const DEFAULT_RESOLVE_CONDITION_NAMES = [
10 "workerd",
11 "worker",
12 "module",
13 "browser",
14];
15const DEFAULT_RESOLVE_MAIN_FIELDS = [
16 "browser",
17 "module",
18 "jsnext:main",
19 "jsnext",
20 "main",
21];
22const 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
35const TARGET = "es2024";
36
37export interface OptionsApi {
38 input: () => Record<string, string>;
39}
40
41export 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 */
263const 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
276const 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
287const 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
296const 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 */
319const 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
327const 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
335const wrapInput = (input: Record<string, string>) =>
336 Object.fromEntries(
337 Object.entries(input).map(([key, id]) => [key, workerEntryId(id)]),
338 );
339
340const 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
368const 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};