diff --git a/lib/internal/modules/esm/loader.js b/lib/internal/modules/esm/loader.js index 0bff0763fcf58f..cb0ee6d252627d 100644 --- a/lib/internal/modules/esm/loader.js +++ b/lib/internal/modules/esm/loader.js @@ -211,6 +211,7 @@ class ModuleLoader { constructor(asyncLoaderHooks) { this.#setAsyncLoaderHooks(asyncLoaderHooks); + this.importParents = new Map(); } /** diff --git a/lib/internal/modules/esm/module_job.js b/lib/internal/modules/esm/module_job.js index 929577c0da6d08..5b0c38ed5bf20b 100644 --- a/lib/internal/modules/esm/module_job.js +++ b/lib/internal/modules/esm/module_job.js @@ -23,6 +23,73 @@ let debug = require('internal/util/debuglog').debuglog('esm', (fn) => { debug = fn; }); +const { + overrideStackTrace, + ErrorPrepareStackTrace, + codes, +} = require('internal/errors'); + +const { ERR_REQUIRE_ASYNC_MODULE } = codes; + +/** + * Builds a linear import trace by walking parent modules + * from the module that threw during evaluation. + */ +function buildImportTrace(importParents, startURL) { + const trace = []; + let current = startURL; + const seen = new Set([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. + */ +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 (!importParents || typeof importParents.get !== 'function') return; + 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, @@ -53,9 +120,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 = [ @@ -159,6 +223,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) { @@ -430,6 +495,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 }; diff --git a/test/es-module/test-esm-import-trace.mjs b/test/es-module/test-esm-import-trace.mjs new file mode 100644 index 00000000000000..0a33f2e9f88c9a --- /dev/null +++ b/test/es-module/test-esm-import-trace.mjs @@ -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/); +}); \ No newline at end of file diff --git a/test/fixtures/es-modules/import-trace/bar.mjs b/test/fixtures/es-modules/import-trace/bar.mjs new file mode 100644 index 00000000000000..8d48f71d57cbde --- /dev/null +++ b/test/fixtures/es-modules/import-trace/bar.mjs @@ -0,0 +1 @@ +throw new Error('bar failed'); diff --git a/test/fixtures/es-modules/import-trace/entry.mjs b/test/fixtures/es-modules/import-trace/entry.mjs new file mode 100644 index 00000000000000..a63434dddb1bb6 --- /dev/null +++ b/test/fixtures/es-modules/import-trace/entry.mjs @@ -0,0 +1 @@ +import './foo.mjs'; diff --git a/test/fixtures/es-modules/import-trace/foo.mjs b/test/fixtures/es-modules/import-trace/foo.mjs new file mode 100644 index 00000000000000..118fb781654638 --- /dev/null +++ b/test/fixtures/es-modules/import-trace/foo.mjs @@ -0,0 +1 @@ +import './bar.mjs'; \ No newline at end of file