Skip to content

Commit 693d63e

Browse files
fix: refactor Node.js compat support to ensure all polyfills are pre-bundled before the first request (#8176)
* fix: refactor Node.js compat support to ensure all polyfills are pre-bundled before the first request * fixups after PR review * add assertion * rename globInject variable
1 parent 419b93d commit 693d63e

File tree

6 files changed

+101
-119
lines changed

6 files changed

+101
-119
lines changed

.changeset/ninety-phones-tell.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cloudflare/vite-plugin": patch
3+
---
4+
5+
fix: refactor Node.js compat support to ensure all polyfills are pre-bundled before the first request

packages/vite-plugin-cloudflare/src/cloudflare-environment.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import assert from "node:assert";
22
import * as vite from "vite";
33
import { INIT_PATH, UNKNOWN_HOST } from "./shared";
4-
import { getOutputDirectory, nodeBuiltInModules } from "./utils";
4+
import { getOutputDirectory } from "./utils";
55
import type { ResolvedPluginConfig, WorkerConfig } from "./plugin-config";
66
import type { Fetcher } from "@cloudflare/workers-types/experimental";
77
import type {
@@ -134,8 +134,6 @@ export function createCloudflareEnvironmentOptions(
134134
conditions: [...defaultConditions, "development|production"],
135135
// The Cloudflare ones are proper builtins in the environment
136136
builtins: [...cloudflareBuiltInModules],
137-
// The Node.js ones are no proper builtins in the environment since we also polyfill them using unenv
138-
external: [...nodeBuiltInModules],
139137
},
140138
dev: {
141139
createEnvironment(name, config) {
@@ -164,10 +162,6 @@ export function createCloudflareEnvironmentOptions(
164162
// Note: ssr pre-bundling is opt-in and we need to enable it by setting `noDiscovery` to false
165163
noDiscovery: false,
166164
entries: workerConfig.main,
167-
exclude: [
168-
// we have to exclude all node modules to work in dev-mode not just the unenv externals...
169-
...nodeBuiltInModules,
170-
],
171165
esbuildOptions: {
172166
platform: "neutral",
173167
conditions: [...defaultConditions, "development"],

packages/vite-plugin-cloudflare/src/index.ts

Lines changed: 50 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import assert from "node:assert";
22
import * as fs from "node:fs";
3+
import { builtinModules } from "node:module";
34
import * as path from "node:path";
45
import { createMiddleware } from "@hattip/adapter-node";
56
import MagicString from "magic-string";
@@ -16,11 +17,10 @@ import {
1617
getPreviewMiniflareOptions,
1718
} from "./miniflare-options";
1819
import {
19-
getNodeCompatAliases,
20+
getNodeCompatEntries,
2021
getNodeCompatExternals,
2122
injectGlobalCode,
2223
isNodeCompat,
23-
maybeStripNodeJsVirtualPrefix,
2424
resolveNodeJSImport,
2525
} from "./node-js-compat";
2626
import { resolvePluginConfig } from "./plugin-config";
@@ -407,67 +407,68 @@ export function cloudflare(pluginConfig: PluginConfig = {}): vite.Plugin[] {
407407
// Skip this whole plugin if we are in preview mode
408408
return !env.isPreview;
409409
},
410-
config() {
411-
// Configure Vite with the Node.js polyfill aliases
412-
// We have to do this across the whole Vite config because it is not possible to do it per Environment.
413-
// These aliases are to virtual modules that then get Environment specific handling in the resolveId hook.
414-
return {
415-
resolve: {
416-
alias: getNodeCompatAliases(),
417-
},
418-
};
419-
},
420-
configEnvironment(environmentName) {
421-
// Ignore Node.js external modules when building environments that use Node.js compat.
422-
const workerConfig = getWorkerConfig(environmentName);
423-
if (isNodeCompat(workerConfig)) {
410+
configEnvironment(name) {
411+
// Only configure this environment if it is a Worker using Node.js compatibility.
412+
if (isNodeCompat(getWorkerConfig(name))) {
424413
return {
425-
build: {
426-
rollupOptions: {
427-
external: getNodeCompatExternals(),
428-
},
414+
resolve: {
415+
builtins: getNodeCompatExternals(),
416+
},
417+
optimizeDeps: {
418+
// This is a list of dependency entry-points that should be pre-bundled.
419+
// In this case we provide a list of all the possible polyfills so that they are pre-bundled,
420+
// ready ahead the first request to the dev server.
421+
// Without this the dependency optimizer will try to bundle them on-the-fly in the middle of the first request,
422+
// which can potentially cause problems if it leads to previous pre-bundling to become stale and needing to be reloaded.
423+
include: [...getNodeCompatEntries()],
424+
// This is a list of module specifiers that the dependency optimizer should not follow when doing import analysis.
425+
// In this case we provide a list of all the Node.js modules, both those built-in to workerd and those that will be polyfilled.
426+
// Obviously we don't want/need the optimizer to try to process modules that are built-in;
427+
// But also we want to avoid following the ones that are polyfilled since the dependency-optimizer import analyzer does not
428+
// resolve these imports using our `resolveId()` hook causing the optimization step to fail.
429+
exclude: [
430+
...builtinModules,
431+
...builtinModules.map((m) => `node:${m}`),
432+
],
429433
},
430434
};
431435
}
432436
},
437+
applyToEnvironment(environment) {
438+
// Only run this plugin's runtime hooks if it is a Worker using Node.js compatibility.
439+
return isNodeCompat(getWorkerConfig(environment.name));
440+
},
441+
// We need the resolver from this plugin to run before built-in ones, otherwise Vite's built-in
442+
// resolver will try to externalize the Node.js module imports (e.g. `perf_hooks` and `node:tty`)
443+
// rather than allowing the resolve hook here to alias then to polyfills.
444+
enforce: "pre",
433445
async resolveId(source, importer, options) {
434-
// Handle the virtual modules that come from Node.js compat aliases.
435-
const strippedSource = maybeStripNodeJsVirtualPrefix(source);
436-
if (!strippedSource) {
437-
return;
446+
// See if we can map the `source` to a Node.js compat alias.
447+
const result = resolveNodeJSImport(source);
448+
if (!result) {
449+
// The source is not a Node.js compat alias so just pass it through
450+
return this.resolve(source, importer, options);
438451
}
439452

440-
const workerConfig = getWorkerConfig(this.environment.name);
441-
if (!isNodeCompat(workerConfig)) {
442-
// We are not in Node.js compat mode, so we must not try to apply any unenv aliases.
443-
// Just resolve the module id as normal.
444-
return this.resolve(strippedSource, importer, options);
445-
}
446-
447-
// Resolve this id following any unenv aliases.
448-
const { unresolved, resolved } = resolveNodeJSImport(strippedSource);
449-
450-
if (this.environment.mode === "dev" && this.environment.depsOptimizer) {
451-
// Make sure the dependency optimizer is aware of this aliased import
452-
const optimized =
453-
this.environment.depsOptimizer.registerMissingImport(
454-
unresolved,
455-
resolved
456-
).id;
457-
// We must pass the id from the depsOptimizer through the rest of the
458-
// resolving pipeline to ensure that the optimized version gets used.
459-
return this.resolve(optimized, importer, options);
453+
if (this.environment.mode === "dev") {
454+
assert(
455+
this.environment.depsOptimizer,
456+
"depsOptimizer is required in dev mode"
457+
);
458+
// We are in dev mode (rather than build).
459+
// Node.js compat polyfill modules have already been pre-bundled,
460+
// so we can use the unresolved path to the polyfill
461+
// and let the dependency optimizer resolve the path to the pre-bundled version.
462+
return this.resolve(result.unresolved, importer, options);
460463
}
461464

462-
return this.resolve(resolved, importer, options);
465+
// We are in build mode so return the absolute path to the polyfill.
466+
return this.resolve(result.resolved, importer, options);
463467
},
464468
async transform(code, id) {
465469
// Inject the Node.js compat globals into the entry module for Node.js compat environments.
466470
const workerConfig = getWorkerConfig(this.environment.name);
467-
if (!isNodeCompat(workerConfig)) {
468-
return;
469-
}
470-
471+
assert(workerConfig, "Expected a worker config");
471472
const resolvedId = await this.resolve(workerConfig.main);
472473
if (id === resolvedId?.id) {
473474
return injectGlobalCode(id, code);

packages/vite-plugin-cloudflare/src/miniflare-options.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import {
1616
} from "./constants";
1717
import { getWorkerConfigPaths } from "./deploy-config";
1818
import { MODULE_PATTERN } from "./shared";
19-
import { nodeBuiltInModules } from "./utils";
2019
import type { CloudflareDevEnvironment } from "./cloudflare-environment";
2120
import type {
2221
PersistState,
@@ -331,9 +330,7 @@ export function getDevMiniflareOptions(
331330

332331
const shouldExternalize =
333332
// Worker modules (CompiledWasm, Text, Data)
334-
moduleRE.test(moduleId) ||
335-
// Node.js builtin node modules (they will be resolved to unenv aliases)
336-
nodeBuiltInModules.has(moduleId);
333+
moduleRE.test(moduleId);
337334

338335
if (shouldExternalize) {
339336
const result = {

packages/vite-plugin-cloudflare/src/node-js-compat.ts

Lines changed: 44 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@ const { env } = defineEnv({
1111
presets: [cloudflare],
1212
});
1313

14-
const CLOUDFLARE_VIRTUAL_PREFIX = "\0__CLOUDFLARE_NODEJS_COMPAT__";
15-
1614
/**
1715
* Returns true if the given combination of compat dates and flags means that we need Node.js compatibility.
1816
*/
@@ -43,21 +41,47 @@ export function isNodeCompat(
4341
}
4442

4543
/**
46-
* If the current environment needs Node.js compatibility,
47-
* then inject the necessary global polyfills into the code.
44+
* Gets a set of module specifiers for all possible Node.js compat polyfill entry-points
45+
*/
46+
export function getNodeCompatEntries() {
47+
const entries = new Set<string>(Object.values(env.alias));
48+
for (const globalInject of Object.values(env.inject)) {
49+
if (typeof globalInject === "string") {
50+
entries.add(globalInject);
51+
} else {
52+
assert(
53+
globalInject[0] !== undefined,
54+
"Expected first element of globalInject to be defined"
55+
);
56+
entries.add(globalInject[0]);
57+
}
58+
}
59+
for (const external of env.external) {
60+
entries.delete(external);
61+
}
62+
return entries;
63+
}
64+
65+
/**
66+
* Gets the necessary global polyfills to inject into the entry-point of the user's code.
4867
*/
4968
export function injectGlobalCode(id: string, code: string) {
5069
const injectedCode = Object.entries(env.inject)
5170
.map(([globalName, globalInject]) => {
5271
if (typeof globalInject === "string") {
5372
const moduleSpecifier = globalInject;
5473
// the mapping is a simple string, indicating a default export, so the string is just the module specifier.
55-
return `import var_${globalName} from "${CLOUDFLARE_VIRTUAL_PREFIX}${moduleSpecifier}";\nglobalThis.${globalName} = var_${globalName};\n`;
74+
return `import var_${globalName} from "${moduleSpecifier}";\nglobalThis.${globalName} = var_${globalName};\n`;
5675
}
5776

5877
// the mapping is a 2 item tuple, indicating a named export, made up of a module specifier and an export name.
5978
const [moduleSpecifier, exportName] = globalInject;
60-
return `import var_${globalName} from "${CLOUDFLARE_VIRTUAL_PREFIX}${moduleSpecifier}";\nglobalThis.${globalName} = var_${globalName}.${exportName};\n`;
79+
assert(
80+
moduleSpecifier !== undefined,
81+
"Expected moduleSpecifier to be defined"
82+
);
83+
assert(exportName !== undefined, "Expected exportName to be defined");
84+
return `import var_${globalName} from "${moduleSpecifier}";\nglobalThis.${globalName} = var_${globalName}.${exportName};\n`;
6185
})
6286
.join("\n");
6387

@@ -70,63 +94,29 @@ export function injectGlobalCode(id: string, code: string) {
7094
}
7195

7296
/**
73-
* We only want to alias Node.js built-ins if the environment has Node.js compatibility turned on.
74-
* But Vite only allows us to configure aliases at the shared options level, not per environment.
75-
* So instead we alias these to a virtual module, which are then handled with environment specific code in the `resolveId` handler
76-
*/
77-
export function getNodeCompatAliases() {
78-
const aliases: Record<string, string> = {};
79-
Object.keys(env.alias).forEach((key) => {
80-
// Don't create aliases for modules that are already marked as external
81-
if (!env.external.includes(key)) {
82-
aliases[key] = CLOUDFLARE_VIRTUAL_PREFIX + key;
83-
}
84-
});
85-
return aliases;
86-
}
87-
88-
/**
89-
* Get an array of modules that should be considered external.
97+
* Gets an array of modules that should be considered external.
9098
*/
9199
export function getNodeCompatExternals(): string[] {
92100
return env.external;
93101
}
94102

95103
/**
96-
* If the `source` module id starts with the virtual prefix then strip it and return the rest of the id.
97-
* Otherwise return undefined.
98-
*/
99-
export function maybeStripNodeJsVirtualPrefix(
100-
source: string
101-
): string | undefined {
102-
return source.startsWith(CLOUDFLARE_VIRTUAL_PREFIX)
103-
? source.slice(CLOUDFLARE_VIRTUAL_PREFIX.length)
104-
: undefined;
105-
}
106-
107-
/**
108-
* Resolve the source import to an absolute path, potentially via its alias.
104+
* Resolves the `source` to a Node.js compat alias if possible.
105+
*
106+
* If there is an alias, the return value is an object with:
107+
* - `unresolved`: a bare import path to the polyfill (e.g. `unenv/runtime/node/crypto`)
108+
* - `resolved`: an absolute path to the polyfill (e.g. `/path/to/project/node_modules/unenv/runtime/node/child_process/index.mjs`)
109109
*/
110110
export function resolveNodeJSImport(source: string) {
111111
const alias = env.alias[source];
112+
113+
// These aliases must be resolved from the context of this plugin since the alias will refer to one of the
114+
// `@cloudflare/unenv-preset` or the `unenv` packages, which are direct dependencies of this package,
115+
// and not the user's project.
112116
if (alias) {
113-
// If `alias` is `undefined` then `source` was injected in the `transform` hook and we can resolve it directly.
114-
// Else we resolve the `alias` instead of the `source`.
115-
assert(
116-
!env.external.includes(alias),
117-
`Unexpected unenv alias to external module: ${source} -> ${alias}`
118-
);
119-
source = alias;
117+
return {
118+
unresolved: alias,
119+
resolved: resolvePathSync(alias, { url: import.meta.url }),
120+
};
120121
}
121-
// Resolve to an absolute path using the path to this file as the resolution starting point.
122-
// This is essential so that we can find the `@cloudflare/unenv-preset` and `unenv` packages,
123-
// which are dependencies of this package but may not be direct dependencies of the user's project.
124-
const resolved = resolvePathSync(source, {
125-
url: import.meta.url,
126-
});
127-
128-
return {
129-
unresolved: source,
130-
resolved,
131-
};
132122
}

packages/vite-plugin-cloudflare/src/utils.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,8 @@
1-
import { builtinModules } from "node:module";
21
import * as path from "node:path";
32
import { Request as MiniflareRequest } from "miniflare";
43
import * as vite from "vite";
54
import type { IncomingHttpHeaders } from "node:http";
65

7-
export const nodeBuiltInModules = new Set(
8-
builtinModules.concat(builtinModules.map((m) => `node:${m}`))
9-
);
10-
116
export function getOutputDirectory(
127
userConfig: vite.UserConfig,
138
environmentName: string

0 commit comments

Comments
 (0)