Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

module: add API for interacting with source maps #31132

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
86 changes: 86 additions & 0 deletions doc/api/modules.md
Expand Up @@ -1033,6 +1033,86 @@ import('fs').then((esmFS) => {
});
```

## Source Map V3 Support
<!-- YAML
added: REPLACEME
-->

> Stability: 1 - Experimental

Helpers for for interacting with the source map cache. This cache is
populated when source map parsing is enabled and
[source map include directives][] are found in a modules' footer.

To enable source map parsing, Node.js must be run with the flag
[`--enable-source-maps`][], or with code coverage enabled by setting
[`NODE_V8_COVERAGE=dir`][].

```js
const { findSourceMap, SourceMap } = require('module');
```

### `module.findSourceMap(path[, error])`
<!-- YAML
added: REPLACEME
-->

* `path` {string}
* `error` {Error}
* Returns: {module.SourceMap}

`path` is the resolved path for the file for which a corresponding source map
should be fetched.

The `error` instance should be passed as the second parameter to `findSourceMap`
in exceptional flows, e.g., when an overridden
[`Error.prepareStackTrace(error, trace)`][] is invoked. Modules are not added to
the module cache until they are successfully loaded, in these cases source maps
will be associated with the `error` instance along with the `path`.

### Class: `module.SourceMap`
<!-- YAML
added: REPLACEME
-->

#### `new SourceMap(payload)`

* `payload` {Object}

Creates a new `sourceMap` instance.

`payload` is an object with keys matching the [Source Map V3 format][]:

* `file`: {string}
* `version`: {number}
* `sources`: {string[]}
* `sourcesContent`: {string[]}
* `names`: {string[]}
* `mappings`: {string}
* `sourceRoot`: {string}

#### `sourceMap.payload`

* Returns: {Object}

Getter for the payload used to construct the [`SourceMap`][] instance.

#### `sourceMap.findEntry(lineNumber, columnNumber)`

* `lineNumber` {number}
* `columnNumber` {number}
* Returns: {Object}

Given a line number and column number in the generated source file, returns
an object representing the position in the original file. The object returned
consists of the following keys:

* generatedLine: {number}
* generatedColumn: {number}
* originalSource: {string}
* originalLine: {number}
* originalColumn: {number}

[GLOBAL_FOLDERS]: #modules_loading_from_the_global_folders
[`Error`]: errors.html#errors_class_error
[`__dirname`]: #modules_dirname
Expand All @@ -1046,3 +1126,9 @@ import('fs').then((esmFS) => {
[module resolution]: #modules_all_together
[module wrapper]: #modules_the_module_wrapper
[native addons]: addons.html
[source map include directives]: https://sourcemaps.info/spec.html#h.lmz475t4mvbx
[`--enable-source-maps`]: cli.html#cli_enable_source_maps
[`NODE_V8_COVERAGE=dir`]: cli.html#cli_node_v8_coverage_dir
[`Error.prepareStackTrace(error, trace)`]: https://v8.dev/docs/stack-trace-api#customizing-stack-traces
[`SourceMap`]: modules.html#modules_class_module_sourcemap
[Source Map V3 format]: https://sourcemaps.info/spec.html#h.mofvlxcwqzej
18 changes: 10 additions & 8 deletions lib/internal/source_map/prepare_stack_trace.js
Expand Up @@ -29,7 +29,6 @@ const prepareStackTrace = (globalThis, error, trace) => {
maybeOverridePrepareStackTrace(globalThis, error, trace);
if (globalOverride !== kNoOverride) return globalOverride;

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

if (trace.length === 0) {
Expand All @@ -39,16 +38,19 @@ const prepareStackTrace = (globalThis, error, trace) => {
let str = i !== 0 ? '\n at ' : '';
str = `${str}${t}`;
try {
const sourceMap = findSourceMap(t.getFileName(), error);
if (sourceMap && sourceMap.data) {
const sm = new SourceMap(sourceMap.data);
const sm = findSourceMap(t.getFileName(), error);
if (sm) {
// Source Map V3 lines/columns use zero-based offsets whereas, in
// stack traces, they start at 1/1.
const [, , url, line, col] =
sm.findEntry(t.getLineNumber() - 1, t.getColumnNumber() - 1);
if (url && line !== undefined && col !== undefined) {
const {
originalLine,
originalColumn,
originalSource
} = sm.findEntry(t.getLineNumber() - 1, t.getColumnNumber() - 1);
if (originalSource && originalLine !== undefined &&
originalColumn !== undefined) {
str +=
`\n -> ${url.replace('file://', '')}:${line + 1}:${col + 1}`;
`\n -> ${originalSource.replace('file://', '')}:${originalLine + 1}:${originalColumn + 1}`;
}
}
} catch (err) {
Expand Down
72 changes: 50 additions & 22 deletions lib/internal/source_map/source_map.js
Expand Up @@ -66,6 +66,14 @@

'use strict';

const {
Array
} = primordials;

const {
ERR_INVALID_ARG_TYPE
} = require('internal/errors').codes;

let base64Map;

const VLQ_BASE_SHIFT = 5;
Expand Down Expand Up @@ -112,6 +120,7 @@ class StringCharIterator {
* @param {SourceMapV3} payload
*/
class SourceMap {
#payload;
#reverseMappingsBySourceURL = [];
#mappings = [];
#sources = {};
Expand All @@ -129,17 +138,25 @@ class SourceMap {
for (let i = 0; i < base64Digits.length; ++i)
base64Map[base64Digits[i]] = i;
}
this.#parseMappingPayload(payload);
this.#payload = cloneSourceMapV3(payload);
this.#parseMappingPayload();
}

/**
* @return {Object} raw source map v3 payload.
*/
get payload() {
return cloneSourceMapV3(this.#payload);
}

/**
* @param {SourceMapV3} mappingPayload
*/
#parseMappingPayload = (mappingPayload) => {
if (mappingPayload.sections)
this.#parseSections(mappingPayload.sections);
#parseMappingPayload = () => {
if (this.#payload.sections)
this.#parseSections(this.#payload.sections);
else
this.#parseMap(mappingPayload, 0, 0);
this.#parseMap(this.#payload, 0, 0);
}

/**
Expand Down Expand Up @@ -175,24 +192,18 @@ class SourceMap {
const entry = this.#mappings[first];
if (!first && entry && (lineNumber < entry[0] ||
(lineNumber === entry[0] && columnNumber < entry[1]))) {
return null;
return {};
} else if (!entry) {
return {};
} else {
return {
generatedLine: entry[0],
generatedColumn: entry[1],
originalSource: entry[2],
originalLine: entry[3],
originalColumn: entry[4]
};
}
return entry;
}

/**
* @param {string} sourceURL of the originating resource
* @param {number} lineNumber in the originating resource
* @return {Array}
*/
findEntryReversed(sourceURL, lineNumber) {
bcoe marked this conversation as resolved.
Show resolved Hide resolved
const mappings = this.#reverseMappingsBySourceURL[sourceURL];
for (; lineNumber < mappings.length; ++lineNumber) {
const mapping = mappings[lineNumber];
if (mapping)
return mapping;
}
return this.#mappings[0];
}

/**
Expand Down Expand Up @@ -296,6 +307,23 @@ function decodeVLQ(stringCharIterator) {
return negative ? -result : result;
}

/**
* @param {SourceMapV3} payload
* @return {SourceMapV3}
*/
function cloneSourceMapV3(payload) {
if (typeof payload !== 'object') {
throw new ERR_INVALID_ARG_TYPE('payload', ['Object'], payload);
}
payload = { ...payload };
for (const key in payload) {
if (payload.hasOwnProperty(key) && Array.isArray(payload[key])) {
payload[key] = payload[key].slice(0);
}
}
return payload;
}

module.exports = {
SourceMap
};
12 changes: 11 additions & 1 deletion lib/internal/source_map/source_map_cache.js
Expand Up @@ -37,6 +37,7 @@ const cjsSourceMapCache = new WeakMap();
const esmSourceMapCache = new Map();
const { fileURLToPath, URL } = require('url');
let Module;
let SourceMap;

let experimentalSourceMaps;
function maybeCacheSourceMap(filename, content, cjsModuleInstance) {
Expand Down Expand Up @@ -222,8 +223,13 @@ function appendCJSCache(obj) {

// Attempt to lookup a source map, which is either attached to a file URI, or
// keyed on an error instance.
// TODO(bcoe): once WeakRefs are available in Node.js, refactor to drop
// requirement of error parameter.
function findSourceMap(uri, error) {
if (!Module) Module = require('internal/modules/cjs/loader').Module;
if (!SourceMap) {
SourceMap = require('internal/source_map/source_map').SourceMap;
}
let sourceMap = cjsSourceMapCache.get(Module._cache[uri]);
if (!uri.startsWith('file://')) uri = normalizeReferrerURL(uri);
if (sourceMap === undefined) {
Expand All @@ -235,7 +241,11 @@ function findSourceMap(uri, error) {
sourceMap = candidateSourceMap;
}
}
return sourceMap;
if (sourceMap && sourceMap.data) {
return new SourceMap(sourceMap.data);
} else {
return undefined;
}
}

module.exports = {
Expand Down
8 changes: 7 additions & 1 deletion lib/module.js
@@ -1,3 +1,9 @@
'use strict';

module.exports = require('internal/modules/cjs/loader').Module;
const { findSourceMap } = require('internal/source_map/source_map_cache');
const { Module } = require('internal/modules/cjs/loader');
const { SourceMap } = require('internal/source_map/source_map');

Module.findSourceMap = findSourceMap;
Module.SourceMap = SourceMap;
bcoe marked this conversation as resolved.
Show resolved Hide resolved
module.exports = Module;
84 changes: 84 additions & 0 deletions test/parallel/test-source-map-api.js
@@ -0,0 +1,84 @@
// Flags: --enable-source-maps
'use strict';

require('../common');
const assert = require('assert');
const { findSourceMap, SourceMap } = require('module');
const { readFileSync } = require('fs');

// findSourceMap() can lookup source-maps based on URIs, in the
// non-exceptional case.
{
require('../fixtures/source-map/disk-relative-path.js');
const sourceMap = findSourceMap(
require.resolve('../fixtures/source-map/disk-relative-path.js')
);
const {
originalLine,
originalColumn,
originalSource
} = sourceMap.findEntry(0, 29);
assert.strictEqual(originalLine, 2);
assert.strictEqual(originalColumn, 4);
assert(originalSource.endsWith('disk.js'));
}

// findSourceMap() can be used in Error.prepareStackTrace() to lookup
// source-map attached to error.
{
let callSite;
let sourceMap;
Error.prepareStackTrace = (error, trace) => {
const throwingRequireCallSite = trace[0];
if (throwingRequireCallSite.getFileName().endsWith('typescript-throw.js')) {
sourceMap = findSourceMap(throwingRequireCallSite.getFileName(), error);
callSite = throwingRequireCallSite;
}
};
try {
// Require a file that throws an exception, and has a source map.
require('../fixtures/source-map/typescript-throw.js');
} catch (err) {
err.stack; // Force prepareStackTrace() to be called.
}
assert(callSite);
assert(sourceMap);
const {
generatedLine,
generatedColumn,
originalLine,
originalColumn,
originalSource
} = sourceMap.findEntry(
callSite.getLineNumber() - 1,
callSite.getColumnNumber() - 1
);

assert.strictEqual(generatedLine, 19);
assert.strictEqual(generatedColumn, 14);

assert.strictEqual(originalLine, 17);
assert.strictEqual(originalColumn, 10);
assert(originalSource.endsWith('typescript-throw.ts'));
}

// SourceMap can be instantiated with Source Map V3 object as payload.
{
const payload = JSON.parse(readFileSync(
require.resolve('../fixtures/source-map/disk.map'), 'utf8'
));
const sourceMap = new SourceMap(payload);
const {
originalLine,
originalColumn,
originalSource
} = sourceMap.findEntry(0, 29);
assert.strictEqual(originalLine, 2);
assert.strictEqual(originalColumn, 4);
assert(originalSource.endsWith('disk.js'));
// The stored payload should be a clone:
assert.strictEqual(payload.mappings, sourceMap.payload.mappings);
assert.notStrictEqual(payload, sourceMap.payload);
assert.strictEqual(payload.sources[0], sourceMap.payload.sources[0]);
assert.notStrictEqual(payload.sources, sourceMap.payload.sources);
}
4 changes: 4 additions & 0 deletions tools/doc/type-parser.js
Expand Up @@ -101,6 +101,10 @@ const customTypesMap = {
'https.Server': 'https.html#https_class_https_server',

'module': 'modules.html#modules_the_module_object',

'module.SourceMap':
'modules.html#modules_class_module_sourcemap',

'require': 'modules.html#modules_require_id',

'Handle': 'net.html#net_server_listen_handle_backlog_callback',
Expand Down