Skip to content

Commit 910007b

Browse files
vite-plugin: show warning if the user has forgotten to turn on nodejs_compat (#8207)
* Add warnings to `vite dev` for when code (or a library) uses Node.js APIs without setting the `nodejs_compat` flag * fixups
1 parent a24cf19 commit 910007b

File tree

11 files changed

+240
-12
lines changed

11 files changed

+240
-12
lines changed

.changeset/loud-worlds-ask.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+
Show warning if the user has forgotten to turn on nodejs_compat
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { readFileSync, writeFileSync } from "node:fs";
2+
import { join } from "node:path";
3+
import dedent from "ts-dedent";
4+
import { expect, test, vi } from "vitest";
5+
import { serverLogs } from "../../../__test-utils__";
6+
7+
test("should display warnings if nodejs_compat is missing", async () => {
8+
await vi.waitFor(async () => {
9+
expect(serverLogs.warns[0]?.replaceAll("\\", "/")).toContain(
10+
dedent`
11+
Unexpected Node.js imports for environment "worker". Do you need to enable the "nodejs_compat" compatibility flag?
12+
Refer to https://developers.cloudflare.com/workers/runtime-apis/nodejs/ for more details.
13+
- "node:assert/strict" imported from "worker-warnings/index.ts"
14+
- "perf_hooks" imported from "worker-warnings/index.ts"
15+
`
16+
);
17+
});
18+
});

packages/vite-plugin-cloudflare/playground/node-compat/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,11 @@
3333
"random:build": "vite build --app -c vite.config.worker-random.ts",
3434
"random:dev": "vite dev -c vite.config.worker-random.ts",
3535
"random:preview": "vite preview -c vite.config.worker-random.ts",
36-
"random:test": "vitest run -c ../vitest.config.e2e.ts worker-random"
36+
"random:test": "vitest run -c ../vitest.config.e2e.ts worker-random",
37+
"warnings:build": "vite build --app -c vite.config.worker-warnings.ts",
38+
"warnings:dev": "vite dev -c vite.config.worker-warnings.ts",
39+
"warnings:preview": "vite preview -c vite.config.worker-warnings.ts",
40+
"warnings:test": "vitest run -c ../vitest.config.e2e.ts worker-warnings"
3741
},
3842
"devDependencies": {
3943
"@cloudflare/vite-plugin": "workspace:*",

packages/vite-plugin-cloudflare/playground/node-compat/tsconfig.worker.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"worker-crypto",
77
"worker-postgres",
88
"worker-process",
9-
"worker-random"
9+
"worker-random",
10+
"worker-warnings"
1011
]
1112
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { cloudflare } from "@cloudflare/vite-plugin";
2+
import { defineConfig } from "vite";
3+
4+
export default defineConfig({
5+
build: {
6+
outDir: "dist/worker-warnings",
7+
},
8+
plugins: [
9+
cloudflare({
10+
configPath: "./worker-warnings/wrangler.toml",
11+
inspectorPort: false,
12+
persistState: false,
13+
}),
14+
],
15+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import assert from "node:assert/strict";
2+
// Check that we can actually import unenv polyfilled modules in user source.
3+
import "perf_hooks";
4+
5+
export default {
6+
async fetch() {
7+
assert(true, "the world is broken");
8+
return new Response("OK!");
9+
},
10+
} satisfies ExportedHandler;
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
name = "worker"
2+
main = "./index.ts"
3+
compatibility_date = "2024-12-30"
4+
# Do not turn on Node.js compatibility to trigger the warnings
5+
# compatibility_flags = ["nodejs_compat"]

packages/vite-plugin-cloudflare/playground/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"@cloudflare/vite-plugin": "workspace:*",
1515
"@cloudflare/workers-tsconfig": "workspace:*",
1616
"playwright-chromium": "catalog:default",
17+
"ts-dedent": "^2.2.0",
1718
"typescript": "catalog:default"
1819
}
1920
}

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

Lines changed: 121 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import assert from "node:assert";
22
import * as fs from "node:fs";
33
import * as fsp from "node:fs/promises";
4-
import { builtinModules } from "node:module";
54
import * as path from "node:path";
65
import { createMiddleware } from "@hattip/adapter-node";
76
import MagicString from "magic-string";
@@ -31,6 +30,9 @@ import {
3130
injectGlobalCode,
3231
isNodeCompat,
3332
nodeCompatExternals,
33+
NODEJS_MODULES_RE,
34+
nodejsBuiltins,
35+
NodeJsCompatWarnings,
3436
resolveNodeJSImport,
3537
} from "./node-js-compat";
3638
import { resolvePluginConfig } from "./plugin-config";
@@ -43,7 +45,11 @@ import {
4345
} from "./utils";
4446
import { handleWebSocket } from "./websockets";
4547
import { getWarningForWorkersConfigs } from "./workers-configs";
46-
import type { PluginConfig, ResolvedPluginConfig } from "./plugin-config";
48+
import type {
49+
PluginConfig,
50+
ResolvedPluginConfig,
51+
WorkerConfig,
52+
} from "./plugin-config";
4753
import type { Unstable_RawConfig } from "wrangler";
4854

