342 lines
15 KiB
JavaScript
342 lines
15 KiB
JavaScript
(function(root, factory) {
|
|
'use strict';
|
|
// Universal Module Definition (UMD) to support AMD, CommonJS/Node.js, Rhino, and browsers.
|
|
|
|
/* istanbul ignore next */
|
|
if (typeof define === 'function' && define.amd) {
|
|
define('stacktrace-gps', ['source-map', 'stackframe'], factory);
|
|
} else if (typeof exports === 'object') {
|
|
module.exports = factory(require('source-map/lib/source-map-consumer'), require('stackframe'));
|
|
} else {
|
|
root.StackTraceGPS = factory(root.SourceMap || root.sourceMap, root.StackFrame);
|
|
}
|
|
}(this, function(SourceMap, StackFrame) {
|
|
'use strict';
|
|
|
|
/**
|
|
* Make a X-Domain request to url and callback.
|
|
*
|
|
* @param {String} url
|
|
* @returns {Promise} with response text if fulfilled
|
|
*/
|
|
function _xdr(url) {
|
|
return new Promise(function(resolve, reject) {
|
|
var req = new XMLHttpRequest();
|
|
req.open('get', url);
|
|
req.onerror = reject;
|
|
req.onreadystatechange = function onreadystatechange() {
|
|
if (req.readyState === 4) {
|
|
if ((req.status >= 200 && req.status < 300) ||
|
|
(url.substr(0, 7) === 'file://' && req.responseText)) {
|
|
resolve(req.responseText);
|
|
} else {
|
|
reject(new Error('HTTP status: ' + req.status + ' retrieving ' + url));
|
|
}
|
|
}
|
|
};
|
|
req.send();
|
|
});
|
|
|
|
}
|
|
|
|
/**
|
|
* Convert a Base64-encoded string into its original representation.
|
|
* Used for inline sourcemaps.
|
|
*
|
|
* @param {String} b64str Base-64 encoded string
|
|
* @returns {String} original representation of the base64-encoded string.
|
|
*/
|
|
function _atob(b64str) {
|
|
if (typeof window !== 'undefined' && window.atob) {
|
|
return window.atob(b64str);
|
|
} else {
|
|
throw new Error('You must supply a polyfill for window.atob in this environment');
|
|
}
|
|
}
|
|
|
|
function _parseJson(string) {
|
|
if (typeof JSON !== 'undefined' && JSON.parse) {
|
|
return JSON.parse(string);
|
|
} else {
|
|
throw new Error('You must supply a polyfill for JSON.parse in this environment');
|
|
}
|
|
}
|
|
|
|
function _findFunctionName(source, lineNumber/*, columnNumber*/) {
|
|
var syntaxes = [
|
|
// {name} = function ({args}) TODO args capture
|
|
/['"]?([$_A-Za-z][$_A-Za-z0-9]*)['"]?\s*[:=]\s*function\b/,
|
|
// function {name}({args}) m[1]=name m[2]=args
|
|
/function\s+([^('"`]*?)\s*\(([^)]*)\)/,
|
|
// {name} = eval()
|
|
/['"]?([$_A-Za-z][$_A-Za-z0-9]*)['"]?\s*[:=]\s*(?:eval|new Function)\b/,
|
|
// fn_name() {
|
|
/\b(?!(?:if|for|switch|while|with|catch)\b)(?:(?:static)\s+)?(\S+)\s*\(.*?\)\s*\{/,
|
|
// {name} = () => {
|
|
/['"]?([$_A-Za-z][$_A-Za-z0-9]*)['"]?\s*[:=]\s*\(.*?\)\s*=>/
|
|
];
|
|
var lines = source.split('\n');
|
|
|
|
// Walk backwards in the source lines until we find the line which matches one of the patterns above
|
|
var code = '';
|
|
var maxLines = Math.min(lineNumber, 20);
|
|
for (var i = 0; i < maxLines; ++i) {
|
|
// lineNo is 1-based, source[] is 0-based
|
|
var line = lines[lineNumber - i - 1];
|
|
var commentPos = line.indexOf('//');
|
|
if (commentPos >= 0) {
|
|
line = line.substr(0, commentPos);
|
|
}
|
|
|
|
if (line) {
|
|
code = line + code;
|
|
var len = syntaxes.length;
|
|
for (var index = 0; index < len; index++) {
|
|
var m = syntaxes[index].exec(code);
|
|
if (m && m[1]) {
|
|
return m[1];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function _ensureSupportedEnvironment() {
|
|
if (typeof Object.defineProperty !== 'function' || typeof Object.create !== 'function') {
|
|
throw new Error('Unable to consume source maps in older browsers');
|
|
}
|
|
}
|
|
|
|
function _ensureStackFrameIsLegit(stackframe) {
|
|
if (typeof stackframe !== 'object') {
|
|
throw new TypeError('Given StackFrame is not an object');
|
|
} else if (typeof stackframe.fileName !== 'string') {
|
|
throw new TypeError('Given file name is not a String');
|
|
} else if (typeof stackframe.lineNumber !== 'number' ||
|
|
stackframe.lineNumber % 1 !== 0 ||
|
|
stackframe.lineNumber < 1) {
|
|
throw new TypeError('Given line number must be a positive integer');
|
|
} else if (typeof stackframe.columnNumber !== 'number' ||
|
|
stackframe.columnNumber % 1 !== 0 ||
|
|
stackframe.columnNumber < 0) {
|
|
throw new TypeError('Given column number must be a non-negative integer');
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function _findSourceMappingURL(source) {
|
|
var sourceMappingUrlRegExp = /\/\/[#@] ?sourceMappingURL=([^\s'"]+)\s*$/mg;
|
|
var lastSourceMappingUrl;
|
|
var matchSourceMappingUrl;
|
|
// eslint-disable-next-line no-cond-assign
|
|
while (matchSourceMappingUrl = sourceMappingUrlRegExp.exec(source)) {
|
|
lastSourceMappingUrl = matchSourceMappingUrl[1];
|
|
}
|
|
if (lastSourceMappingUrl) {
|
|
return lastSourceMappingUrl;
|
|
} else {
|
|
throw new Error('sourceMappingURL not found');
|
|
}
|
|
}
|
|
|
|
function _extractLocationInfoFromSourceMapSource(stackframe, sourceMapConsumer, sourceCache) {
|
|
return new Promise(function(resolve, reject) {
|
|
var loc = sourceMapConsumer.originalPositionFor({
|
|
line: stackframe.lineNumber,
|
|
column: stackframe.columnNumber
|
|
});
|
|
|
|
if (loc.source) {
|
|
// cache mapped sources
|
|
var mappedSource = sourceMapConsumer.sourceContentFor(loc.source);
|
|
if (mappedSource) {
|
|
sourceCache[loc.source] = mappedSource;
|
|
}
|
|
|
|
resolve(
|
|
// given stackframe and source location, update stackframe
|
|
new StackFrame({
|
|
functionName: loc.name || stackframe.functionName,
|
|
args: stackframe.args,
|
|
fileName: loc.source,
|
|
lineNumber: loc.line,
|
|
columnNumber: loc.column
|
|
}));
|
|
} else {
|
|
reject(new Error('Could not get original source for given stackframe and source map'));
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @constructor
|
|
* @param {Object} opts
|
|
* opts.sourceCache = {url: "Source String"} => preload source cache
|
|
* opts.sourceMapConsumerCache = {/path/file.js.map: SourceMapConsumer}
|
|
* opts.offline = True to prevent network requests.
|
|
* Best effort without sources or source maps.
|
|
* opts.ajax = Promise returning function to make X-Domain requests
|
|
*/
|
|
return function StackTraceGPS(opts) {
|
|
if (!(this instanceof StackTraceGPS)) {
|
|
return new StackTraceGPS(opts);
|
|
}
|
|
opts = opts || {};
|
|
|
|
this.sourceCache = opts.sourceCache || {};
|
|
this.sourceMapConsumerCache = opts.sourceMapConsumerCache || {};
|
|
|
|
this.ajax = opts.ajax || _xdr;
|
|
|
|
this._atob = opts.atob || _atob;
|
|
|
|
this._get = function _get(location) {
|
|
return new Promise(function(resolve, reject) {
|
|
var isDataUrl = location.substr(0, 5) === 'data:';
|
|
if (this.sourceCache[location]) {
|
|
resolve(this.sourceCache[location]);
|
|
} else if (opts.offline && !isDataUrl) {
|
|
reject(new Error('Cannot make network requests in offline mode'));
|
|
} else {
|
|
if (isDataUrl) {
|
|
// data URLs can have parameters.
|
|
// see http://tools.ietf.org/html/rfc2397
|
|
var supportedEncodingRegexp =
|
|
/^data:application\/json;([\w=:"-]+;)*base64,/;
|
|
var match = location.match(supportedEncodingRegexp);
|
|
if (match) {
|
|
var sourceMapStart = match[0].length;
|
|
var encodedSource = location.substr(sourceMapStart);
|
|
var source = this._atob(encodedSource);
|
|
this.sourceCache[location] = source;
|
|
resolve(source);
|
|
} else {
|
|
reject(new Error('The encoding of the inline sourcemap is not supported'));
|
|
}
|
|
} else {
|
|
var xhrPromise = this.ajax(location, {method: 'get'});
|
|
// Cache the Promise to prevent duplicate in-flight requests
|
|
this.sourceCache[location] = xhrPromise;
|
|
xhrPromise.then(resolve, reject);
|
|
}
|
|
}
|
|
}.bind(this));
|
|
};
|
|
|
|
/**
|
|
* Creating SourceMapConsumers is expensive, so this wraps the creation of a
|
|
* SourceMapConsumer in a per-instance cache.
|
|
*
|
|
* @param {String} sourceMappingURL = URL to fetch source map from
|
|
* @param {String} defaultSourceRoot = Default source root for source map if undefined
|
|
* @returns {Promise} that resolves a SourceMapConsumer
|
|
*/
|
|
this._getSourceMapConsumer = function _getSourceMapConsumer(sourceMappingURL, defaultSourceRoot) {
|
|
return new Promise(function(resolve) {
|
|
if (this.sourceMapConsumerCache[sourceMappingURL]) {
|
|
resolve(this.sourceMapConsumerCache[sourceMappingURL]);
|
|
} else {
|
|
var sourceMapConsumerPromise = new Promise(function(resolve, reject) {
|
|
return this._get(sourceMappingURL).then(function(sourceMapSource) {
|
|
if (typeof sourceMapSource === 'string') {
|
|
sourceMapSource = _parseJson(sourceMapSource.replace(/^\)\]\}'/, ''));
|
|
}
|
|
if (typeof sourceMapSource.sourceRoot === 'undefined') {
|
|
sourceMapSource.sourceRoot = defaultSourceRoot;
|
|
}
|
|
|
|
resolve(new SourceMap.SourceMapConsumer(sourceMapSource));
|
|
}, reject);
|
|
}.bind(this));
|
|
this.sourceMapConsumerCache[sourceMappingURL] = sourceMapConsumerPromise;
|
|
resolve(sourceMapConsumerPromise);
|
|
}
|
|
}.bind(this));
|
|
};
|
|
|
|
/**
|
|
* Given a StackFrame, enhance function name and use source maps for a
|
|
* better StackFrame.
|
|
*
|
|
* @param {StackFrame} stackframe object
|
|
* @returns {Promise} that resolves with with source-mapped StackFrame
|
|
*/
|
|
this.pinpoint = function StackTraceGPS$$pinpoint(stackframe) {
|
|
return new Promise(function(resolve, reject) {
|
|
this.getMappedLocation(stackframe).then(function(mappedStackFrame) {
|
|
function resolveMappedStackFrame() {
|
|
resolve(mappedStackFrame);
|
|
}
|
|
|
|
this.findFunctionName(mappedStackFrame)
|
|
.then(resolve, resolveMappedStackFrame)
|
|
// eslint-disable-next-line no-unexpected-multiline
|
|
['catch'](resolveMappedStackFrame);
|
|
}.bind(this), reject);
|
|
}.bind(this));
|
|
};
|
|
|
|
/**
|
|
* Given a StackFrame, guess function name from location information.
|
|
*
|
|
* @param {StackFrame} stackframe
|
|
* @returns {Promise} that resolves with enhanced StackFrame.
|
|
*/
|
|
this.findFunctionName = function StackTraceGPS$$findFunctionName(stackframe) {
|
|
return new Promise(function(resolve, reject) {
|
|
_ensureStackFrameIsLegit(stackframe);
|
|
this._get(stackframe.fileName).then(function getSourceCallback(source) {
|
|
var lineNumber = stackframe.lineNumber;
|
|
var columnNumber = stackframe.columnNumber;
|
|
var guessedFunctionName = _findFunctionName(source, lineNumber, columnNumber);
|
|
// Only replace functionName if we found something
|
|
if (guessedFunctionName) {
|
|
resolve(new StackFrame({
|
|
functionName: guessedFunctionName,
|
|
args: stackframe.args,
|
|
fileName: stackframe.fileName,
|
|
lineNumber: lineNumber,
|
|
columnNumber: columnNumber
|
|
}));
|
|
} else {
|
|
resolve(stackframe);
|
|
}
|
|
}, reject)['catch'](reject);
|
|
}.bind(this));
|
|
};
|
|
|
|
/**
|
|
* Given a StackFrame, seek source-mapped location and return new enhanced StackFrame.
|
|
*
|
|
* @param {StackFrame} stackframe
|
|
* @returns {Promise} that resolves with enhanced StackFrame.
|
|
*/
|
|
this.getMappedLocation = function StackTraceGPS$$getMappedLocation(stackframe) {
|
|
return new Promise(function(resolve, reject) {
|
|
_ensureSupportedEnvironment();
|
|
_ensureStackFrameIsLegit(stackframe);
|
|
|
|
var sourceCache = this.sourceCache;
|
|
var fileName = stackframe.fileName;
|
|
this._get(fileName).then(function(source) {
|
|
var sourceMappingURL = _findSourceMappingURL(source);
|
|
var isDataUrl = sourceMappingURL.substr(0, 5) === 'data:';
|
|
var defaultSourceRoot = fileName.substring(0, fileName.lastIndexOf('/') + 1);
|
|
|
|
if (sourceMappingURL[0] !== '/' && !isDataUrl && !(/^https?:\/\/|^\/\//i).test(sourceMappingURL)) {
|
|
sourceMappingURL = defaultSourceRoot + sourceMappingURL;
|
|
}
|
|
|
|
return this._getSourceMapConsumer(sourceMappingURL, defaultSourceRoot)
|
|
.then(function(sourceMapConsumer) {
|
|
return _extractLocationInfoFromSourceMapSource(stackframe, sourceMapConsumer, sourceCache)
|
|
.then(resolve)['catch'](function() {
|
|
resolve(stackframe);
|
|
});
|
|
});
|
|
}.bind(this), reject)['catch'](reject);
|
|
}.bind(this));
|
|
};
|
|
};
|
|
}));
|