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
103 changes: 100 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,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) {
Comment on lines +35 to +39
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if a module has multiple parents, like it's imported many times across an application? How do we know that the trace matches the code path that was followed to throw the error?

Assuming you've solved this case, we should have a test for this.

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,
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 };
Expand Down
2 changes: 2 additions & 0 deletions src/env_properties.h
Original file line number Diff line number Diff line change
Expand Up @@ -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") \
Expand Down Expand Up @@ -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") \
Expand Down
12 changes: 10 additions & 2 deletions src/module_wrap.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -565,7 +566,7 @@ static Local<Object> createImportAttributesContainer(
}

static Local<Array> createModuleRequestsContainer(
Realm* realm, Isolate* isolate, Local<FixedArray> raw_requests) {
Realm* realm, Isolate* isolate, Local<Module> module, Local<FixedArray> raw_requests) {
EscapableHandleScope scope(isolate);
Local<Context> context = realm->context();
LocalVector<Value> requests(isolate, raw_requests->Length());
Expand All @@ -584,15 +585,22 @@ static Local<Array> 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<Name> 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<Value> 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));

Expand All @@ -616,7 +624,7 @@ void ModuleWrap::GetModuleRequests(const FunctionCallbackInfo<Value>& args) {

Local<Module> module = obj->module_.Get(isolate);
args.GetReturnValue().Set(createModuleRequestsContainer(
realm, isolate, module->GetModuleRequests()));
realm, isolate, module, module->GetModuleRequests()));
}

// moduleWrap.link(moduleWraps)
Expand Down
54 changes: 54 additions & 0 deletions test/es-module/test-esm-import-trace.mjs
Original file line number Diff line number Diff line change
@@ -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', () => {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@GeoffreyBooth see new test added for module with multiple parents. The import trace will print whichever path is the first to resolve.

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');
}
});
1 change: 1 addition & 0 deletions test/fixtures/es-modules/import-trace-multi/alt.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import './bar.mjs';
1 change: 1 addition & 0 deletions test/fixtures/es-modules/import-trace-multi/bar.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
throw new Error('fail');
2 changes: 2 additions & 0 deletions test/fixtures/es-modules/import-trace-multi/entry.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import './foo.mjs';
import './alt.mjs';
1 change: 1 addition & 0 deletions test/fixtures/es-modules/import-trace-multi/foo.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import './bar.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';