Skip to content

Commit 4dced02

Browse files
bcoeMylesBorins
authored andcommitted
module: add API for interacting with source maps
PR-URL: #31132 Reviewed-By: Anna Henningsen <[email protected]> Reviewed-By: Rich Trott <[email protected]> Reviewed-By: Benjamin Gruenbaum <[email protected]> Reviewed-By: James M Snell <[email protected]>
1 parent aedbfdb commit 4dced02

File tree

8 files changed

+252
-32
lines changed

8 files changed

+252
-32
lines changed

doc/api/modules.md

+86
Original file line numberDiff line numberDiff line change
@@ -1033,6 +1033,86 @@ import('fs').then((esmFS) => {
10331033
});
10341034
```
10351035
1036+
## Source Map V3 Support
1037+
<!-- YAML
1038+
added: REPLACEME
1039+
-->
1040+
1041+
> Stability: 1 - Experimental
1042+
1043+
Helpers for for interacting with the source map cache. This cache is
1044+
populated when source map parsing is enabled and
1045+
[source map include directives][] are found in a modules' footer.
1046+
1047+
To enable source map parsing, Node.js must be run with the flag
1048+
[`--enable-source-maps`][], or with code coverage enabled by setting
1049+
[`NODE_V8_COVERAGE=dir`][].
1050+
1051+
```js
1052+
const { findSourceMap, SourceMap } = require('module');
1053+
```
1054+
1055+
### `module.findSourceMap(path[, error])`
1056+
<!-- YAML
1057+
added: REPLACEME
1058+
-->
1059+
1060+
* `path` {string}
1061+
* `error` {Error}
1062+
* Returns: {module.SourceMap}
1063+
1064+
`path` is the resolved path for the file for which a corresponding source map
1065+
should be fetched.
1066+
1067+
The `error` instance should be passed as the second parameter to `findSourceMap`
1068+
in exceptional flows, e.g., when an overridden
1069+
[`Error.prepareStackTrace(error, trace)`][] is invoked. Modules are not added to
1070+
the module cache until they are successfully loaded, in these cases source maps
1071+
will be associated with the `error` instance along with the `path`.
1072+
1073+
### Class: `module.SourceMap`
1074+
<!-- YAML
1075+
added: REPLACEME
1076+
-->
1077+
1078+
#### `new SourceMap(payload)`
1079+
1080+
* `payload` {Object}
1081+
1082+
Creates a new `sourceMap` instance.
1083+
1084+
`payload` is an object with keys matching the [Source Map V3 format][]:
1085+
1086+
* `file`: {string}
1087+
* `version`: {number}
1088+
* `sources`: {string[]}
1089+
* `sourcesContent`: {string[]}
1090+
* `names`: {string[]}
1091+
* `mappings`: {string}
1092+
* `sourceRoot`: {string}
1093+
1094+
#### `sourceMap.payload`
1095+
1096+
* Returns: {Object}
1097+
1098+
Getter for the payload used to construct the [`SourceMap`][] instance.
1099+
1100+
#### `sourceMap.findEntry(lineNumber, columnNumber)`
1101+
1102+
* `lineNumber` {number}
1103+
* `columnNumber` {number}
1104+
* Returns: {Object}
1105+
1106+
Given a line number and column number in the generated source file, returns
1107+
an object representing the position in the original file. The object returned
1108+
consists of the following keys:
1109+
1110+
* generatedLine: {number}
1111+
* generatedColumn: {number}
1112+
* originalSource: {string}
1113+
* originalLine: {number}
1114+
* originalColumn: {number}
1115+
10361116
[GLOBAL_FOLDERS]: #modules_loading_from_the_global_folders
10371117
[`Error`]: errors.html#errors_class_error
10381118
[`__dirname`]: #modules_dirname
@@ -1046,3 +1126,9 @@ import('fs').then((esmFS) => {
10461126
[module resolution]: #modules_all_together
10471127
[module wrapper]: #modules_the_module_wrapper
10481128
[native addons]: addons.html
1129+
[source map include directives]: https://sourcemaps.info/spec.html#h.lmz475t4mvbx
1130+
[`--enable-source-maps`]: cli.html#cli_enable_source_maps
1131+
[`NODE_V8_COVERAGE=dir`]: cli.html#cli_node_v8_coverage_dir
1132+
[`Error.prepareStackTrace(error, trace)`]: https://v8.dev/docs/stack-trace-api#customizing-stack-traces
1133+
[`SourceMap`]: modules.html#modules_class_module_sourcemap
1134+
[Source Map V3 format]: https://sourcemaps.info/spec.html#h.mofvlxcwqzej

lib/internal/source_map/prepare_stack_trace.js

+10-8
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ const prepareStackTrace = (globalThis, error, trace) => {
2929
maybeOverridePrepareStackTrace(globalThis, error, trace);
3030
if (globalOverride !== kNoOverride) return globalOverride;
3131

32-
const { SourceMap } = require('internal/source_map/source_map');
3332
const errorString = ErrorToString.call(error);
3433

3534
if (trace.length === 0) {
@@ -39,16 +38,19 @@ const prepareStackTrace = (globalThis, error, trace) => {
3938
let str = i !== 0 ? '\n at ' : '';
4039
str = `${str}${t}`;
4140
try {
42-
const sourceMap = findSourceMap(t.getFileName(), error);
43-
if (sourceMap && sourceMap.data) {
44-
const sm = new SourceMap(sourceMap.data);
41+
const sm = findSourceMap(t.getFileName(), error);
42+
if (sm) {
4543
// Source Map V3 lines/columns use zero-based offsets whereas, in
4644
// stack traces, they start at 1/1.
47-
const [, , url, line, col] =
48-
sm.findEntry(t.getLineNumber() - 1, t.getColumnNumber() - 1);
49-
if (url && line !== undefined && col !== undefined) {
45+
const {
46+
originalLine,
47+
originalColumn,
48+
originalSource
49+
} = sm.findEntry(t.getLineNumber() - 1, t.getColumnNumber() - 1);
50+
if (originalSource && originalLine !== undefined &&
51+
originalColumn !== undefined) {
5052
str +=
51-
`\n -> ${url.replace('file://', '')}:${line + 1}:${col + 1}`;
53+
`\n -> ${originalSource.replace('file://', '')}:${originalLine + 1}:${originalColumn + 1}`;
5254
}
5355
}
5456
} catch (err) {

lib/internal/source_map/source_map.js

+50-22
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,14 @@
6666

6767
'use strict';
6868

69+
const {
70+
Array
71+
} = primordials;
72+
73+
const {
74+
ERR_INVALID_ARG_TYPE
75+
} = require('internal/errors').codes;
76+
6977
let base64Map;
7078

7179
const VLQ_BASE_SHIFT = 5;
@@ -112,6 +120,7 @@ class StringCharIterator {
112120
* @param {SourceMapV3} payload
113121
*/
114122
class SourceMap {
123+
#payload;
115124
#reverseMappingsBySourceURL = [];
116125
#mappings = [];
117126
#sources = {};
@@ -129,17 +138,25 @@ class SourceMap {
129138
for (let i = 0; i < base64Digits.length; ++i)
130139
base64Map[base64Digits[i]] = i;
131140
}
132-
this.#parseMappingPayload(payload);
141+
this.#payload = cloneSourceMapV3(payload);
142+
this.#parseMappingPayload();
143+
}
144+
145+
/**
146+
* @return {Object} raw source map v3 payload.
147+
*/
148+
get payload() {
149+
return cloneSourceMapV3(this.#payload);
133150
}
134151

135152
/**
136153
* @param {SourceMapV3} mappingPayload
137154
*/
138-
#parseMappingPayload = (mappingPayload) => {
139-
if (mappingPayload.sections)
140-
this.#parseSections(mappingPayload.sections);
155+
#parseMappingPayload = () => {
156+
if (this.#payload.sections)
157+
this.#parseSections(this.#payload.sections);
141158
else
142-
this.#parseMap(mappingPayload, 0, 0);
159+
this.#parseMap(this.#payload, 0, 0);
143160
}
144161

145162
/**
@@ -175,24 +192,18 @@ class SourceMap {
175192
const entry = this.#mappings[first];
176193
if (!first && entry && (lineNumber < entry[0] ||
177194
(lineNumber === entry[0] && columnNumber < entry[1]))) {
178-
return null;
195+
return {};
196+
} else if (!entry) {
197+
return {};
198+
} else {
199+
return {
200+
generatedLine: entry[0],
201+
generatedColumn: entry[1],
202+
originalSource: entry[2],
203+
originalLine: entry[3],
204+
originalColumn: entry[4]
205+
};
179206
}
180-
return entry;
181-
}
182-
183-
/**
184-
* @param {string} sourceURL of the originating resource
185-
* @param {number} lineNumber in the originating resource
186-
* @return {Array}
187-
*/
188-
findEntryReversed(sourceURL, lineNumber) {
189-
const mappings = this.#reverseMappingsBySourceURL[sourceURL];
190-
for (; lineNumber < mappings.length; ++lineNumber) {
191-
const mapping = mappings[lineNumber];
192-
if (mapping)
193-
return mapping;
194-
}
195-
return this.#mappings[0];
196207
}
197208

198209
/**
@@ -296,6 +307,23 @@ function decodeVLQ(stringCharIterator) {
296307
return negative ? -result : result;
297308
}
298309

310+
/**
311+
* @param {SourceMapV3} payload
312+
* @return {SourceMapV3}
313+
*/
314+
function cloneSourceMapV3(payload) {
315+
if (typeof payload !== 'object') {
316+
throw new ERR_INVALID_ARG_TYPE('payload', ['Object'], payload);
317+
}
318+
payload = { ...payload };
319+
for (const key in payload) {
320+
if (payload.hasOwnProperty(key) && Array.isArray(payload[key])) {
321+
payload[key] = payload[key].slice(0);
322+
}
323+
}
324+
return payload;
325+
}
326+
299327
module.exports = {
300328
SourceMap
301329
};

lib/internal/source_map/source_map_cache.js

+11-1
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ const cjsSourceMapCache = new WeakMap();
3737
const esmSourceMapCache = new Map();
3838
const { fileURLToPath, URL } = require('url');
3939
let Module;
40+
let SourceMap;
4041

4142
let experimentalSourceMaps;
4243
function maybeCacheSourceMap(filename, content, cjsModuleInstance) {
@@ -222,8 +223,13 @@ function appendCJSCache(obj) {
222223

223224
// Attempt to lookup a source map, which is either attached to a file URI, or
224225
// keyed on an error instance.
226+
// TODO(bcoe): once WeakRefs are available in Node.js, refactor to drop
227+
// requirement of error parameter.
225228
function findSourceMap(uri, error) {
226229
if (!Module) Module = require('internal/modules/cjs/loader').Module;
230+
if (!SourceMap) {
231+
SourceMap = require('internal/source_map/source_map').SourceMap;
232+
}
227233
let sourceMap = cjsSourceMapCache.get(Module._cache[uri]);
228234
if (!uri.startsWith('file://')) uri = normalizeReferrerURL(uri);
229235
if (sourceMap === undefined) {
@@ -235,7 +241,11 @@ function findSourceMap(uri, error) {
235241
sourceMap = candidateSourceMap;
236242
}
237243
}
238-
return sourceMap;
244+
if (sourceMap && sourceMap.data) {
245+
return new SourceMap(sourceMap.data);
246+
} else {
247+
return undefined;
248+
}
239249
}
240250

241251
module.exports = {

lib/module.js

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
11
'use strict';
22

3-
module.exports = require('internal/modules/cjs/loader').Module;
3+
const { findSourceMap } = require('internal/source_map/source_map_cache');
4+
const { Module } = require('internal/modules/cjs/loader');
5+
const { SourceMap } = require('internal/source_map/source_map');
6+
7+
Module.findSourceMap = findSourceMap;
8+
Module.SourceMap = SourceMap;
9+
module.exports = Module;

test/parallel/test-source-map-api.js

+84
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Flags: --enable-source-maps
2+
'use strict';
3+
4+
require('../common');
5+
const assert = require('assert');
6+
const { findSourceMap, SourceMap } = require('module');
7+
const { readFileSync } = require('fs');
8+
9+
// findSourceMap() can lookup source-maps based on URIs, in the
10+
// non-exceptional case.
11+
{
12+
require('../fixtures/source-map/disk-relative-path.js');
13+
const sourceMap = findSourceMap(
14+
require.resolve('../fixtures/source-map/disk-relative-path.js')
15+
);
16+
const {
17+
originalLine,
18+
originalColumn,
19+
originalSource
20+
} = sourceMap.findEntry(0, 29);
21+
assert.strictEqual(originalLine, 2);
22+
assert.strictEqual(originalColumn, 4);
23+
assert(originalSource.endsWith('disk.js'));
24+
}
25+
26+
// findSourceMap() can be used in Error.prepareStackTrace() to lookup
27+
// source-map attached to error.
28+
{
29+
let callSite;
30+
let sourceMap;
31+
Error.prepareStackTrace = (error, trace) => {
32+
const throwingRequireCallSite = trace[0];
33+
if (throwingRequireCallSite.getFileName().endsWith('typescript-throw.js')) {
34+
sourceMap = findSourceMap(throwingRequireCallSite.getFileName(), error);
35+
callSite = throwingRequireCallSite;
36+
}
37+
};
38+
try {
39+
// Require a file that throws an exception, and has a source map.
40+
require('../fixtures/source-map/typescript-throw.js');
41+
} catch (err) {
42+
err.stack; // Force prepareStackTrace() to be called.
43+
}
44+
assert(callSite);
45+
assert(sourceMap);
46+
const {
47+
generatedLine,
48+
generatedColumn,
49+
originalLine,
50+
originalColumn,
51+
originalSource
52+
} = sourceMap.findEntry(
53+
callSite.getLineNumber() - 1,
54+
callSite.getColumnNumber() - 1
55+
);
56+
57+
assert.strictEqual(generatedLine, 19);
58+
assert.strictEqual(generatedColumn, 14);
59+
60+
assert.strictEqual(originalLine, 17);
61+
assert.strictEqual(originalColumn, 10);
62+
assert(originalSource.endsWith('typescript-throw.ts'));
63+
}
64+
65+
// SourceMap can be instantiated with Source Map V3 object as payload.
66+
{
67+
const payload = JSON.parse(readFileSync(
68+
require.resolve('../fixtures/source-map/disk.map'), 'utf8'
69+
));
70+
const sourceMap = new SourceMap(payload);
71+
const {
72+
originalLine,
73+
originalColumn,
74+
originalSource
75+
} = sourceMap.findEntry(0, 29);
76+
assert.strictEqual(originalLine, 2);
77+
assert.strictEqual(originalColumn, 4);
78+
assert(originalSource.endsWith('disk.js'));
79+
// The stored payload should be a clone:
80+
assert.strictEqual(payload.mappings, sourceMap.payload.mappings);
81+
assert.notStrictEqual(payload, sourceMap.payload);
82+
assert.strictEqual(payload.sources[0], sourceMap.payload.sources[0]);
83+
assert.notStrictEqual(payload.sources, sourceMap.payload.sources);
84+
}

tools/doc/type-parser.js

+4
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ const customTypesMap = {
101101
'https.Server': 'https.html#https_class_https_server',
102102

103103
'module': 'modules.html#modules_the_module_object',
104+
105+
'module.SourceMap':
106+
'modules.html#modules_class_module_sourcemap',
107+
104108
'require': 'modules.html#modules_require_id',
105109

106110
'Handle': 'net.html#net_server_listen_handle_backlog_callback',

0 commit comments

Comments
 (0)