diff options
author | bcoe <bencoe@google.com> | 2019-09-30 00:15:39 +0300 |
---|---|---|
committer | Benjamin Coe <bencoe@google.com> | 2019-10-06 03:08:00 +0300 |
commit | e1e2f669f65fd53323b8a58d80ed3cee039706b7 (patch) | |
tree | 582baba1511e57a9915b379d0f263cb29fb6c38f /lib/internal/source_map | |
parent | 739f113ba63367a93e1567032d85573a079b97b5 (diff) |
process: add source-map support to stack traces
PR-URL: https://github.com/nodejs/node/pull/29564
Reviewed-By: Anna Henningsen <anna@addaleax.net>
Reviewed-By: Benjamin Gruenbaum <benjamingr@gmail.com>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: James M Snell <jasnell@gmail.com>
Diffstat (limited to 'lib/internal/source_map')
-rw-r--r-- | lib/internal/source_map/source_map.js | 301 | ||||
-rw-r--r-- | lib/internal/source_map/source_map_cache.js | 237 |
2 files changed, 538 insertions, 0 deletions
diff --git a/lib/internal/source_map/source_map.js b/lib/internal/source_map/source_map.js new file mode 100644 index 00000000000..9044521b6d6 --- /dev/null +++ b/lib/internal/source_map/source_map.js @@ -0,0 +1,301 @@ +// This file is a modified version of: +// https://cs.chromium.org/chromium/src/v8/tools/SourceMap.js?rcl=dd10454c1d +// from the V8 codebase. Logic specific to WebInspector is removed and linting +// is made to match the Node.js style guide. + +// Copyright 2013 the V8 project authors. All rights reserved. +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following +// disclaimer in the documentation and/or other materials provided +// with the distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived +// from this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +// This is a copy from blink dev tools, see: +// http://src.chromium.org/viewvc/blink/trunk/Source/devtools/front_end/SourceMap.js +// revision: 153407 + +/* + * Copyright (C) 2012 Google Inc. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google Inc. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +'use strict'; + +let base64Map; + +const VLQ_BASE_SHIFT = 5; +const VLQ_BASE_MASK = (1 << 5) - 1; +const VLQ_CONTINUATION_MASK = 1 << 5; + +class StringCharIterator { + /** + * @constructor + * @param {string} string + */ + constructor(string) { + this._string = string; + this._position = 0; + } + + /** + * @return {string} + */ + next() { + return this._string.charAt(this._position++); + } + + /** + * @return {string} + */ + peek() { + return this._string.charAt(this._position); + } + + /** + * @return {boolean} + */ + hasNext() { + return this._position < this._string.length; + } +} + +/** + * Implements Source Map V3 model. See http://code.google.com/p/closure-compiler/wiki/SourceMaps + * for format description. + * @constructor + * @param {string} sourceMappingURL + * @param {SourceMapV3} payload + */ +class SourceMap { + #reverseMappingsBySourceURL = []; + #mappings = []; + #sources = {}; + #sourceContentByURL = {}; + + /** + * @constructor + * @param {SourceMapV3} payload + */ + constructor(payload) { + if (!base64Map) { + const base64Digits = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; + base64Map = {}; + for (let i = 0; i < base64Digits.length; ++i) + base64Map[base64Digits[i]] = i; + } + this.#parseMappingPayload(payload); + } + + /** + * @param {SourceMapV3} mappingPayload + */ + #parseMappingPayload = (mappingPayload) => { + if (mappingPayload.sections) + this.#parseSections(mappingPayload.sections); + else + this.#parseMap(mappingPayload, 0, 0); + } + + /** + * @param {Array.<SourceMapV3.Section>} sections + */ + #parseSections = (sections) => { + for (let i = 0; i < sections.length; ++i) { + const section = sections[i]; + this.#parseMap(section.map, section.offset.line, section.offset.column); + } + } + + /** + * @param {number} lineNumber in compiled resource + * @param {number} columnNumber in compiled resource + * @return {?Array} + */ + findEntry(lineNumber, columnNumber) { + let first = 0; + let count = this.#mappings.length; + while (count > 1) { + const step = count >> 1; + const middle = first + step; + const mapping = this.#mappings[middle]; + if (lineNumber < mapping[0] || + (lineNumber === mapping[0] && columnNumber < mapping[1])) { + count = step; + } else { + first = middle; + count -= step; + } + } + 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 this.#mappings[0]; + } + + /** + * @override + */ + #parseMap = (map, lineNumber, columnNumber) => { + let sourceIndex = 0; + let sourceLineNumber = 0; + let sourceColumnNumber = 0; + + const sources = []; + const originalToCanonicalURLMap = {}; + for (let i = 0; i < map.sources.length; ++i) { + const url = map.sources[i]; + originalToCanonicalURLMap[url] = url; + sources.push(url); + this.#sources[url] = true; + + if (map.sourcesContent && map.sourcesContent[i]) + this.#sourceContentByURL[url] = map.sourcesContent[i]; + } + + const stringCharIterator = new StringCharIterator(map.mappings); + let sourceURL = sources[sourceIndex]; + + while (true) { + if (stringCharIterator.peek() === ',') + stringCharIterator.next(); + else { + while (stringCharIterator.peek() === ';') { + lineNumber += 1; + columnNumber = 0; + stringCharIterator.next(); + } + if (!stringCharIterator.hasNext()) + break; + } + + columnNumber += decodeVLQ(stringCharIterator); + if (isSeparator(stringCharIterator.peek())) { + this.#mappings.push([lineNumber, columnNumber]); + continue; + } + + const sourceIndexDelta = decodeVLQ(stringCharIterator); + if (sourceIndexDelta) { + sourceIndex += sourceIndexDelta; + sourceURL = sources[sourceIndex]; + } + sourceLineNumber += decodeVLQ(stringCharIterator); + sourceColumnNumber += decodeVLQ(stringCharIterator); + if (!isSeparator(stringCharIterator.peek())) + // Unused index into the names list. + decodeVLQ(stringCharIterator); + + this.#mappings.push([lineNumber, columnNumber, sourceURL, + sourceLineNumber, sourceColumnNumber]); + } + + for (let i = 0; i < this.#mappings.length; ++i) { + const mapping = this.#mappings[i]; + const url = mapping[2]; + if (!url) + continue; + if (!this.#reverseMappingsBySourceURL[url]) + this.#reverseMappingsBySourceURL[url] = []; + const reverseMappings = this.#reverseMappingsBySourceURL[url]; + const sourceLine = mapping[3]; + if (!reverseMappings[sourceLine]) + reverseMappings[sourceLine] = [mapping[0], mapping[1]]; + } + }; +} + +/** + * @param {string} char + * @return {boolean} + */ +function isSeparator(char) { + return char === ',' || char === ';'; +} + +/** + * @param {SourceMap.StringCharIterator} stringCharIterator + * @return {number} + */ +function decodeVLQ(stringCharIterator) { + // Read unsigned value. + let result = 0; + let shift = 0; + let digit; + do { + digit = base64Map[stringCharIterator.next()]; + result += (digit & VLQ_BASE_MASK) << shift; + shift += VLQ_BASE_SHIFT; + } while (digit & VLQ_CONTINUATION_MASK); + + // Fix the sign. + const negative = result & 1; + result >>= 1; + return negative ? -result : result; +} + +module.exports = { + SourceMap +}; diff --git a/lib/internal/source_map/source_map_cache.js b/lib/internal/source_map/source_map_cache.js new file mode 100644 index 00000000000..94a4165546b --- /dev/null +++ b/lib/internal/source_map/source_map_cache.js @@ -0,0 +1,237 @@ +'use strict'; + +// See https://sourcemaps.info/spec.html for SourceMap V3 specification. +const { Buffer } = require('buffer'); +const debug = require('internal/util/debuglog').debuglog('source_map'); +const { dirname, resolve } = require('path'); +const fs = require('fs'); +const { getOptionValue } = require('internal/options'); +const { + normalizeReferrerURL, +} = require('internal/modules/cjs/helpers'); +const { JSON, Object } = primordials; +// For cjs, since Module._cache is exposed to users, we use a WeakMap +// keyed on module, facilitating garbage collection. +const cjsSourceMapCache = new WeakMap(); +// The esm cache is not exposed to users, so we can use a Map keyed +// on filenames. +const esmSourceMapCache = new Map(); +const { fileURLToPath, URL } = require('url'); +const { overrideStackTrace } = require('internal/errors'); + +let experimentalSourceMaps; +function maybeCacheSourceMap(filename, content, cjsModuleInstance) { + if (experimentalSourceMaps === undefined) { + experimentalSourceMaps = getOptionValue('--enable-source-maps'); + } + if (!(process.env.NODE_V8_COVERAGE || experimentalSourceMaps)) return; + let basePath; + try { + filename = normalizeReferrerURL(filename); + basePath = dirname(fileURLToPath(filename)); + } catch (err) { + // This is most likely an [eval]-wrapper, which is currently not + // supported. + debug(err.stack); + return; + } + + const match = content.match(/\/[*/]#\s+sourceMappingURL=(?<sourceMappingURL>[^\s]+)/); + if (match) { + if (cjsModuleInstance) { + cjsSourceMapCache.set(cjsModuleInstance, { + filename, + url: match.groups.sourceMappingURL, + data: dataFromUrl(basePath, match.groups.sourceMappingURL) + }); + } else { + // If there is no cjsModuleInstance assume we are in a + // "modules/esm" context. + esmSourceMapCache.set(filename, { + url: match.groups.sourceMappingURL, + data: dataFromUrl(basePath, match.groups.sourceMappingURL) + }); + } + } +} + +function dataFromUrl(basePath, sourceMappingURL) { + try { + const url = new URL(sourceMappingURL); + switch (url.protocol) { + case 'data:': + return sourceMapFromDataUrl(basePath, url.pathname); + default: + debug(`unknown protocol ${url.protocol}`); + return null; + } + } catch (err) { + debug(err.stack); + // If no scheme is present, we assume we are dealing with a file path. + const sourceMapFile = resolve(basePath, sourceMappingURL); + return sourceMapFromFile(sourceMapFile); + } +} + +function sourceMapFromFile(sourceMapFile) { + try { + const content = fs.readFileSync(sourceMapFile, 'utf8'); + const data = JSON.parse(content); + return sourcesToAbsolute(dirname(sourceMapFile), data); + } catch (err) { + debug(err.stack); + return null; + } +} + +// data:[<mediatype>][;base64],<data> see: +// https://tools.ietf.org/html/rfc2397#section-2 +function sourceMapFromDataUrl(basePath, url) { + const [format, data] = url.split(','); + const splitFormat = format.split(';'); + const contentType = splitFormat[0]; + const base64 = splitFormat[splitFormat.length - 1] === 'base64'; + if (contentType === 'application/json') { + const decodedData = base64 ? + Buffer.from(data, 'base64').toString('utf8') : data; + try { + const parsedData = JSON.parse(decodedData); + return sourcesToAbsolute(basePath, parsedData); + } catch (err) { + debug(err.stack); + return null; + } + } else { + debug(`unknown content-type ${contentType}`); + return null; + } +} + +// If the sources are not absolute URLs after prepending of the "sourceRoot", +// the sources are resolved relative to the SourceMap (like resolving script +// src in a html document). +function sourcesToAbsolute(base, data) { + data.sources = data.sources.map((source) => { + source = (data.sourceRoot || '') + source; + if (!/^[\\/]/.test(source[0])) { + source = resolve(base, source); + } + if (!source.startsWith('file://')) source = `file://${source}`; + return source; + }); + // The sources array is now resolved to absolute URLs, sourceRoot should + // be updated to noop. + data.sourceRoot = ''; + return data; +} + +// Move source map from garbage collected module to alternate key. +function rekeySourceMap(cjsModuleInstance, newInstance) { + const sourceMap = cjsSourceMapCache.get(cjsModuleInstance); + if (sourceMap) { + cjsSourceMapCache.set(newInstance, sourceMap); + } +} + +// Get serialized representation of source-map cache, this is used +// to persist a cache of source-maps to disk when NODE_V8_COVERAGE is enabled. +function sourceMapCacheToObject() { + const obj = Object.create(null); + + for (const [k, v] of esmSourceMapCache) { + obj[k] = v; + } + appendCJSCache(obj); + + if (Object.keys(obj).length === 0) { + return undefined; + } else { + return obj; + } +} + +// Since WeakMap can't be iterated over, we use Module._cache's +// keys to facilitate Source Map serialization. +// +// TODO(bcoe): this means we don't currently serialize source-maps attached +// to error instances, only module instances. +function appendCJSCache(obj) { + const { Module } = require('internal/modules/cjs/loader'); + Object.keys(Module._cache).forEach((key) => { + const value = cjsSourceMapCache.get(Module._cache[key]); + if (value) { + obj[`file://${key}`] = { + url: value.url, + data: value.data + }; + } + }); +} + +// Create a prettified stacktrace, inserting context from source maps +// if possible. +const ErrorToString = Error.prototype.toString; // Capture original toString. +const prepareStackTrace = (globalThis, error, trace) => { + // API for node internals to override error stack formatting + // without interfering with userland code. + // TODO(bcoe): add support for source-maps to repl. + if (overrideStackTrace.has(error)) { + const f = overrideStackTrace.get(error); + overrideStackTrace.delete(error); + return f(error, trace); + } + + const { SourceMap } = require('internal/source_map/source_map'); + const errorString = ErrorToString.call(error); + + if (trace.length === 0) { + return errorString; + } + const preparedTrace = trace.map((t, i) => { + 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); + // 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) { + str += + `\n -> ${url.replace('file://', '')}:${line + 1}:${col + 1}`; + } + } + } catch (err) { + debug(err.stack); + } + return str; + }); + return `${errorString}\n at ${preparedTrace.join('')}`; +}; + +// Attempt to lookup a source map, which is either attached to a file URI, or +// keyed on an error instance. +function findSourceMap(uri, error) { + const { Module } = require('internal/modules/cjs/loader'); + let sourceMap = cjsSourceMapCache.get(Module._cache[uri]); + if (!uri.startsWith('file://')) uri = normalizeReferrerURL(uri); + if (sourceMap === undefined) { + sourceMap = esmSourceMapCache.get(uri); + } + if (sourceMap === undefined) { + const candidateSourceMap = cjsSourceMapCache.get(error); + if (candidateSourceMap && uri === candidateSourceMap.filename) { + sourceMap = candidateSourceMap; + } + } + return sourceMap; +} + +module.exports = { + maybeCacheSourceMap, + prepareStackTrace, + rekeySourceMap, + sourceMapCacheToObject, +}; |