Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions lib/internal/modules/esm/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const {
Promise,
PromisePrototypeThen,
RegExpPrototypeSymbolReplace,
SafeMap,
encodeURIComponent,
hardenRegExp,
} = primordials;
Expand Down Expand Up @@ -195,6 +196,7 @@ class ModuleLoader {

constructor(asyncLoaderHooks) {
this.#setAsyncLoaderHooks(asyncLoaderHooks);
this.importParents = new SafeMap();
}

/**
Expand Down
81 changes: 78 additions & 3 deletions lib/internal/modules/esm/module_job.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,80 @@ let debug = require('internal/util/debuglog').debuglog('esm', (fn) => {
debug = fn;
});

const {
ErrorPrepareStackTrace,
codes: {
ERR_REQUIRE_ASYNC_MODULE,
},
overrideStackTrace,
} = require('internal/errors');

/**
* Builds a linear import trace by walking parent modules
* from the module that threw during evaluation.
* @returns {Array<{child: string, parent: string}>|null}
*/
function buildImportTrace(importParents, startURL) {
const trace = [];
let current = startURL;
const seen = new SafeSet([current]);

while (true) {
const parent = importParents.get(current);
if (!parent || seen.has(parent)) {
break;
}

trace.push({ child: current, parent });
seen.add(current);
current = parent;
}

return trace.length ? trace : null;
}

/**
* Formats an import trace for inclusion in an error stack.
* @returns {string}
*/
function formatImportTrace(trace) {
return trace
.map(({ child, parent }) => ` ${child} imported by ${parent}`)
.join('\n');
}

/**
* Appends an ESM import trace to an error's stack output.
* Uses a per-error stack override; no global side effects.
*/
function decorateErrorWithImportTrace(e, importParents) {
if (!e || typeof e !== 'object') {
return;
}

overrideStackTrace.set(e, (error, trace) => {
let thrownURL;
for (const cs of trace) {
const getFileName = cs.getFileName;
if (typeof getFileName === 'function') {
const file = getFileName.call(cs);
if (typeof file === 'string' && file.startsWith('file://')) {
thrownURL = file;
break;
}
}
}

const importTrace = thrownURL ? buildImportTrace(importParents, thrownURL) : null;
const stack = ErrorPrepareStackTrace(error, trace);
if (!importTrace) {
return stack;
}

return `${stack}\n\nImport trace:\n${formatImportTrace(importTrace)}`;
});
}

const {
ModuleWrap,
kErrored,
Expand Down Expand Up @@ -53,9 +127,6 @@ const {
} = require('internal/modules/helpers');
const { getOptionValue } = require('internal/options');
const noop = FunctionPrototype;
const {
ERR_REQUIRE_ASYNC_MODULE,
} = require('internal/errors').codes;
let hasPausedEntry = false;

const CJSGlobalLike = [
Expand Down Expand Up @@ -159,6 +230,7 @@ class ModuleJobBase {
// that hooks can pre-fetch sources off-thread.
const job = this.loader.getOrCreateModuleJob(this.url, request, requestType);
debug(`ModuleJobBase.syncLink() ${this.url} -> ${request.specifier}`, job);
this.loader.importParents.set(job.url, this.url);
assert(!isPromise(job));
assert(job.module instanceof ModuleWrap);
if (request.phase === kEvaluationPhase) {
Expand Down Expand Up @@ -430,6 +502,9 @@ class ModuleJob extends ModuleJobBase {
await this.module.evaluate(timeout, breakOnSigint);
} catch (e) {
explainCommonJSGlobalLikeNotDefinedError(e, this.module.url, this.module.hasTopLevelAwait);

decorateErrorWithImportTrace(e, this.loader.importParents);

throw e;
}
return { __proto__: null, module: this.module };
Expand Down
26 changes: 26 additions & 0 deletions test/es-module/test-esm-import-trace.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { spawnSync } from 'node:child_process';
import assert from 'node:assert';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { test } from 'node:test';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const fixture = path.join(
__dirname,
'../fixtures/es-modules/import-trace/entry.mjs'
);

test('includes import trace for evaluation-time errors', () => {
const result = spawnSync(
process.execPath,
[fixture],
{ encoding: 'utf8' }
);

assert.notStrictEqual(result.status, 0);
assert.match(result.stderr, /Import trace:/);
assert.match(result.stderr, /bar\.mjs imported by .*foo\.mjs/);
assert.match(result.stderr, /foo\.mjs imported by .*entry\.mjs/);
});
1 change: 1 addition & 0 deletions test/fixtures/es-modules/import-trace/bar.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
throw new Error('bar failed');
1 change: 1 addition & 0 deletions test/fixtures/es-modules/import-trace/entry.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import './foo.mjs';
1 change: 1 addition & 0 deletions test/fixtures/es-modules/import-trace/foo.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import './bar.mjs';