4955
export type { PluginConfig } from "./plugin-config";
@@ -66,6 +72,8 @@ export function cloudflare(pluginConfig: PluginConfig = {}): vite.Plugin[] {
6672

6773
const additionalModulePaths = new Set<string>();
6874

75+
const nodeJsCompatWarningsMap = new Map<WorkerConfig, NodeJsCompatWarnings>();
76+
6977
// This is set when the client environment is built to determine if the entry Worker should include assets
7078
let hasClientBuild = false;
7179

@@ -522,10 +530,7 @@ export function cloudflare(pluginConfig: PluginConfig = {}): vite.Plugin[] {
522530
// Obviously we don't want/need the optimizer to try to process modules that are built-in;
523531
// But also we want to avoid following the ones that are polyfilled since the dependency-optimizer import analyzer does not
524532
// resolve these imports using our `resolveId()` hook causing the optimization step to fail.
525-
exclude: [
526-
...builtinModules,
527-
...builtinModules.map((m) => `node:${m}`),
528-
],
533+
exclude: [...nodejsBuiltins],
529534
},
530535
};
531536
}
@@ -619,7 +624,7 @@ export function cloudflare(pluginConfig: PluginConfig = {}): vite.Plugin[] {
619624
}
620625

621626
const workerNames = workerConfigs.map((worker) => {
622-
assert(worker.name);
627+
assert(worker.name, "Expected the Worker to have a name");
623628
return worker.name;
624629
});
625630

@@ -639,6 +644,115 @@ export function cloudflare(pluginConfig: PluginConfig = {}): vite.Plugin[] {
639644
});
640645
},
641646
},
647+
// Plugin to warn if Node.js APIs are being used without nodejs_compat turned on
648+
{
649+
name: "vite-plugin-cloudflare:nodejs-compat-warnings",
650+
apply(_config, env) {
651+
// Skip this whole plugin if we are in preview mode
652+
return !env.isPreview;
653+
},
654+
configEnvironment(environmentName) {
655+
const workerConfig = getWorkerConfig(environmentName);
656+
if (workerConfig && !isNodeCompat(workerConfig)) {
657+
return {
658+
optimizeDeps: {
659+
esbuildOptions: {
660+
plugins: [
661+
{
662+
name: "vite-plugin-cloudflare:nodejs-compat-warnings-resolver",
663+
setup(build) {
664+
build.onResolve(
665+
{ filter: NODEJS_MODULES_RE },
666+
({ path, importer }) => {
667+
// We have to delay getting this `nodeJsCompatWarnings` from the `nodeJsCompatWarningsMap` until we are in this function.
668+
// It has not been added to the map until the `configureServer()` hook is called, which is after the `configEnvironment()` hook.
669+
const nodeJsCompatWarnings =
670+
nodeJsCompatWarningsMap.get(workerConfig);
671+
assert(
672+
nodeJsCompatWarnings,
673+
`expected nodeJsCompatWarnings to be defined for Worker "${workerConfig.name}"`
674+
);
675+
nodeJsCompatWarnings.registerImport(path, importer);
676+
// Mark this path as external to avoid messy unwanted resolve errors.
677+
// It will fail at runtime but we will log warnings to the user.
678+
return { path, external: true };
679+
}
680+
);
681+
build.onEnd(() => {
682+
const nodeJsCompatWarnings =
683+
nodeJsCompatWarningsMap.get(workerConfig);
684+
if (nodeJsCompatWarnings) {
685+
nodeJsCompatWarnings.renderWarnings();
686+
}
687+
});
688+
},
689+
},
690+
],
691+
},
692+
},
693+
};
694+
}
695+
},
696+
configureServer(viteDevServer) {
697+
// Create a nodeJsCompatWarnings object for each Worker environment that has Node.js compat turned off.
698+
for (const environment of Object.values(viteDevServer.environments)) {
699+
const workerConfig = getWorkerConfig(environment.name);
700+
if (workerConfig && !isNodeCompat(workerConfig)) {
701+
nodeJsCompatWarningsMap.set(
702+
workerConfig,
703+
new NodeJsCompatWarnings(environment)
704+
);
705+
}
706+
}
707+
},
708+
buildStart() {
709+
const workerConfig = getWorkerConfig(this.environment.name);
710+
if (workerConfig && !isNodeCompat(workerConfig)) {
711+
nodeJsCompatWarningsMap.set(
712+
workerConfig,
713+
new NodeJsCompatWarnings(this.environment)
714+
);
715+
}
716+
},
717+
buildEnd() {
718+
const workerConfig = getWorkerConfig(this.environment.name);
719+
if (workerConfig && !isNodeCompat(workerConfig)) {
720+
const nodeJsCompatWarnings =
721+
nodeJsCompatWarningsMap.get(workerConfig);
722+
assert(
723+
nodeJsCompatWarnings,
724+
`expected nodeJsCompatWarnings to be defined for Worker "${workerConfig.name}"`
725+
);
726+
nodeJsCompatWarnings.renderWarnings();
727+
}
728+
},
729+
// We must ensure that the `resolveId` hook runs before the built-in ones otherwise we
730+
// never see the Node.js built-in imports since they get handled by default Vite behavior.
731+
enforce: "pre",
732+
async resolveId(source, importer) {
733+
const workerConfig = getWorkerConfig(this.environment.name);
734+
if (workerConfig && !isNodeCompat(workerConfig)) {
735+
const nodeJsCompatWarnings =
736+
nodeJsCompatWarningsMap.get(workerConfig);
737+
assert(
738+
nodeJsCompatWarnings,
739+
`expected nodeJsCompatWarnings to be defined for Worker "${workerConfig.name}"`
740+
);
741+
if (nodejsBuiltins.has(source)) {
742+
nodeJsCompatWarnings.registerImport(source, importer);
743+
// We don't have a natural place to trigger the rendering of the warnings
744+
// So we trigger a rendering to happen soon after this round of processing.
745+
nodeJsCompatWarnings.renderWarningsOnIdle();
746+
// Mark this path as external to avoid messy unwanted resolve errors.
747+
// It will fail at runtime but we will log warnings to the user.
748+
return {
749+
id: source,
750+
external: true,
751+
};
752+
}
753+
}
754+
},
755+
},
642756
];
643757

