From bc7c0c0e0ed1baf4dc6c6f2086cd202d79a7f520 Mon Sep 17 00:00:00 2001 From: bcoe Date: Sat, 4 Jan 2020 19:17:42 -0800 Subject: [PATCH 1/2] module: add API for interacting with source maps --- doc/api/modules.md | 85 +++++++++++++++++++ .../source_map/prepare_stack_trace.js | 18 ++-- lib/internal/source_map/source_map.js | 54 +++++++----- lib/internal/source_map/source_map_cache.js | 12 ++- lib/module.js | 8 +- test/parallel/test-source-map-api.js | 80 +++++++++++++++++ ...ource-map.js => test-source-map-enable.js} | 0 tools/doc/type-parser.js | 4 + 8 files changed, 229 insertions(+), 32 deletions(-) create mode 100644 test/parallel/test-source-map-api.js rename test/parallel/{test-source-map.js => test-source-map-enable.js} (100%) diff --git a/doc/api/modules.md b/doc/api/modules.md index bac8b21d775fae..2a1a52f1b809ec 100644 --- a/doc/api/modules.md +++ b/doc/api/modules.md @@ -1033,6 +1033,85 @@ import('fs').then((esmFS) => { }); ``` +## Source Map V3 Support + + + +> 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])` + + +* `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` + + +#### `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 @@ -1046,3 +1125,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 diff --git a/lib/internal/source_map/prepare_stack_trace.js b/lib/internal/source_map/prepare_stack_trace.js index df559b2cdfe581..037a8dc53e0855 100644 --- a/lib/internal/source_map/prepare_stack_trace.js +++ b/lib/internal/source_map/prepare_stack_trace.js @@ -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) { @@ -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) { diff --git a/lib/internal/source_map/source_map.js b/lib/internal/source_map/source_map.js index 9044521b6d62d0..b599e24596c5ae 100644 --- a/lib/internal/source_map/source_map.js +++ b/lib/internal/source_map/source_map.js @@ -112,6 +112,7 @@ class StringCharIterator { * @param {SourceMapV3} payload */ class SourceMap { + #payload = null; #reverseMappingsBySourceURL = []; #mappings = []; #sources = {}; @@ -129,17 +130,25 @@ class SourceMap { for (let i = 0; i < base64Digits.length; ++i) base64Map[base64Digits[i]] = i; } - this.#parseMappingPayload(payload); + this.#payload = payload; + this.#parseMappingPayload(); + } + + /** + * @return {Object} raw source map v3 payload. + */ + get payload() { + return 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); } /** @@ -160,6 +169,13 @@ class SourceMap { findEntry(lineNumber, columnNumber) { let first = 0; let count = this.#mappings.length; + const nullEntry = { + generatedLine: null, + generatedColumn: null, + originalSource: null, + originalLine: null, + originalColumn: null + }; while (count > 1) { const step = count >> 1; const middle = first + step; @@ -175,24 +191,18 @@ class SourceMap { const entry = this.#mappings[first]; if (!first && entry && (lineNumber < entry[0] || (lineNumber === entry[0] && columnNumber < entry[1]))) { - return null; - } - 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 nullEntry; + } else if (!entry) { + return nullEntry; + } else { + return { + generatedLine: entry[0], + generatedColumn: entry[1], + originalSource: entry[2], + originalLine: entry[3], + originalColumn: entry[4] + }; } - return this.#mappings[0]; } /** diff --git a/lib/internal/source_map/source_map_cache.js b/lib/internal/source_map/source_map_cache.js index 593c2c8277f224..fcefbb740abde0 100644 --- a/lib/internal/source_map/source_map_cache.js +++ b/lib/internal/source_map/source_map_cache.js @@ -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) { @@ -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) { @@ -235,7 +241,11 @@ function findSourceMap(uri, error) { sourceMap = candidateSourceMap; } } - return sourceMap; + if (sourceMap && sourceMap.data) { + return new SourceMap(sourceMap.data); + } else { + return null; + } } module.exports = { diff --git a/lib/module.js b/lib/module.js index a767330f5e3d6e..b4a6dd7d18de56 100644 --- a/lib/module.js +++ b/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; diff --git a/test/parallel/test-source-map-api.js b/test/parallel/test-source-map-api.js new file mode 100644 index 00000000000000..ffe8ade0f16f5b --- /dev/null +++ b/test/parallel/test-source-map-api.js @@ -0,0 +1,80 @@ +// 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')); + assert.strictEqual(payload, sourceMap.payload); +} diff --git a/test/parallel/test-source-map.js b/test/parallel/test-source-map-enable.js similarity index 100% rename from test/parallel/test-source-map.js rename to test/parallel/test-source-map-enable.js diff --git a/tools/doc/type-parser.js b/tools/doc/type-parser.js index ef4499e50ff35a..add331016c2204 100644 --- a/tools/doc/type-parser.js +++ b/tools/doc/type-parser.js @@ -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', From 3ea89d2bd653c546b39df0a7e1566790d622e62f Mon Sep 17 00:00:00 2001 From: Benjamin Coe Date: Fri, 10 Jan 2020 17:43:37 -0800 Subject: [PATCH 2/2] chore: address code review --- doc/api/modules.md | 5 ++- lib/internal/source_map/source_map.js | 42 +++++++++++++++------ lib/internal/source_map/source_map_cache.js | 2 +- test/parallel/test-source-map-api.js | 6 ++- 4 files changed, 39 insertions(+), 16 deletions(-) diff --git a/doc/api/modules.md b/doc/api/modules.md index 2a1a52f1b809ec..049fff804d6aaf 100644 --- a/doc/api/modules.md +++ b/doc/api/modules.md @@ -1034,8 +1034,9 @@ import('fs').then((esmFS) => { ``` ## Source Map V3 Support - - + > Stability: 1 - Experimental diff --git a/lib/internal/source_map/source_map.js b/lib/internal/source_map/source_map.js index b599e24596c5ae..32fe43ac8f68cb 100644 --- a/lib/internal/source_map/source_map.js +++ b/lib/internal/source_map/source_map.js @@ -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; @@ -112,7 +120,7 @@ class StringCharIterator { * @param {SourceMapV3} payload */ class SourceMap { - #payload = null; + #payload; #reverseMappingsBySourceURL = []; #mappings = []; #sources = {}; @@ -130,7 +138,7 @@ class SourceMap { for (let i = 0; i < base64Digits.length; ++i) base64Map[base64Digits[i]] = i; } - this.#payload = payload; + this.#payload = cloneSourceMapV3(payload); this.#parseMappingPayload(); } @@ -138,7 +146,7 @@ class SourceMap { * @return {Object} raw source map v3 payload. */ get payload() { - return this.#payload; + return cloneSourceMapV3(this.#payload); } /** @@ -169,13 +177,6 @@ class SourceMap { findEntry(lineNumber, columnNumber) { let first = 0; let count = this.#mappings.length; - const nullEntry = { - generatedLine: null, - generatedColumn: null, - originalSource: null, - originalLine: null, - originalColumn: null - }; while (count > 1) { const step = count >> 1; const middle = first + step; @@ -191,9 +192,9 @@ class SourceMap { const entry = this.#mappings[first]; if (!first && entry && (lineNumber < entry[0] || (lineNumber === entry[0] && columnNumber < entry[1]))) { - return nullEntry; + return {}; } else if (!entry) { - return nullEntry; + return {}; } else { return { generatedLine: entry[0], @@ -306,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 }; diff --git a/lib/internal/source_map/source_map_cache.js b/lib/internal/source_map/source_map_cache.js index fcefbb740abde0..b64af7eed6e097 100644 --- a/lib/internal/source_map/source_map_cache.js +++ b/lib/internal/source_map/source_map_cache.js @@ -244,7 +244,7 @@ function findSourceMap(uri, error) { if (sourceMap && sourceMap.data) { return new SourceMap(sourceMap.data); } else { - return null; + return undefined; } } diff --git a/test/parallel/test-source-map-api.js b/test/parallel/test-source-map-api.js index ffe8ade0f16f5b..e7704cf45cf68e 100644 --- a/test/parallel/test-source-map-api.js +++ b/test/parallel/test-source-map-api.js @@ -76,5 +76,9 @@ const { readFileSync } = require('fs'); assert.strictEqual(originalLine, 2); assert.strictEqual(originalColumn, 4); assert(originalSource.endsWith('disk.js')); - assert.strictEqual(payload, sourceMap.payload); + // 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); }