Skip to content
66 changes: 66 additions & 0 deletions benchmark/esm/startup-esm-graph.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
'use strict';

const common = require('../common');
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const tmpdir = require('../../test/common/tmpdir');

const bench = common.createBenchmark(main, {
modules: ['0250', '0500', '1000', '2000'],
n: [30],
});

const BRANCHING_FACTOR = 10;

function prepare(count) {
tmpdir.refresh();
const dir = tmpdir.resolve('esm-graph');
fs.mkdirSync(dir, { recursive: true });

// Create a tree-shaped ESM graph with a branching factor of 10.
// The root (mod0) plus `count` additional modules are created in BFS order.
// Module i imports modules BRANCHING_FACTOR*i+1 through
// BRANCHING_FACTOR*i+BRANCHING_FACTOR (capped at count), so the graph is a
// complete 10-ary tree rooted at mod0.
const total = count + 1;
for (let i = 0; i < total; i++) {
const children = [];
for (let c = 1; c <= BRANCHING_FACTOR; c++) {
const child = BRANCHING_FACTOR * i + c;
if (child < total) {
children.push(`import './mod${child}.mjs';`);
}
}
const content = children.join('\n') + (children.length ? '\n' : '') +
`export const value${i} = ${i};\n`;
fs.writeFileSync(path.join(dir, `mod${i}.mjs`), content);
}

return path.join(dir, 'mod0.mjs');
}

function main({ n, modules }) {
const entry = prepare(Number(modules));
const cmd = process.execPath || process.argv[0];
const warmup = 3;
const state = { finished: -warmup };

while (state.finished < n) {
const child = spawnSync(cmd, [entry]);
if (child.status !== 0) {
console.log('---- STDOUT ----');
console.log(child.stdout.toString());
console.log('---- STDERR ----');
console.log(child.stderr.toString());
throw new Error(`Child process stopped with exit code ${child.status}`);
}
state.finished++;
if (state.finished === 0) {
bench.start();
}
if (state.finished === n) {
bench.end(n);
}
}
}
39 changes: 35 additions & 4 deletions lib/internal/modules/esm/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,32 @@ class ModuleLoader {
return { wrap: job.module, namespace: job.runSync(parent).namespace };
}

/**
* Synchronously load and evaluate the entry point module.
* This avoids creating any promises when no TLA is present
* and no async customization hooks are registered.
* @param {string} url The URL of the entry point module.
* @returns {{ module: ModuleWrap, completed: boolean }} The entry module and whether
* evaluation completed synchronously. When false, the caller should fall back to
* async evaluation (TLA detected).
*/
importSyncForEntryPoint(url) {
return onImport.traceSync(() => {
const request = { specifier: url, phase: kEvaluationPhase, attributes: kEmptyObject, __proto__: null };
const job = this.getOrCreateModuleJob(undefined, request, kImportInImportedESM);
job.module.instantiate();
if (job.module.hasAsyncGraph) {
return { __proto__: null, module: job.module, completed: false };
}
job.runSync();
return { __proto__: null, module: job.module, completed: true };
}, {
__proto__: null,
parentURL: undefined,
url,
});
}

