Skip to content

Commit 2bb88f9

Browse files
committed
module: synchronously load most ES modules
1 parent f8ee196 commit 2bb88f9

File tree

7 files changed

+211
-20
lines changed

7 files changed

+211
-20
lines changed

benchmark/esm/startup-esm-graph.js

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
'use strict';
2+
3+
const common = require('../common');
4+
const fs = require('fs');
5+
const path = require('path');
6+
const { spawnSync } = require('child_process');
7+
const tmpdir = require('../../test/common/tmpdir');
8+
9+
const bench = common.createBenchmark(main, {
10+
modules: [250, 500, 1000, 2000],
11+
n: [30],
12+
});
13+
14+
function prepare(count) {
15+
tmpdir.refresh();
16+
const dir = tmpdir.resolve('esm-graph');
17+
fs.mkdirSync(dir, { recursive: true });
18+
19+
// Create a flat ESM graph: entry imports all modules directly.
20+
// Each module is independent, maximizing the number of resolve/load/link
21+
// operations in the loader pipeline.
22+
const imports = [];
23+
for (let i = 0; i < count; i++) {
24+
fs.writeFileSync(
25+
path.join(dir, `mod${i}.mjs`),
26+
`export const value${i} = ${i};\n`,
27+
);
28+
imports.push(`import './mod${i}.mjs';`);
29+
}
30+
31+
const entry = path.join(dir, 'entry.mjs');
32+
fs.writeFileSync(entry, imports.join('\n') + '\n');
33+
return entry;
34+
}
35+
36+
function main({ n, modules }) {
37+
const entry = prepare(modules);
38+
const cmd = process.execPath || process.argv[0];
39+
const warmup = 3;
40+
const state = { finished: -warmup };
41+
42+
while (state.finished < n) {
43+
const child = spawnSync(cmd, [entry]);
44+
if (child.status !== 0) {
45+
console.log('---- STDOUT ----');
46+
console.log(child.stdout.toString());
47+
console.log('---- STDERR ----');
48+
console.log(child.stderr.toString());
49+
throw new Error(`Child process stopped with exit code ${child.status}`);
50+
}
51+
state.finished++;
52+
if (state.finished === 0) {
53+
bench.start();
54+
}
55+
if (state.finished === n) {
56+
bench.end(n);
57+
}
58+
}
59+
}

lib/internal/modules/esm/loader.js

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,32 @@ class ModuleLoader {
366366
return { wrap: job.module, namespace: job.runSync(parent).namespace };
367367
}
368368

