Skip to content

Commit 01298e5

Browse files
authored
feat(vm): support require(esm) in vm pools (#10829)
1 parent 3850323 commit 01298e5

13 files changed

Lines changed: 1004 additions & 90 deletions

File tree

‎docs/config/pool.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ Restarting a worker thread is not free: Node.js runs a full garbage collection o
3131
The `vmForks` pool recycles workers by letting the child process exit, and the operating system reclaims the memory. If your test suite is large enough to recycle workers, `vmForks` is usually noticeably faster than `vmThreads`, even though its communication with the main process is slower.
3232
:::
3333

34+
On Node.js 24.9 and later, `require()` of an ES module is supported inside vm pools, mirroring [Node's own `require(esm)`](https://nodejs.org/api/modules.html#loading-ecmascript-modules-using-require). Calling `require()` on an ES module whose graph contains top-level `await` throws `ERR_REQUIRE_ASYNC_MODULE` - use `await import()` for those files.
35+
3436
::: warning
3537
Running code in a sandbox has some advantages (faster tests), but also comes with a number of disadvantages.
3638

‎packages/vitest/src/runtime/external-executor.ts‎

Lines changed: 147 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,12 @@ import { lookupPackageScopeType } from '@vitest/utils/resolver'
1111
import { extname, normalize } from 'pathe'
1212
import { CommonjsExecutor } from './vm/commonjs-executor'
1313
import { EsmExecutor } from './vm/esm-executor'
14-
import { setActiveVmExecutor } from './vm/utils'
14+
import {
15+
createRequireAsyncModuleError,
16+
hasEsmSyntax,
17+
setActiveVmExecutor,
18+
supportsSyncEsmEvaluate,
19+
} from './vm/utils'
1520
import { ViteExecutor } from './vm/vite-executor'
1621

1722
const { existsSync } = fs
@@ -52,6 +57,14 @@ export interface ModuleInformation {
5257
exists?: boolean
5358
}
5459

60+
// how the sync require(esm) graph walker should treat a resolved module:
61+
// 'ready' modules are complete synthetic modules (builtins, CJS files),
62+
// 'source'/'json' carry the raw content for the walker to build itself
63+
export type SyncModuleDisposition
64+
= | { kind: 'ready'; module: VMModule }
65+
| { kind: 'json'; code: string }
66+
| { kind: 'source'; code: string }
67+
5568
// TODO: improve Node.js strict mode support in #2854
5669
export class ExternalModulesExecutor {
5770
private cjs: CommonjsExecutor
@@ -79,6 +92,8 @@ export class ExternalModulesExecutor {
7992
fileMap: options.fileMap,
8093
codeCache: options.codeCache,
8194
interopDefault: options.interopDefault,
95+
shouldRequireAsEsm: this.shouldRequireAsEsm,
96+
requireEsm: this.requireEsm,
8297
})
8398
this.vite = new ViteExecutor({
8499
esmExecutor: this.esm,
@@ -103,6 +118,122 @@ export class ExternalModulesExecutor {
103118
return this.cjs.createRequire(identifier)
104119
}
105120

121+
#esmSyntaxCache = new Map<string, boolean>()
122+
123+
// require() dispatches to the sync ESM loader only for files that are
124+
// explicitly marked as ESM (.mjs or a "type": "module" package scope).
125+
// JSON files keep the CJS json loader for Node require() parity — the
126+
// extension wins over the package scope.
127+
private shouldRequireAsEsm = (resolvedPath: string): boolean => {
128+
if (!supportsSyncEsmEvaluate) {
129+
return false
130+
}
131+
const information = this.getModuleInformation(resolvedPath)
132+
if (information.type !== 'module' || information.path.endsWith('.json')) {
133+
return false
134+
}
135+
if (information.path.endsWith('.mjs')) {
136+
return true
137+
}
138+
// A .js file in an ESM package scope may still contain plain CJS code —
139+
// Node evaluates it as ESM with injected CJS module variables (module,
140+
// require, __filename), which a vm SourceTextModule cannot emulate.
141+
// Files without ESM syntax keep loading through the CJS executor; a
142+
// false negative here is corrected by its ESM-syntax fallback.
143+
let syntax = this.#esmSyntaxCache.get(information.path)
144+
if (syntax == null) {
145+
syntax = hasEsmSyntax(this.fs.readFile(information.path))
146+
this.#esmSyntaxCache.set(information.path, syntax)
147+
}
148+
return syntax
149+
}
150+
151+
private requireEsm = (resolvedPath: string): unknown => {
152+
const { url } = this.getModuleInformation(resolvedPath)
153+
const module = this.esm.requireEsModuleSync(url)
154+
const namespace = module.namespace as Record<string, unknown>
155+
// Node parity: an ES module can define its own require() result with an
156+
// export named "module.exports"
157+
return 'module.exports' in namespace
158+
? namespace['module.exports']
159+
: namespace
160+
}
161+
162+
public resolveSyncSpecifier = (
163+
specifier: string,
164+
referencer: string,
165+
): string => {
166+
const resolved = this.resolve(specifier, referencer) as
167+
| string
168+
| Promise<string>
169+
if (resolved instanceof Promise) {
170+
throw createRequireAsyncModuleError(
171+
referencer,
172+
`"${specifier}" cannot be resolved synchronously`,
173+
)
174+
}
175+
return resolved
176+
}
177+
178+
// the sync counterpart of `createModule`, used by the require(esm) graph
179+
// walker. `forceEsmSource` loads a 'commonjs'-typed file as ES module
180+
// source — the CJS executor requests this after its parser rejected a .js
181+
// file that contains ESM syntax.
182+
public materializeSyncModule = (
183+
identifier: string,
184+
forceEsmSource: boolean,
185+
): SyncModuleDisposition => {
186+
const information = this.getModuleInformation(identifier)
187+
const { type, path } = information
188+
this.assertModuleExists(information)
189+
190+
switch (type) {
191+
case 'builtin':
192+
return {
193+
kind: 'ready',
194+
module: this.cjs.getCoreSyntheticModule(identifier),
195+
}
196+
case 'module':
197+
case 'commonjs': {
198+
if (type === 'commonjs' && !forceEsmSource) {
199+
return {
200+
kind: 'ready',
201+
module: this.cjs.getCjsSyntheticModule(path, identifier),
202+
}
203+
}
204+
if (path.endsWith('.json')) {
205+
return { kind: 'json', code: this.fs.readFile(path) }
206+
}
207+
return { kind: 'source', code: this.fs.readFile(path) }
208+
}
209+
case 'data':
210+
// data: URIs are materialized by the ESM executor before it consults
211+
// the external executor
212+
throw new Error(
213+
`[vitest] Unexpected data: module ${identifier} in the sync module walker. This is a bug in Vitest.`,
214+
)
215+
case 'vite':
216+
throw createRequireAsyncModuleError(
217+
identifier,
218+
'the module is transformed by Vite, which is asynchronous',
219+
)
220+
case 'wasm':
221+
throw createRequireAsyncModuleError(
222+
identifier,
223+
'WebAssembly modules cannot be loaded synchronously',
224+
)
225+
case 'network':
226+
throw createRequireAsyncModuleError(
227+
identifier,
228+
'network modules cannot be loaded synchronously',
229+
)
230+
default: {
231+
const _deadend: never = type
232+
return _deadend
233+
}
234+
}
235+
}
236+
106237
// dynamic import can be used in both ESM and CJS, so we have it in the executor
107238
public importModuleDynamically = async (
108239
specifier: string,
@@ -213,20 +344,25 @@ export class ExternalModulesExecutor {
213344
return { type, path: pathUrl, url: fileUrl }
214345
}
215346

216-
private createModule(identifier: string): VMModule | Promise<VMModule> {
217-
const information = this.getModuleInformation(identifier)
218-
const { type, url, path } = information
219-
220-
// create ERR_MODULE_NOT_FOUND on our own since latest NodeJS's import.meta.resolve doesn't throw on non-existing namespace or path
221-
// https://github.com/nodejs/node/pull/49038
347+
// create ERR_MODULE_NOT_FOUND on our own since latest NodeJS's import.meta.resolve doesn't throw on non-existing namespace or path
348+
// https://github.com/nodejs/node/pull/49038
349+
private assertModuleExists(information: ModuleInformation): void {
350+
const { type, path } = information
222351
if (type === 'module' || type === 'commonjs' || type === 'wasm') {
223352
information.exists ??= existsSync(path)
224353
}
225354
if (information.exists === false) {
226-
const error = new Error(`Cannot find ${isBareImport(path) ? 'package' : 'module'} '${path}'`);
227-
(error as any).code = 'ERR_MODULE_NOT_FOUND'
355+
const error: NodeJS.ErrnoException = new Error(`Cannot find ${isBareImport(path) ? 'package' : 'module'} '${path}'`)
356+
error.code = 'ERR_MODULE_NOT_FOUND'
228357
throw error
229358
}
359+
}
360+
361+
private createModule(identifier: string): VMModule | Promise<VMModule> {
362+
const information = this.getModuleInformation(identifier)
363+
const { type, url, path } = information
364+
365+
this.assertModuleExists(information)
230366

231367
switch (type) {
232368
case 'data':
@@ -236,11 +372,9 @@ export class ExternalModulesExecutor {
236372
case 'vite':
237373
return this.vite.createViteModule(url)
238374
case 'wasm':
239-
return this.esm.createWebAssemblyModule(url, () =>
240-
this.fs.readBuffer(path))
375+
return this.esm.createWebAssemblyModule(url, () => this.fs.readBuffer(path))
241376
case 'module':
242-
return this.esm.createEsModule(url, () =>
243-
this.fs.readFileAsync(path))
377+
return this.esm.createEsModule(url, () => this.fs.readFile(path))
244378
case 'commonjs':
245379
return this.cjs.getCjsSyntheticModule(path, identifier)
246380
case 'network':

0 commit comments

Comments
 (0)