diff --git a/lib/internal/modules/esm/loader.js b/lib/internal/modules/esm/loader.js index 37eb267e154cc7..53ea9916c6b903 100644 --- a/lib/internal/modules/esm/loader.js +++ b/lib/internal/modules/esm/loader.js @@ -10,6 +10,7 @@ const { Promise, PromisePrototypeThen, RegExpPrototypeSymbolReplace, + SafeMap, encodeURIComponent, hardenRegExp, } = primordials; @@ -195,6 +196,7 @@ class ModuleLoader { constructor(asyncLoaderHooks) { this.#setAsyncLoaderHooks(asyncLoaderHooks); + this.importParents = new SafeMap(); } /** diff --git a/lib/internal/modules/esm/module_job.js b/lib/internal/modules/esm/module_job.js index 929577c0da6d08..5566762d7bb38f 100644 --- a/lib/internal/modules/esm/module_job.js +++ b/lib/internal/modules/esm/module_job.js @@ -23,6 +23,94 @@ 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 parentInfo = importParents.get(current); + let parentURL; + if (typeof parentInfo === 'object' && parentInfo !== null && typeof parentInfo.url === 'string') { + parentURL = parentInfo.url; + } else { + parentURL = parentInfo; + } + if (!parentInfo || seen.has(parentURL)) { + break; + } + let parentDisplay; + if (typeof parentInfo === 'object' && parentInfo !== null && typeof parentInfo.url === 'string') { + parentDisplay = parentInfo.url; + if (typeof parentInfo.line === 'number' && typeof parentInfo.column === 'number') { + parentDisplay += `:${parentInfo.line + 1}:${parentInfo.column + 1}`; + } + } else { + parentDisplay = parentInfo; + } + trace.push({ child: current, parent: parentDisplay }); + seen.add(parentURL); + current = parentURL; + } + + 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 }) => ` ${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\nImported by:\n${formatImportTrace(importTrace)}`; + }); +} + const { ModuleWrap, kErrored, @@ -53,9 +141,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 +244,15 @@ 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); + + const line = request.line; + const column = request.column; + // Set the parent info with line/column + let parentInfo = this.url; + if (typeof line === 'number' && typeof column === 'number') { + parentInfo = { url: this.url, line, column }; + } + this.loader.importParents.set(job.url, parentInfo); assert(!isPromise(job)); assert(job.module instanceof ModuleWrap); if (request.phase === kEvaluationPhase) { @@ -430,6 +524,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/src/env_properties.h b/src/env_properties.h index 91e2e06c3c2703..4e206d17e18840 100644 --- a/src/env_properties.h +++ b/src/env_properties.h @@ -100,6 +100,7 @@ "transferList") \ V(clone_untransferable_str, "Found invalid value in transferList.") \ V(code_string, "code") \ + V(column_string, "column") \ V(config_string, "config") \ V(constants_string, "constants") \ V(crypto_dh_string, "dh") \ @@ -239,6 +240,7 @@ V(length_string, "length") \ V(limits_string, "limits") \ V(library_string, "library") \ + V(line_string, "line") \ V(loop_count, "loopCount") \ V(max_buffer_string, "maxBuffer") \ V(max_concurrent_streams_string, "maxConcurrentStreams") \ diff --git a/src/module_wrap.cc b/src/module_wrap.cc index 354b45bda9ccc7..27f88e64a99be3 100644 --- a/src/module_wrap.cc +++ b/src/module_wrap.cc @@ -40,6 +40,7 @@ using v8::Isolate; using v8::Just; using v8::JustVoid; using v8::Local; +using v8::Location; using v8::LocalVector; using v8::Maybe; using v8::MaybeLocal; @@ -565,7 +566,7 @@ static Local createImportAttributesContainer( } static Local createModuleRequestsContainer( - Realm* realm, Isolate* isolate, Local raw_requests) { + Realm* realm, Isolate* isolate, Local module, Local raw_requests) { EscapableHandleScope scope(isolate); Local context = realm->context(); LocalVector requests(isolate, raw_requests->Length()); @@ -584,15 +585,22 @@ static Local createModuleRequestsContainer( createImportAttributesContainer(realm, isolate, raw_attributes, 3); ModuleImportPhase phase = module_request->GetPhase(); + int source_offset = module_request->GetSourceOffset(); + Location loc = module->SourceOffsetToLocation(source_offset); + Local names[] = { realm->isolate_data()->specifier_string(), realm->isolate_data()->attributes_string(), realm->isolate_data()->phase_string(), + realm->isolate_data()->line_string(), + realm->isolate_data()->column_string(), }; Local values[] = { specifier, attributes, Integer::New(isolate, to_phase_constant(phase)), + Integer::New(isolate, loc.GetLineNumber()), + Integer::New(isolate, loc.GetColumnNumber()), }; DCHECK_EQ(arraysize(names), arraysize(values)); @@ -616,7 +624,7 @@ void ModuleWrap::GetModuleRequests(const FunctionCallbackInfo& args) { Local module = obj->module_.Get(isolate); args.GetReturnValue().Set(createModuleRequestsContainer( - realm, isolate, module->GetModuleRequests())); + realm, isolate, module, module->GetModuleRequests())); } // moduleWrap.link(moduleWraps) 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..5252da6cdd8e5f --- /dev/null +++ b/test/es-module/test-esm-import-trace.mjs @@ -0,0 +1,54 @@ +import { spawnSync } from 'node:child_process'; +import assert from 'node:assert'; +import path from 'node:path'; +import { test } from 'node:test'; + +const fixture = path.join( + import.meta.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, /Imported by:/); + assert.match(result.stderr, /.*foo\.mjs:1:8/); + assert.match(result.stderr, /.*entry\.mjs:1:8/); +}); + +const multiFixture = path.join( + import.meta.dirname, + '../fixtures/es-modules/import-trace-multi/entry.mjs' +); + +test('import trace matches actual code path for multiple parents', () => { + const result = spawnSync( + process.execPath, + [multiFixture], + { encoding: 'utf8' } + ); + + assert.notStrictEqual(result.status, 0); + const traceFoo = /.*foo\.mjs:1:8/; + const traceAlt = /.*alt\.mjs:1:8/; + const entryFoo = /.*entry\.mjs:1:8/; + const entryAlt = /.*entry\.mjs:2:8/; + + let parentMatched; + if (traceFoo.test(result.stderr)) { + parentMatched = 'foo'; + assert(entryFoo.test(result.stderr), + 'Import trace should show foo.mjs imported by entry.mjs with line/column'); + } else if (traceAlt.test(result.stderr)) { + parentMatched = 'alt'; + assert(entryAlt.test(result.stderr), + 'Import trace should show alt.mjs imported by entry.mjs with line/column'); + } else { + assert.fail('Import trace should show either foo.mjs or alt.mjs as parent with line/column'); + } +}); \ No newline at end of file diff --git a/test/fixtures/es-modules/import-trace-multi/alt.mjs b/test/fixtures/es-modules/import-trace-multi/alt.mjs new file mode 100644 index 00000000000000..dd325c78f2a52c --- /dev/null +++ b/test/fixtures/es-modules/import-trace-multi/alt.mjs @@ -0,0 +1 @@ +import './bar.mjs'; diff --git a/test/fixtures/es-modules/import-trace-multi/bar.mjs b/test/fixtures/es-modules/import-trace-multi/bar.mjs new file mode 100644 index 00000000000000..d6bc0faf85db40 --- /dev/null +++ b/test/fixtures/es-modules/import-trace-multi/bar.mjs @@ -0,0 +1 @@ +throw new Error('fail'); diff --git a/test/fixtures/es-modules/import-trace-multi/entry.mjs b/test/fixtures/es-modules/import-trace-multi/entry.mjs new file mode 100644 index 00000000000000..0c1d61d130340c --- /dev/null +++ b/test/fixtures/es-modules/import-trace-multi/entry.mjs @@ -0,0 +1,2 @@ +import './foo.mjs'; +import './alt.mjs'; diff --git a/test/fixtures/es-modules/import-trace-multi/foo.mjs b/test/fixtures/es-modules/import-trace-multi/foo.mjs new file mode 100644 index 00000000000000..dd325c78f2a52c --- /dev/null +++ b/test/fixtures/es-modules/import-trace-multi/foo.mjs @@ -0,0 +1 @@ +import './bar.mjs'; 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