369+
/**
370+
* Synchronously load and evaluate the entry point module.
371+
* This avoids creating any promises when no TLA is present
372+
* and no async customization hooks are registered.
373+
* @param {string} url The URL of the entry point module.
374+
* @returns {{ module: ModuleWrap, completed: boolean }} The entry module and whether
375+
* evaluation completed synchronously. When false, the caller should fall back to
376+
* async evaluation (TLA detected).
377+
*/
378+
importSyncForEntryPoint(url) {
379+
return onImport.traceSync(() => {
380+
const request = { specifier: url, phase: kEvaluationPhase, attributes: kEmptyObject, __proto__: null };
381+
const job = this.getOrCreateModuleJob(undefined, request, kImportInImportedESM);
382+
job.module.instantiate();
383+
if (job.module.hasAsyncGraph) {
384+
return { __proto__: null, module: job.module, completed: false };
385+
}
386+
job.runSync();
387+
return { __proto__: null, module: job.module, completed: true };
388+
}, {
389+
__proto__: null,
390+
parentURL: undefined,
391+
url,
392+
});
393+
}
394+
369395
/**
370396
* Check invariants on a cached module job when require()'d from ESM.
371397
* @param {string} specifier The first parameter of require().
@@ -561,10 +587,15 @@ class ModuleLoader {
561587
}
562588

563589
const { ModuleJob, ModuleJobSync } = require('internal/modules/esm/module_job');
564-
// TODO(joyeecheung): use ModuleJobSync for kRequireInImportedCJS too.
565-
const ModuleJobCtor = (requestType === kImportInRequiredESM ? ModuleJobSync : ModuleJob);
566590
const isMain = (parentURL === undefined);
567591
const inspectBrk = (isMain && getOptionValue('--inspect-brk'));
592+
// Use ModuleJobSync whenever we're on the main thread (not the async loader hook worker),
593+
// except for kRequireInImportedCJS (TODO: consolidate that case too) and --inspect-brk
594+
// (which needs the async ModuleJob to pause on the first line).
595+
// TODO(joyeecheung): use ModuleJobSync for kRequireInImportedCJS too.
596+
const ModuleJobCtor = (!this.isForAsyncLoaderHookWorker && !inspectBrk &&
597+
requestType !== kRequireInImportedCJS) ?
598+
ModuleJobSync : ModuleJob;
568599
job = new ModuleJobCtor(
569600
this,
570601
url,
@@ -591,8 +622,9 @@ class ModuleLoader {
591622
*/
592623
getOrCreateModuleJob(parentURL, request, requestType) {
593624
let maybePromise;
594-
if (requestType === kRequireInImportedCJS || requestType === kImportInRequiredESM) {
595-
// In these two cases, resolution must be synchronous.
625+
if (!this.isForAsyncLoaderHookWorker) {
626+
// On the main thread, always resolve synchronously;
627+
// `resolveSync` coordinates with the async loader hook worker if needed.
596628
maybePromise = this.resolveSync(parentURL, request);
597629
assert(!isPromise(maybePromise));
598630
} else {

lib/internal/modules/esm/module_job.js

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ const {
4646
getSourceMapsSupport,
4747
} = require('internal/source_map/source_map_cache');
4848
const assert = require('internal/assert');
49-
const resolvedPromise = PromiseResolve();
49+
let resolvedPromise;
5050
const {
5151
setHasStartedUserESMExecution,
5252
urlToFilename,
@@ -374,7 +374,7 @@ class ModuleJob extends ModuleJobBase {
374374
for (const dependencyJob of jobsInGraph) {
375375
// Calling `this.module.instantiate()` instantiates not only the
376376
// ModuleWrap in this module, but all modules in the graph.
377-
dependencyJob.instantiated = resolvedPromise;
377+
dependencyJob.instantiated = resolvedPromise ??= PromiseResolve();
378378
}
379379
}
380380

@@ -445,12 +445,14 @@ class ModuleJob extends ModuleJobBase {
445445

446446
/**
447447
* This is a fully synchronous job and does not spawn additional threads in any way.
448-
* All the steps are ensured to be synchronous and it throws on instantiating
449-
* an asynchronous graph. It also disallows CJS <-> ESM cycles.
448+
* Loading and linking are always synchronous. Evaluation via runSync() throws on an
449+
* asynchronous graph; evaluation via run() falls back to async for top-level await.
450+
* It also disallows CJS <-> ESM cycles.
450451
*
451-
* This is used for ES modules loaded via require(esm). Modules loaded by require() in
452-
* imported CJS are handled by ModuleJob with the isForRequireInImportedCJS set to true instead.
453-
* The two currently have different caching behaviors.
452+
* Used for all ES module imports on the main thread, regardless of how the import was
453+
* triggered (entry point, import(), require(esm), --import, etc.).
454+
* Modules loaded by require() in imported CJS are handled by ModuleJob with the
455+
* isForRequireInImportedCJS set to true instead. The two currently have different caching behaviors.
454456
* TODO(joyeecheung): consolidate this with the isForRequireInImportedCJS variant of ModuleJob.
455457
*/
456458
class ModuleJobSync extends ModuleJobBase {
@@ -491,32 +493,49 @@ class ModuleJobSync extends ModuleJobBase {
491493
return PromiseResolve(this.module);
492494
}
493495

494-
async run() {
496+
async run(isEntryPoint = false) {
495497
assert(this.phase === kEvaluationPhase);
496-
// This path is hit by a require'd module that is imported again.
497498
const status = this.module.getStatus();
498499
debug('ModuleJobSync.run()', status, this.module);
499500
// If the module was previously required and errored, reject from import() again.
500501
if (status === kErrored) {
501502
throw this.module.getError();
502-
} else if (status > kInstantiated) {
503+
}
504+
if (status > kInstantiated) {
505+
// Already evaluated (e.g. previously require()'d and now import()'d again).
503506
if (this.evaluationPromise) {
504507
await this.evaluationPromise;
505508
}
506509
return { __proto__: null, module: this.module };
507-
} else if (status === kInstantiated) {
508-
// The evaluation may have been canceled because instantiate() detected TLA first.
509-
// But when it is imported again, it's fine to re-evaluate it asynchronously.
510+
}
511+
if (status < kInstantiated) {
512+
// Fresh module: instantiate it now (links were already resolved synchronously in constructor)
513+
this.module.instantiate();
514+
}
515+
// `status === kInstantiated`: either just instantiated above, or previously instantiated
516+
// but evaluation was deferred (e.g. TLA detected by a prior `runSync()` call)
517+
if (isEntryPoint) {
518+
globalThis[entry_point_module_private_symbol] = this.module;
519+
}
520+
setHasStartedUserESMExecution();
521+
if (this.module.hasAsyncGraph) {
522+
// Has top-level `await`: fall back to async evaluation
510523
const timeout = -1;
511524
const breakOnSigint = false;
512525
this.evaluationPromise = this.module.evaluate(timeout, breakOnSigint);
513526
await this.evaluationPromise;
514527
this.evaluationPromise = undefined;
515528
return { __proto__: null, module: this.module };
516529
}
517-
518-
assert.fail('Unexpected status of a module that is imported again after being required. ' +
519-
`Status = ${status}`);
530+
// No top-level `await`: evaluate synchronously
531+
const filename = urlToFilename(this.url);
532+
try {
533+
this.module.evaluateSync(filename, undefined);
534+
} catch (evaluateError) {
535+
explainCommonJSGlobalLikeNotDefinedError(evaluateError, this.module.url, this.module.hasTopLevelAwait);
536+
throw evaluateError;
537+
}
538+
return { __proto__: null, module: this.module };
520539
}
521540

522541
runSync(parent) {

lib/internal/modules/run_main.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const {
1919
const {
2020
privateSymbols: {
2121
entry_point_promise_private_symbol,
22+
entry_point_module_private_symbol,
2223
},
2324
} = internalBinding('util');
2425
/**
@@ -156,6 +157,20 @@ function executeUserEntryPoint(main = process.argv[1]) {
156157
const mainPath = resolvedMain || main;
157158
const mainURL = getOptionValue('--entry-url') ? new URL(mainPath, getCWDURL()) : pathToFileURL(mainPath);
158159

160+
// When no async hooks or --inspect-brk are needed, try the fully synchronous path first.
161+
// This avoids creating any promises during startup.
162+
if (!getOptionValue('--inspect-brk') &&
163+
getOptionValue('--experimental-loader').length === 0 &&
164+
getOptionValue('--import').length === 0) {
165+
const cascadedLoader = require('internal/modules/esm/loader').getOrInitializeCascadedLoader();
166+
const { module: entryModule, completed } = cascadedLoader.importSyncForEntryPoint(mainURL.href);
167+
globalThis[entry_point_module_private_symbol] = entryModule;
168+
if (completed) {
169+
return;
170+
}
171+
// TLA detected — fall through to async path.
172+
}
173+
159174
runEntryPointWithESMLoader((cascadedLoader) => {
160175
// Note that if the graph contains unsettled TLA, this may never resolve
161176
// even after the event loop stops running.
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// Flags: --no-warnings
2+
import { spawnPromisified } from '../common/index.mjs';
3+
import * as fixtures from '../common/fixtures.mjs';
4+
import { describe, it } from 'node:test';
5+
import assert from 'node:assert';
6+
7+
8+
describe('synchronous ESM loading', () => {
9+
it('should create minimal promises for ESM importing ESM', async () => {
10+
// import-esm.mjs imports imported-esm.mjs — a pure ESM graph.
11+
const count = await getPromiseCount(fixtures.path('es-modules', 'import-esm.mjs'));
12+
// V8's Module::Evaluate returns one promise for the entire graph.
13+
assert.strictEqual(count, 1);
14+
});
15+
16+
it('should create minimal promises for ESM importing CJS', async () => {
17+
// builtin-imports-case.mjs imports node:assert (builtin) + dep1.js and dep2.js (CJS).
18+
const count = await getPromiseCount(fixtures.path('es-modules', 'builtin-imports-case.mjs'));
19+
// V8 creates one promise for the ESM entry evaluation, plus one per CJS module
20+
// in the graph (each CJS namespace is wrapped in a promise).
21+
// entry (ESM, 1) + node:assert (CJS, 1) + dep1.js (CJS, 1) + dep2.js (CJS, 1) = 4.
22+
assert.strictEqual(count, 4);
23+
});
24+
25+
it('should fall back to async evaluation for top-level await', async () => {
26+
// tla/resolved.mjs uses top-level await, so the sync path detects TLA
27+
// and falls back to async evaluation.
28+
const count = await getPromiseCount(fixtures.path('es-modules', 'tla', 'resolved.mjs'));
29+
// The async fallback creates more promises — just verify the module
30+
// still runs successfully. The promise count will be higher than the
31+
// sync path but should remain bounded.
32+
assert(count > 1, `Expected TLA fallback to create multiple promises, got ${count}`);
33+
});
34+
35+
it('should create minimal promises when entry point is CJS importing ESM', async () => {
36+
// When a CJS entry point uses require(esm), the ESM module is loaded via
37+
// ModuleJobSync, so the same promise minimization applies.
38+
const count = await getPromiseCount(fixtures.path('es-modules', 'require-esm-entry.cjs'));
39+
// V8's Module::Evaluate returns one promise for the ESM module.
40+
assert.strictEqual(count, 1);
41+
});
42+
});
43+
44+
45+
async function getPromiseCount(entry) {
46+
const { stdout, stderr, code } = await spawnPromisified(process.execPath, [
47+
'--require', fixtures.path('es-modules', 'promise-counter.cjs'),
48+
entry,
49+
]);
50+
assert.strictEqual(code, 0, `child failed:\nstdout: ${stdout}\nstderr: ${stderr}`);
51+
const match = stdout.match(/PROMISE_COUNT=(\d+)/);
52+
assert(match, `Expected PROMISE_COUNT in output, got: ${stdout}`);
53+
return Number(match[1]);
54+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Counts PROMISE async resources created during the process lifetime.
2+
// Used by test-esm-sync-entry-point.mjs to verify the sync ESM loader
3+
// path does not create unnecessary promises.
4+
'use strict';
5+
let count = 0;
6+
require('async_hooks').createHook({
7+
init(id, type) { if (type === 'PROMISE') count++; },
8+
}).enable();
9+
process.on('exit', () => {
10+
process.stdout.write(`PROMISE_COUNT=${count}\n`);
11+
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
require('./imported-esm.mjs');

0 commit comments

Comments
 (0)