Skip to content
This repository was archived by the owner on Mar 13, 2025. It is now read-only.

Commit e507beb

Browse files
committed
Add support for NodeJsCompatModule
...and exhaustiveness checks for module types
1 parent 1d535b7 commit e507beb

File tree

6 files changed

+206
-50
lines changed

6 files changed

+206
-50
lines changed

packages/miniflare/src/plugins/core/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -588,7 +588,7 @@ export function getGlobalServices({
588588

589589
function getWorkerScript(
590590
sourceMapRegistry: SourceMapRegistry,
591-
options: SourceOptions,
591+
options: SourceOptions & { compatibilityFlags?: string[] },
592592
workerIndex: number,
593593
additionalModuleNames: string[]
594594
): { serviceWorkerScript: string } | { modules: Worker_Module[] } {
@@ -622,7 +622,8 @@ function getWorkerScript(
622622
sourceMapRegistry,
623623
modulesRoot,
624624
additionalModuleNames,
625-
options.modulesRules
625+
options.modulesRules,
626+
options.compatibilityFlags
626627
);
627628
// If `script` and `scriptPath` are set, resolve modules in `script`
628629
// against `scriptPath`.

packages/miniflare/src/plugins/core/modules.ts

Lines changed: 105 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,13 @@ const SUGGEST_NODE =
2424
"that uses Node.js built-ins, you'll either need to:" +
2525
"\n- Bundle your Worker, configuring your bundler to polyfill Node.js built-ins" +
2626
"\n- Configure your bundler to load Workers-compatible builds by changing the main fields/conditions" +
27+
"\n- Enable the `nodejs_compat` compatibility flag and use the `NodeJsCompatModule` module type" +
2728
"\n- Find an alternative package that doesn't require Node.js built-ins";
2829

30+
const builtinModulesWithPrefix = builtinModules.concat(
31+
builtinModules.map((module) => `node:${module}`)
32+
);
33+
2934
// Module identifier used if script came from `script` option
3035
export function buildStringScriptPath(workerIndex: number) {
3136
return `<script:${workerIndex}>`;
@@ -38,15 +43,18 @@ export function maybeGetStringScriptPathIndex(
3843
return match === null ? undefined : parseInt(match[1]);
3944
}
4045

41-
export const ModuleRuleTypeSchema = z.union([
42-
z.literal("ESModule"),
43-
z.literal("CommonJS"),
44-
z.literal("Text"),
45-
z.literal("Data"),
46-
z.literal("CompiledWasm"),
46+
export const ModuleRuleTypeSchema = z.enum([
47+
"ESModule",
48+
"CommonJS",
49+
"NodeJsCompatModule",
50+
"Text",
51+
"Data",
52+
"CompiledWasm",
4753
]);
4854
export type ModuleRuleType = z.infer<typeof ModuleRuleTypeSchema>;
4955

56+
type JavaScriptModuleRuleType = "ESModule" | "CommonJS" | "NodeJsCompatModule";
57+
5058
export const ModuleRuleSchema = z.object({
5159
type: ModuleRuleTypeSchema,
5260
include: z.string().array(),
@@ -130,16 +138,22 @@ function getResolveErrorPrefix(referencingPath: string): string {
130138

131139
export class ModuleLocator {
132140
readonly #compiledRules: CompiledModuleRule[];
141+
readonly #nodejsCompat: boolean;
133142
readonly #visitedPaths = new Set<string>();
134143
readonly modules: Worker_Module[] = [];
135144

136145
constructor(
137146
private readonly sourceMapRegistry: SourceMapRegistry,
138147
private readonly modulesRoot: string,
139148
private readonly additionalModuleNames: string[],
140-
rules?: ModuleRule[]
149+
rules?: ModuleRule[],
150+
compatibilityFlags?: string[]
141151
) {
142152
this.#compiledRules = compileModuleRules(rules);
153+
// `nodejs_compat` doesn't have a default-on date, so we know whether it's
154+
// enabled just by looking at flags:
155+
// https://github.com/cloudflare/workerd/blob/edcd0300bc7b8f56040d090177db947edd22f91b/src/workerd/io/compatibility-date.capnp#L237-L240
156+
this.#nodejsCompat = compatibilityFlags?.includes("nodejs_compat") ?? false;
143157
}
144158

145159
visitEntrypoint(code: string, modulePath: string) {
@@ -150,23 +164,32 @@ export class ModuleLocator {
150164
this.#visitedPaths.add(modulePath);
151165

152166
// Entrypoint is always an ES module
153-
this.#visitJavaScriptModule(code, modulePath);
167+
this.#visitJavaScriptModule(code, modulePath, "ESModule");
154168
}
155169

156-
#visitJavaScriptModule(code: string, modulePath: string, esModule = true) {
170+
#visitJavaScriptModule(
171+
code: string,
172+
modulePath: string,
173+
type: JavaScriptModuleRuleType
174+
) {
157175
// Register module
158176
const name = path.relative(this.modulesRoot, modulePath);
159-
code = this.sourceMapRegistry.register(code, modulePath);
160-
this.modules.push(
161-
esModule ? { name, esModule: code } : { name, commonJsModule: code }
177+
const module = createJavaScriptModule(
178+
this.sourceMapRegistry,
179+
code,
180+
name,
181+
modulePath,
182+
type
162183
);
184+
this.modules.push(module);
163185

164186
// Parse code and visit all import/export statements
187+
const isESM = type === "ESModule";
165188
let root;
166189
try {
167190
root = parse(code, {
168191
ecmaVersion: "latest",
169-
sourceType: esModule ? "module" : "script",
192+
sourceType: isESM ? "module" : "script",
170193
locations: true,
171194
});
172195
} catch (e: any) {
@@ -187,18 +210,20 @@ export class ModuleLocator {
187210
// noinspection JSUnusedGlobalSymbols
188211
const visitors = {
189212
ImportDeclaration: (node: estree.ImportDeclaration) => {
190-
this.#visitModule(modulePath, node.source);
213+
this.#visitModule(modulePath, type, node.source);
191214
},
192215
ExportNamedDeclaration: (node: estree.ExportNamedDeclaration) => {
193-
if (node.source != null) this.#visitModule(modulePath, node.source);
216+
if (node.source != null) {
217+
this.#visitModule(modulePath, type, node.source);
218+
}
194219
},
195220
ExportAllDeclaration: (node: estree.ExportAllDeclaration) => {
196-
this.#visitModule(modulePath, node.source);
221+
this.#visitModule(modulePath, type, node.source);
197222
},
198223
ImportExpression: (node: estree.ImportExpression) => {
199-
this.#visitModule(modulePath, node.source);
224+
this.#visitModule(modulePath, type, node.source);
200225
},
201-
CallExpression: esModule
226+
CallExpression: isESM
202227
? undefined
203228
: (node: estree.CallExpression) => {
204229
// TODO: check global?
@@ -208,7 +233,7 @@ export class ModuleLocator {
208233
node.callee.name === "require" &&
209234
argument !== undefined
210235
) {
211-
this.#visitModule(modulePath, argument);
236+
this.#visitModule(modulePath, type, argument);
212237
}
213238
},
214239
};
@@ -217,6 +242,7 @@ export class ModuleLocator {
217242

218243
#visitModule(
219244
referencingPath: string,
245+
referencingType: JavaScriptModuleRuleType,
220246
specExpression: estree.Expression | estree.SpreadElement
221247
) {
222248
if (maybeGetStringScriptPathIndex(referencingPath) !== undefined) {
@@ -238,8 +264,10 @@ export class ModuleLocator {
238264
return ` { type: "${def.type}", path: "${def.path}" }`;
239265
});
240266
const modulesConfig = ` new Miniflare({
267+
...,
241268
modules: [
242-
${modules.join(",\n")}
269+
${modules.join(",\n")},
270+
...
243271
]
244272
})`;
245273

@@ -257,12 +285,14 @@ ${dim(modulesConfig)}`;
257285
}
258286
const spec = specExpression.value;
259287

260-
// `node:`, `cloudflare:` and `workerd:` imports don't need to be included
261-
// explicitly
288+
// `node:` (assuming `nodejs_compat` flag enabled), `cloudflare:` and
289+
// `workerd:` imports don't need to be included explicitly
290+
const isNodeJsCompatModule = referencingType === "NodeJsCompatModule";
262291
if (
263-
spec.startsWith("node:") ||
292+
(this.#nodejsCompat && spec.startsWith("node:")) ||
264293
spec.startsWith("cloudflare:") ||
265294
spec.startsWith("workerd:") ||
295+
(isNodeJsCompatModule && builtinModulesWithPrefix.includes(spec)) ||
266296
this.additionalModuleNames.includes(spec)
267297
) {
268298
return;
@@ -282,7 +312,7 @@ ${dim(modulesConfig)}`;
282312
);
283313
if (rule === undefined) {
284314
const prefix = getResolveErrorPrefix(referencingPath);
285-
const isBuiltin = builtinModules.includes(spec);
315+
const isBuiltin = builtinModulesWithPrefix.includes(spec);
286316
const suggestion = isBuiltin ? SUGGEST_NODE : SUGGEST_BUNDLE;
287317
throw new MiniflareCoreError(
288318
"ERR_MODULE_RULE",
@@ -294,10 +324,10 @@ ${dim(modulesConfig)}`;
294324
const data = readFileSync(identifier);
295325
switch (rule.type) {
296326
case "ESModule":
297-
this.#visitJavaScriptModule(data.toString("utf8"), identifier);
298-
break;
299327
case "CommonJS":
300-
this.#visitJavaScriptModule(data.toString("utf8"), identifier, false);
328+
case "NodeJsCompatModule":
329+
const code = data.toString("utf8");
330+
this.#visitJavaScriptModule(code, identifier, rule.type);
301331
break;
302332
case "Text":
303333
this.modules.push({ name, text: data.toString("utf8") });
@@ -310,11 +340,32 @@ ${dim(modulesConfig)}`;
310340
break;
311341
default:
312342
// `type` should've been validated against `ModuleRuleTypeSchema`
313-
assert.fail(`Unreachable: ${rule.type} modules are unsupported`);
343+
const exhaustive: never = rule.type;
344+
assert.fail(`Unreachable: ${exhaustive} modules are unsupported`);
314345
}
315346
}
316347
}
317348

349+
function createJavaScriptModule(
350+
sourceMapRegistry: SourceMapRegistry,
351+
code: string,
352+
name: string,
353+
modulePath: string,
354+
type: JavaScriptModuleRuleType
355+
): Worker_Module {
356+
code = sourceMapRegistry.register(code, modulePath);
357+
if (type === "ESModule") {
358+
return { name, esModule: code };
359+
} else if (type === "CommonJS") {
360+
return { name, commonJsModule: code };
361+
} else if (type === "NodeJsCompatModule") {
362+
return { name, nodeJsCompatModule: code };
363+
}
364+
// noinspection UnnecessaryLocalVariableJS
365+
const exhaustive: never = type;
366+
assert.fail(`Unreachable: ${exhaustive} JavaScript modules are unsupported`);
367+
}
368+
318369
const encoder = new TextEncoder();
319370
const decoder = new TextDecoder();
320371
export function contentsToString(contents: string | Uint8Array): string {
@@ -331,16 +382,18 @@ export function convertModuleDefinition(
331382
// The runtime requires module identifiers to be relative paths
332383
let name = path.relative(modulesRoot, def.path);
333384
if (path.sep === "\\") name = name.replaceAll("\\", "/");
334-
let contents = def.contents ?? readFileSync(def.path);
385+
const contents = def.contents ?? readFileSync(def.path);
335386
switch (def.type) {
336387
case "ESModule":
337-
contents = contentsToString(contents);
338-
contents = sourceMapRegistry.register(contents, def.path);
339-
return { name, esModule: contents };
340388
case "CommonJS":
341-
contents = contentsToString(contents);
342-
contents = sourceMapRegistry.register(contents, def.path);
343-
return { name, commonJsModule: contents };
389+
case "NodeJsCompatModule":
390+
return createJavaScriptModule(
391+
sourceMapRegistry,
392+
contentsToString(contents),
393+
name,
394+
def.path,
395+
def.type
396+
);
344397
case "Text":
345398
return { name, text: contentsToString(contents) };
346399
case "Data":
@@ -349,22 +402,32 @@ export function convertModuleDefinition(
349402
return { name, wasm: contentsToArray(contents) };
350403
default:
351404
// `type` should've been validated against `ModuleRuleTypeSchema`
352-
assert.fail(`Unreachable: ${def.type} modules are unsupported`);
405+
const exhaustive: never = def.type;
406+
assert.fail(`Unreachable: ${exhaustive} modules are unsupported`);
353407
}
354408
}
355409
function convertWorkerModule(mod: Worker_Module): ModuleDefinition {
356410
const path = mod.name;
357411
assert(path !== undefined);
358412

359-
if ("esModule" in mod) return { path, type: "ESModule" };
360-
else if ("commonJsModule" in mod) return { path, type: "CommonJS" };
361-
else if ("text" in mod) return { path, type: "Text" };
362-
else if ("data" in mod) return { path, type: "Data" };
363-
else if ("wasm" in mod) return { path, type: "CompiledWasm" };
413+
// Mark keys in `mod` as required for exhaustiveness checking
414+
const m = mod as Required<Worker_Module>;
415+
416+
if ("esModule" in m) return { path, type: "ESModule" };
417+
else if ("commonJsModule" in m) return { path, type: "CommonJS" };
418+
else if ("nodeJsCompatModule" in m)
419+
return { path, type: "NodeJsCompatModule" };
420+
else if ("text" in m) return { path, type: "Text" };
421+
else if ("data" in m) return { path, type: "Data" };
422+
else if ("wasm" in m) return { path, type: "CompiledWasm" };
364423

365424
// This function is only used for building error messages including
366425
// generated modules, and these are the types we generate.
426+
assert(!("json" in m), "Unreachable: json modules aren't generated");
427+
const exhaustive: never = m;
367428
assert.fail(
368-
`Unreachable: [${Object.keys(mod).join(", ")}] modules are unsupported`
429+
`Unreachable: [${Object.keys(exhaustive).join(
430+
", "
431+
)}] modules are unsupported`
369432
);
370433
}

packages/miniflare/src/runtime/config/workerd.capnp

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,12 @@ struct Worker {
246246

247247
json @6 :Text;
248248
# Importing this will produce the result of parsing the given text as JSON.
249+
250+
nodeJsCompatModule @7 :Text;
251+
# A Node.js module is a specialization of a commonJsModule that:
252+
# (a) allows for importing Node.js-compat built-ins without the node: specifier-prefix
253+
# (b) exposes the subset of common Node.js globals such as process, Buffer, etc that
254+
# we implement in the workerd runtime.
249255
}
250256
}
251257

@@ -337,7 +343,18 @@ struct Worker {
337343
# namespace will be converted into HTTP requests targetting the given
338344
# service name.
339345

340-
# TODO(someday): dispatch, analyticsEngine, other new features
346+
fromEnvironment @16 :Text;
347+
# Takes the value of an environment variable from the system. The value specified here is
348+
# the name of a system environment variable. The value of the binding is obtained by invoking
349+
# `getenv()` with that name. If the environment variable isn't set, the binding value is
350+
# `null`.
351+
352+
analyticsEngine @17 :ServiceDesignator;
353+
# A binding for Analytics Engine. Allows workers to store information through Analytics Engine Events.
354+
# workerd will forward AnalyticsEngineEvents to designated service in the body of HTTP requests
355+
# This binding is subject to change and requires the `--experimental` flag
356+
357+
# TODO(someday): dispatch, other new features
341358
}
342359

343360
struct Type {
@@ -358,6 +375,7 @@ struct Worker {
358375
r2Bucket @9 :Void;
359376
r2Admin @10 :Void;
360377
queue @11 :Void;
378+
analyticsEngine @12 : Void;
361379
}
362380
}
363381

@@ -783,7 +801,7 @@ struct TlsOptions {
783801

784802
minVersion @4 :Version = goodDefault;
785803
# Minimum TLS version that will be allowed. Generally you should not override this unless you
786-
# have unusual backwards-compatibilty needs.
804+
# have unusual backwards-compatibility needs.
787805

788806
enum Version {
789807
goodDefault @0;

0 commit comments

Comments
 (0)