644758
function getWorkerConfig(environmentName: string) {

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

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import assert from "node:assert";
2+
import { builtinModules } from "node:module";
3+
import path from "node:path";
24
import { cloudflare } from "@cloudflare/unenv-preset";
35
import MagicString from "magic-string";
46
import { getNodeCompat } from "miniflare";
57
import { resolvePathSync } from "mlly";
68
import { defineEnv } from "unenv";
9+
import * as vite from "vite";
710
import type { WorkerConfig } from "./plugin-config";
811

912
const { env } = defineEnv({
@@ -14,12 +17,21 @@ const { env } = defineEnv({
1417
export const nodeCompatExternals = new Set(env.external);
1518
export const nodeCompatEntries = getNodeCompatEntries();
1619

20+
/**
21+
* All the Node.js modules including their `node:...` aliases.
22+
*/
23+
export const nodejsBuiltins = new Set([
24+
...builtinModules,
25+
...builtinModules.map((m) => `node:${m}`),
26+
]);
27+
export const NODEJS_MODULES_RE = new RegExp(
28+
`^(node:)?(${builtinModules.join("|")})$`
29+
);
30+
1731
/**
1832
* Returns true if the given combination of compat dates and flags means that we need Node.js compatibility.
1933
*/
20-
export function isNodeCompat(
21-
workerConfig: WorkerConfig | undefined
22-
): workerConfig is WorkerConfig {
34+
export function isNodeCompat(workerConfig: WorkerConfig | undefined) {
2335
if (workerConfig === undefined) {
2436
return false;
2537
}
@@ -130,3 +142,43 @@ function getNodeCompatEntries() {
130142

131143
return entries;
132144
}
145+
146+
export class NodeJsCompatWarnings {
147+
private sources = new Map<string, Set<string>>();
148+
private timer: NodeJS.Timeout | undefined;
149+
150+
constructor(private readonly environment: vite.Environment) {}
151+
152+
registerImport(source: string, importer = "<unknown>") {
153+
const importers = this.sources.get(source) ?? new Set();
154+
this.sources.set(source, importers);
155+
importers.add(importer);
156+
}
157+
158+
renderWarningsOnIdle() {
159+
if (this.timer) {
160+
clearTimeout(this.timer);
161+
}
162+
this.timer = setTimeout(() => {
163+
this.renderWarnings();
164+
this.timer = undefined;
165+
}, 500);
166+
}
167+
168+
renderWarnings() {
169+
if (this.sources.size > 0) {
170+
let message =
171+
`\n\nUnexpected Node.js imports for environment "${this.environment.name}". Do you need to enable the "nodejs_compat" compatibility flag?\n` +
172+
"Refer to https://developers.cloudflare.com/workers/runtime-apis/nodejs/ for more details.\n";
173+
this.sources.forEach((importers, source) => {
174+
importers.forEach((importer) => {
175+
message += ` - "${source}" imported from "${path.relative(this.environment.config.root, importer)}"\n`;
176+
});
177+
});
178+
this.environment.logger.warn(message, {
179+
environment: this.environment.name,
180+
});
181+
this.sources.clear();
182+
}
183+
}
184+
}

0 commit comments

Comments
 (0)