/**
* Check invariants on a cached module job when require()'d from ESM.
* @param {string} specifier The first parameter of require().
Expand Down Expand Up @@ -572,10 +598,14 @@ class ModuleLoader {
assert(moduleOrModulePromise instanceof ModuleWrap, `Expected ModuleWrap for loading ${url}`);
}

// TODO(joyeecheung): use ModuleJobSync for kRequireInImportedCJS too.
const ModuleJobCtor = (requestType === kImportInRequiredESM ? ModuleJobSync : ModuleJob);
const isMain = (parentURL === undefined);
const inspectBrk = (isMain && getOptionValue('--inspect-brk'));
// Use ModuleJobSync whenever we're on the main thread (not the async loader hook worker),
// except for kRequireInImportedCJS (TODO: consolidate that case too).
// TODO(joyeecheung): use ModuleJobSync for kRequireInImportedCJS too.
const ModuleJobCtor = (!this.isForAsyncLoaderHookWorker &&
requestType !== kRequireInImportedCJS) ?
ModuleJobSync : ModuleJob;
job = new ModuleJobCtor(
this,
url,
Expand All @@ -602,8 +632,9 @@ class ModuleLoader {
*/
getOrCreateModuleJob(parentURL, request, requestType) {
let maybePromise;
if (requestType === kRequireInImportedCJS || requestType === kImportInRequiredESM) {
// In these two cases, resolution must be synchronous.
if (!this.isForAsyncLoaderHookWorker) {
// On the main thread, always resolve synchronously;
// `resolveSync` coordinates with the async loader hook worker if needed.
maybePromise = this.resolveSync(parentURL, request);
assert(!isPromise(maybePromise));
} else {
Expand Down
196 changes: 123 additions & 73 deletions lib/internal/modules/esm/module_job.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ const {
getSourceMapsSupport,
} = require('internal/source_map/source_map_cache');
const assert = require('internal/assert');
const resolvedPromise = PromiseResolve();
let resolvedPromise;
const {
setHasStartedUserESMExecution,
urlToFilename,
Expand All @@ -65,7 +65,6 @@ const {
ERR_REQUIRE_ASYNC_MODULE,
ERR_REQUIRE_ESM_RACE_CONDITION,
} = require('internal/errors').codes;
let hasPausedEntry = false;

const CJSGlobalLike = [
'require',
Expand Down Expand Up @@ -241,6 +240,58 @@ function getTopLevelAwaitLocations(root) {
return locations;
}

/**
* If `error` is a SyntaxError from V8 for a missing named export on a CJS module, rewrite its message to the friendlier
* "Named export '...' not found..." form. Must be called after `decorateErrorStack(error)` so that the arrow (source
* context with the import statement text) has been prepended to `error.stack`.
* @param {Error} error
* @param {ModuleWrap} module The parent module that triggered the instantiation.
* @param {boolean[]} commonJsDeps Per-request array indicating whether each dependency is a CJS module, aligned with
* `module.getModuleRequests()`.
*/
const handleCJSNamedExportError = (error, module, commonJsDeps) => {
// TODO(@bcoe): Add source map support to exception that occurs as result
// of missing named export. This is currently not possible because
// stack trace originates in module_job, not the file itself. A hidden
// symbol with filename could be set in node_errors.cc to facilitate this.
if (!getSourceMapsSupport().enabled &&
StringPrototypeIncludes(error.message,
' does not provide an export named')) {
const splitStack = StringPrototypeSplit(error.stack, '\n', 2);
const { 1: childSpecifier, 2: name } = RegExpPrototypeExec(
/module '(.*)' does not provide an export named '(.+)'/,
error.message);
const moduleRequests = module.getModuleRequests();
let isCommonJS = false;
for (let i = 0; i < moduleRequests.length; ++i) {
if (moduleRequests[i].specifier === childSpecifier) {
isCommonJS = commonJsDeps[i];
break;
}
}
if (isCommonJS) {
const importStatement = splitStack[1];
// TODO(@ctavan): The original error stack only provides the single
// line which causes the error. For multi-line import statements we
// cannot generate an equivalent object destructuring assignment by
// just parsing the error stack.
const oneLineNamedImports = RegExpPrototypeExec(/{.*}/, importStatement);
const destructuringAssignment = oneLineNamedImports &&
RegExpPrototypeSymbolReplace(/\s+as\s+/g, oneLineNamedImports, ': ');
error.message = `Named export '${name}' not found. The requested module` +
` '${childSpecifier}' is a CommonJS module, which may not support` +
' all module.exports as named exports.\nCommonJS modules can ' +
'always be imported via the default export, for example using:' +
`\n\nimport pkg from '${childSpecifier}';\n${
destructuringAssignment ?
`const ${destructuringAssignment} = pkg;\n` : ''}`;
const newStack = StringPrototypeSplit(error.stack, '\n');
newStack[3] = `SyntaxError: ${error.message}`;
error.stack = ArrayPrototypeJoin(newStack, '\n');
}
}
};

class ModuleJobBase {
constructor(loader, url, importAttributes, phase, isMain, inspectBrk) {
assert(typeof phase === 'number');
Expand Down Expand Up @@ -455,63 +506,17 @@ class ModuleJob extends ModuleJobBase {
await addJobsToDependencyGraph(this);

try {
if (!hasPausedEntry && this.inspectBrk) {
hasPausedEntry = true;
const initWrapper = internalBinding('inspector').callAndPauseOnStart;
initWrapper(this.module.instantiate, this.module);
} else {
this.module.instantiate();
}
} catch (e) {
decorateErrorStack(e);
// TODO(@bcoe): Add source map support to exception that occurs as result
// of missing named export. This is currently not possible because
// stack trace originates in module_job, not the file itself. A hidden
// symbol with filename could be set in node_errors.cc to facilitate this.
if (!getSourceMapsSupport().enabled &&
StringPrototypeIncludes(e.message,
' does not provide an export named')) {
const splitStack = StringPrototypeSplit(e.stack, '\n', 2);
const { 1: childSpecifier, 2: name } = RegExpPrototypeExec(
/module '(.*)' does not provide an export named '(.+)'/,
e.message);
const moduleRequests = this.module.getModuleRequests();
let isCommonJS = false;
for (let i = 0; i < moduleRequests.length; ++i) {
if (moduleRequests[i].specifier === childSpecifier) {
isCommonJS = this.commonJsDeps[i];
break;
}
}

if (isCommonJS) {
const importStatement = splitStack[1];
// TODO(@ctavan): The original error stack only provides the single
// line which causes the error. For multi-line import statements we
// cannot generate an equivalent object destructuring assignment by
// just parsing the error stack.
const oneLineNamedImports = RegExpPrototypeExec(/{.*}/, importStatement);
const destructuringAssignment = oneLineNamedImports &&
RegExpPrototypeSymbolReplace(/\s+as\s+/g, oneLineNamedImports, ': ');
e.message = `Named export '${name}' not found. The requested module` +
` '${childSpecifier}' is a CommonJS module, which may not support` +
' all module.exports as named exports.\nCommonJS modules can ' +
'always be imported via the default export, for example using:' +
`\n\nimport pkg from '${childSpecifier}';\n${
destructuringAssignment ?
`const ${destructuringAssignment} = pkg;\n` : ''}`;
const newStack = StringPrototypeSplit(e.stack, '\n');
newStack[3] = `SyntaxError: ${e.message}`;
e.stack = ArrayPrototypeJoin(newStack, '\n');
}
}
throw e;
this.module.instantiate();
} catch (error) {
decorateErrorStack(error);
handleCJSNamedExportError(error, this.module, this.commonJsDeps);
throw error;
}

for (const dependencyJob of jobsInGraph) {
// Calling `this.module.instantiate()` instantiates not only the
// ModuleWrap in this module, but all modules in the graph.
dependencyJob.instantiated = resolvedPromise;
dependencyJob.instantiated = resolvedPromise ??= PromiseResolve();
}
}

Expand Down Expand Up @@ -577,12 +582,14 @@ class ModuleJob extends ModuleJobBase {

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

async run() {
async run(isEntryPoint = false) {
assert(this.shouldRunModule(this.phase));
// This path is hit by a require'd module that is imported again.
const status = this.module.getStatus();
debug('ModuleJobSync.run()', status, this.module);
// If the module was previously required and errored, reject from import() again.
if (status === kErrored) {
throw this.module.getError();
} else if (status > kInstantiated) {
}
if (status > kInstantiated) {
// Already evaluated (e.g. previously require()'d and now import()'d again).
if (this.evaluationPromise) {
await this.evaluationPromise;
}
return { __proto__: null, module: this.module };
} else if (status === kInstantiated || status === kUninstantiated) {
// If we get here, the module was initially required and is now being imported.
// The require() module failed either because the graph has TLA (kInstantiated),
// or instantiation failed (kUninstantiated, e.g. missing named export).
// Try finishing the instantiation - if it succeeds, proceed to evaluation,
// otherwise the branch below re-throw any instantiation error.
if (status === kUninstantiated) {
}
if (status < kInstantiated) {
// Either a fresh module - links were already resolved synchronously in the constructor -
// or one that was initially require()'d and is now being imported after its instantiation
// failed (e.g. a missing named export). Try finishing the instantiation: if it succeeds,
// proceed to evaluation, otherwise re-throw the instantiation error.
try {
this.module.instantiate();
} catch (error) {
decorateErrorStack(error);
handleCJSNamedExportError(error, this.module, this.commonJsDeps);
throw error;
}
}
// `status === kInstantiated`: either just instantiated above, or previously instantiated
// but evaluation was deferred (e.g. TLA detected by a prior `runSync()` call)
if (isEntryPoint) {
globalThis[entry_point_module_private_symbol] = this.module;
}
setHasStartedUserESMExecution();
if (this.module.hasAsyncGraph) {
// Has top-level `await`: fall back to async evaluation
const timeout = -1;
const breakOnSigint = false;
this.evaluationPromise = this.module.evaluate(timeout, breakOnSigint);
if (this.inspectBrk) {
// Pre-evaluate all direct dependencies so that V8 will skip them when we evaluate the entry point.
// This ensures `callAndPauseOnStart` pauses at the entry point's first line rather than a dependency's.
for (const dep of this.linked) {
await dep.module.evaluate(timeout, breakOnSigint);
}
const { callAndPauseOnStart } = internalBinding('inspector');
this.evaluationPromise = callAndPauseOnStart(this.module.evaluate, this.module, timeout, breakOnSigint);
} else {
this.evaluationPromise = this.module.evaluate(timeout, breakOnSigint);
}
await this.evaluationPromise;
this.evaluationPromise = undefined;
return { __proto__: null, module: this.module };
}
// No top-level `await`: evaluate synchronously
try {
this.#evaluateSync();
} catch (evaluateError) {
explainCommonJSGlobalLikeNotDefinedError(evaluateError, this.module.url, this.module.hasTopLevelAwait);
throw evaluateError;
}
return { __proto__: null, module: this.module };
}

assert.fail('Unexpected status of a module that is imported again after being required. ' +
`Status = ${status}`);
// Evaluate this module synchronously, pausing at the first line if --inspect-brk is active.
// When pausing, direct dependencies are pre-evaluated first so V8 skips them and stops at
// this module's first line rather than a dependency's.
#evaluateSync() {
if (this.inspectBrk) {
for (const dep of this.linked) {
dep.module.evaluateSync();
}
const { callAndPauseOnStart } = internalBinding('inspector');
return callAndPauseOnStart(this.module.evaluateSync, this.module);
}
return this.module.evaluateSync();
}

runSync(parent) {
Expand All @@ -670,7 +720,7 @@ class ModuleJobSync extends ModuleJobBase {
this.throwIfAsyncGraph(parent);
setHasStartedUserESMExecution();
try {
const namespace = this.module.evaluateSync();
const namespace = this.#evaluateSync();
return { __proto__: null, module: this.module, namespace };
} catch (e) {
explainCommonJSGlobalLikeNotDefinedError(e, this.module.url, this.module.hasTopLevelAwait);
Expand Down
Loading
Loading