1import {
2 getCloudflarePreset,
3 nonPrefixedNodeModules,
4} from "@cloudflare/unenv-preset";
5import assert from "node:assert";
6import { createRequire } from "node:module";
7import path from "node:path";
8import { defineEnv } from "unenv";
9import { createPlugin } from "../factory.ts";
10import { isSkippedEnvironment } from "../options.ts";
11import type { BasePluginOptions } from "../options.ts";
12import { hasNodejsAls, hasNodejsCompat, toPosixPath } from "../utils.ts";
13
14const ASYNC_HOOKS_REGEXP = /^(node:)?async_hooks$/;
15const NODE_BUILTIN_MODULES_REGEXP = new RegExp(
16 `^(${nonPrefixedNodeModules.join("|")}|node:.+)$`,
17);
18
19export const nodejsAlsPlugin = createPlugin("nodejs-als", (options) => {
20 if (!hasNodejsAls(options.compatibilityFlags)) return;
21 return {
22 rolldown: {
23 resolveId: {
24 filter: { id: ASYNC_HOOKS_REGEXP },
25 handler(id) {
26 return { id, external: true };
27 },
28 },
29 },
30 vite: {
31 enforce: "pre",
32 configEnvironment(name) {
33 if (name === "client") return;
34 return {
35 resolve: {
36 builtins: ["async_hooks", "node:async_hooks"],
37 },
38 optimizeDeps: {
39 exclude: ["async_hooks", "node:async_hooks"],
40 },
41 };
42 },
43 },
44 };
45});
46
47export interface UnenvApi {
48 polyfill: ReadonlyArray<string>;
49 inject: { [injectedName: string]: string };
50}
51
52export const getUnenv = (options: BasePluginOptions) =>
53 defineEnv({
54 presets: [
55 getCloudflarePreset({
56 compatibilityDate: options.compatibilityDate,
57 compatibilityFlags: options.compatibilityFlags,
58 }),
59 ],
60 }).env;
61
62export const nodejsUnenvPlugin = createPlugin<"nodejs-unenv", UnenvApi>(
63 "nodejs-unenv",
64 (options) => {
65 if (!hasNodejsCompat(options.compatibilityFlags, options.compatibilityDate))
66 return;
67 const { alias, inject, polyfill, external } = getUnenv(options);
68 const entries = new Set(Object.values(alias));
69 for (const globalInject of Object.values(inject)) {
70 if (typeof globalInject === "string") {
71 entries.add(globalInject);
72 } else {
73 entries.add(globalInject[0]);
74 }
75 }
76 polyfill.forEach((module) => entries.add(module));
77 external.forEach((module) => entries.delete(module));
78 const require = createRequire(import.meta.url);
79 const resolve = (id: string) => {
80 if (alias[id] && !external.includes(alias[id])) {
81 return require.resolve(alias[id]);
82 }
83 if (entries.has(id)) {
84 return require.resolve(id);
85 }
86 };
87 const RESOLVE_ID_FILTER = {
88 id: [
89 NODE_BUILTIN_MODULES_REGEXP,
90 /^unenv\//,
91 /^@cloudflare\/unenv-preset\//,
92 ],
93 };
94 return {
95 shared: {
96 api: {
97 // Virtual entries have no package directory for bare-import resolution.
98 polyfill: polyfill.map((id) => toPosixPath(require.resolve(id))),
99 inject: Object.fromEntries(
100 Object.entries(inject).map(([injectedName, moduleSpecifier]) => {
101 assert(
102 typeof moduleSpecifier === "string",
103 `expected moduleSpecifier to be a string`,
104 );
105 return [
106 injectedName,
107 toPosixPath(require.resolve(moduleSpecifier)),
108 ];
109 }),
110 ),
111 },
112 },
113 rolldown: {
114 resolveId: {
115 filter: RESOLVE_ID_FILTER,
116 handler(source, importer, options) {
117 const resolved = resolve(source);
118 if (!resolved) return;
119 return this.resolve(resolved, importer, options);
120 },
121 },
122 },
123 vite: {
124 enforce: "pre",
125 async configEnvironment(name) {
126 if (name === "client") return;
127 const { esmExternalRequirePlugin } = await import("vite");
128 return {
129 resolve: {
130 builtins: [...external],
131 },
132 ...(this.meta.rolldownVersion
133 ? {
134 build: {
135 rolldownOptions: {
136 plugins: [
137 esmExternalRequirePlugin({
138 external: [...external],
139 skipDuplicateCheck: true,
140 }),
141 ],
142 },
143 },
144 }
145 : {}),
146 optimizeDeps: {
147 exclude: [
148 ...nonPrefixedNodeModules,
149 ...nonPrefixedNodeModules.map((module) => `node:${module}`),
150 // New Node.js built-in modules are only published with the `node:` prefix.
151 ...[
152 "node:sea",
153 "node:sqlite",
154 "node:test",
155 "node:test/reporters",
156 ],
157 ],
158 ...(this.meta.rolldownVersion
159 ? {
160 rolldownOptions: {
161 plugins: [
162 // In Vite 8, `require` calls are not automatically replaced when
163 // the format is ESM and `platform` is `neutral`.
164 esmExternalRequirePlugin({
165 external: [NODE_BUILTIN_MODULES_REGEXP],
166 skipDuplicateCheck: true,
167 }),
168 ],
169 },
170 }
171 : {}),
172 },
173 };
174 },
175 async configureServer(server) {
176 await Promise.all(
177 Object.values(server.environments).flatMap(async (environment) => {
178 // Framework-owned Node-side environments (e.g. Astro's `astro`
179 // and `prerender`) must be left untouched — this is a
180 // server-level hook, so `applyToEnvironment` cannot gate it.
181 if (isSkippedEnvironment(options, environment.name)) return;
182 const depsOptimizer = environment.depsOptimizer;
183 if (!depsOptimizer) return;
184 // Environments using Vite's no-discovery ("explicit") optimizer
185 // throw on `registerMissingImport` by design; pre-bundling the
186 // unenv polyfills is best-effort, so skip them.
187 if (!supportsMissingImportRegistration(environment)) return;
188 await depsOptimizer.init();
189 return Array.from(entries).map((entry) => {
190 const resolved = resolve(entry);
191 if (!resolved) return;
192 const registration = depsOptimizer.registerMissingImport(
193 entry,
194 resolved,
195 );
196 return registration?.processing;
197 });
198 }),
199 );
200 },
201 resolveId: {
202 filter: RESOLVE_ID_FILTER,
203 handler(source, importer, options) {
204 const resolved = resolve(source);
205 if (!resolved) return;
206 if (
207 this.environment.mode === "dev" &&
208 this.environment.depsOptimizer &&
209 supportsMissingImportRegistration(this.environment)
210 ) {
211 // We are in dev mode (rather than build).
212 // So let's pre-bundle this polyfill entry-point using the dependency optimizer.
213 const { id } =
214 this.environment.depsOptimizer.registerMissingImport(
215 source,
216 resolved,
217 );
218 // We use the unresolved path to the polyfill and let the dependency optimizer's
219 // resolver find the resolved path to the bundled version.
220 return this.resolve(id, importer, options);
221 }
222 return this.resolve(resolved, importer, options);
223 },
224 },
225 },
226 };
227 },
228);
229
230/**
231 * Vite's no-discovery ("explicit") dependency optimizer throws on
232 * `registerMissingImport` by design, and third-party environments may not
233 * implement it at all. Feature-detect before registering polyfill entries.
234 */
235const supportsMissingImportRegistration = (environment: {
236 config: { optimizeDeps?: { noDiscovery?: boolean } };
237 depsOptimizer?: { registerMissingImport?: unknown } | undefined;
238}): boolean =>
239 environment.config.optimizeDeps?.noDiscovery !== true &&
240 typeof environment.depsOptimizer?.registerMissingImport === "function";
241
242export const nodejsImportWarningPlugin = createPlugin(
243 "nodejs-import-warning",
244 (options) => {
245 if (hasNodejsCompat(options.compatibilityFlags, options.compatibilityDate))
246 return;
247 const imports = new Map<string, Set<string>>();
248 let root = process.cwd();
249 return {
250 rolldown: {
251 options(options) {
252 if (options.cwd) {
253 root = options.cwd;
254 }
255 },
256 },
257 vite: {
258 enforce: "pre",
259 configResolved(config) {
260 root = config.root;
261 },
262 },
263 shared: {
264 buildStart() {
265 imports.clear();
266 },
267 resolveId: {
268 filter: { id: NODE_BUILTIN_MODULES_REGEXP },
269 async handler(id, importer) {
270 if (
271 hasNodejsAls(options.compatibilityFlags) &&
272 ASYNC_HOOKS_REGEXP.test(id)
273 )
274 return;
275 if (importer) {
276 if (!imports.has(id)) {
277 imports.set(id, new Set());
278 }
279 imports.get(id)?.add(importer);
280 }
281 return { id, external: true };
282 },
283 },
284 buildEnd() {
285 const filteredImports: Array<{ id: string; importer: string }> = [];
286 for (const [id, importers] of imports.entries()) {
287 for (const importer of importers) {
288 if (this.getModuleInfo(importer)) {
289 filteredImports.push({ id, importer });
290 }
291 }
292 }
293 if (filteredImports.length > 0) {
294 const message = [
295 "Unexpected Node.js imports. ",
296 'Do you need to enable the "nodejs_compat" compatibility flag? ',
297 "Refer to https://developers.cloudflare.com/workers/runtime-apis/nodejs/ for more details.\n",
298 ...filteredImports.map(
299 ({ id, importer }) =>
300 ` - "${id}" imported from "${toPosixPath(path.relative(root, importer))}"\n`,
301 ),
302 ].join("");
303 this.warn(message);
304 }
305 },
306 },
307 };
308 },
309);