From 4dced024fdd94a3580794510c651cf4458f1a997 Mon Sep 17 00:00:00 2001 From: bcoe Date: Sat, 4 Jan 2020 19:17:42 -0800 Subject: [PATCH] module: add API for interacting with source maps PR-URL: https://github.com/nodejs/node/pull/31132 Reviewed-By: Anna Henningsen Reviewed-By: Rich Trott Reviewed-By: Benjamin Gruenbaum Reviewed-By: James M Snell --- doc/api/modules.md | 86 +++++++++++++++++++ .../source_map/prepare_stack_trace.js | 18 ++-- lib/internal/source_map/source_map.js | 72 +++++++++++----- lib/internal/source_map/source_map_cache.js | 12 ++- lib/module.js | 8 +- test/parallel/test-source-map-api.js | 84 ++++++++++++++++++ ...ource-map.js => test-source-map-enable.js} | 0 tools/doc/type-parser.js | 4 + 8 files changed, 252 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..049fff804d6aaf 100644 --- a/doc/api/modules.md +++ b/doc/api/modules.md @@ -1033,6 +1033,86 @@ 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 +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 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..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,6 +120,7 @@ class StringCharIterator { * @param {SourceMapV3} payload */ class SourceMap { + #payload; #reverseMappingsBySourceURL = []; #mappings = []; #sources = {}; @@ -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); } /** @@ -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]; } /** @@ -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 }; diff --git a/lib/internal/source_map/source_map_cache.js b/lib/internal/source_map/source_map_cache.js index 593c2c8277f224..b64af7eed6e097 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 undefined; + } } 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..e7704cf45cf68e --- /dev/null +++ b/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); +} 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',