Skip to content

Commit

Permalink
module: add API for interacting with source maps
Browse files Browse the repository at this point in the history
PR-URL: #31132
Reviewed-By: Anna Henningsen <anna@addaleax.net>
Reviewed-By: Rich Trott <rtrott@gmail.com>
Reviewed-By: Benjamin Gruenbaum <benjamingr@gmail.com>
Reviewed-By: James M Snell <jasnell@gmail.com>
  • Loading branch information
bcoe authored and targos committed Apr 28, 2020
1 parent 1d2565b commit 784fb8f
Show file tree
Hide file tree
Showing 8 changed files with 252 additions and 32 deletions.
86 changes: 86 additions & 0 deletions doc/api/modules.md
Expand Up @@ -1024,6 +1024,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 @@ -1037,3 +1117,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) {
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;
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);
}
File renamed without changes.
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

0 comments on commit 784fb8f

Please sign in to comment.