diff --git a/react-packager/src/Server/__tests__/Server-test.js b/react-packager/src/Server/__tests__/Server-test.js index c96b9ce1..9d5ec47a 100644 --- a/react-packager/src/Server/__tests__/Server-test.js +++ b/react-packager/src/Server/__tests__/Server-test.js @@ -14,6 +14,7 @@ jest.setMock('worker-farm', function() { return () => {}; }) .setMock('timers', { setImmediate: (fn) => setTimeout(fn, 0) }) .setMock('uglify-js') .setMock('crypto') + .setMock('source-map', { SourceMapConsumer: (fn) => {}}) .mock('../../Bundler') .mock('../../AssetServer') .mock('../../lib/declareOpts') @@ -21,6 +22,7 @@ jest.setMock('worker-farm', function() { return () => {}; }) .mock('../../Activity'); const Promise = require('promise'); +const SourceMapConsumer = require('source-map').SourceMapConsumer; const Bundler = require('../../Bundler'); const Server = require('../'); @@ -392,4 +394,59 @@ describe('processRequest', () => { ); }); }); + + describe('/symbolicate endpoint', () => { + pit('should symbolicate given stack trace', () => { + const body = JSON.stringify({stack: [{ + file: 'foo.bundle?platform=ios', + lineNumber: 2100, + column: 44, + customPropShouldBeLeftUnchanged: 'foo', + }]}); + + SourceMapConsumer.prototype.originalPositionFor = jest.fn((frame) => { + expect(frame.line).toEqual(2100); + expect(frame.column).toEqual(44); + return { + source: 'foo.js', + line: 21, + column: 4, + }; + }); + + return makeRequest( + requestHandler, + '/symbolicate', + { rawBody: body } + ).then(response => { + expect(JSON.parse(response.body)).toEqual({ + stack: [{ + file: 'foo.js', + lineNumber: 21, + column: 4, + customPropShouldBeLeftUnchanged: 'foo', + }] + }); + }); + }); + }); + + describe('/symbolicate handles errors', () => { + pit('should symbolicate given stack trace', () => { + const body = 'clearly-not-json'; + console.error = jest.fn(); + + return makeRequest( + requestHandler, + '/symbolicate', + { rawBody: body } + ).then(response => { + expect(response.statusCode).toEqual(500); + expect(JSON.parse(response.body)).toEqual({ + error: jasmine.any(String), + }); + expect(console.error).toBeCalled(); + }); + }); + }); }); diff --git a/react-packager/src/Server/index.js b/react-packager/src/Server/index.js index ecce02fd..10258d4f 100644 --- a/react-packager/src/Server/index.js +++ b/react-packager/src/Server/index.js @@ -14,6 +14,7 @@ const FileWatcher = require('node-haste').FileWatcher; const getPlatformExtension = require('node-haste').getPlatformExtension; const Bundler = require('../Bundler'); const Promise = require('promise'); +const SourceMapConsumer = require('source-map').SourceMapConsumer; const _ = require('lodash'); const declareOpts = require('../lib/declareOpts'); @@ -428,6 +429,9 @@ class Server { } else if (pathname.match(/^\/assets\//)) { this._processAssetsRequest(req, res); return; + } else if (pathname === '/symbolicate') { + this._symbolicate(req, res); + return; } else { next(); return; @@ -480,6 +484,64 @@ class Server { ).done(); } + _symbolicate(req, res) { + const startReqEventId = Activity.startEvent('symbolicate'); + new Promise.resolve(req.rawBody).then(body => { + const stack = JSON.parse(body).stack; + + // In case of multiple bundles / HMR, some stack frames can have + // different URLs from others + const urls = stack.map(frame => frame.file); + const uniqueUrls = urls.filter((elem, idx) => urls.indexOf(elem) === idx); + + const sourceMaps = uniqueUrls.map(sourceUrl => this._sourceMapForURL(sourceUrl)); + return Promise.all(sourceMaps).then(consumers => { + return stack.map(frame => { + const idx = uniqueUrls.indexOf(frame.file); + const consumer = consumers[idx]; + + const original = consumer.originalPositionFor({ + line: frame.lineNumber, + column: frame.column, + }); + + if (!original) { + return frame; + } + + return Object.assign({}, frame, { + file: original.source, + lineNumber: original.line, + column: original.column, + }); + }); + }); + }).then( + stack => res.end(JSON.stringify({stack: stack})), + error => { + console.error(error.stack || error); + res.statusCode = 500; + res.end(JSON.stringify({error: error.message})); + } + ).done(() => { + Activity.endEvent(startReqEventId); + }); + } + + _sourceMapForURL(reqUrl) { + const options = this._getOptionsFromUrl(reqUrl); + const optionsJson = JSON.stringify(options); + const building = this._bundles[optionsJson] || this.buildBundle(options); + this._bundles[optionsJson] = building; + return building.then(p => { + const sourceMap = p.getSourceMap({ + minify: options.minify, + dev: options.dev, + }); + return new SourceMapConsumer(sourceMap); + }); + } + _handleError(res, bundleID, error) { res.writeHead(error.status || 500, { 'Content-Type': 'application/json; charset=UTF-8',