diff --git a/docs/Configuration.md b/docs/Configuration.md index d452173670..4c2ed18e2f 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -385,7 +385,7 @@ The possibility to add custom middleware to the server response chain. Type: `string => string` -A function that will be called every time Metro processes a URL. Metro will use the return value of this function as if it were the original URL provided by the client. This applies to all incoming HTTP requests (after any custom middleware), as well as bundle URLs in `/symbolicate` request payloads and within the hot reloading protocol. +A function that will be called every time Metro processes a URL, after normalization of non-standard query-string delimiters using [`jsc-safe-url`](https://www.npmjs.com/package/jsc-safe-url). Metro will use the return value of this function as if it were the original URL provided by the client. This applies to all incoming HTTP requests (after any custom middleware), as well as bundle URLs in `/symbolicate` request payloads and within the hot reloading protocol. #### `runInspectorProxy` diff --git a/packages/metro/package.json b/packages/metro/package.json index fe9c5a1696..922cb0b9cd 100644 --- a/packages/metro/package.json +++ b/packages/metro/package.json @@ -35,6 +35,7 @@ "image-size": "^0.6.0", "invariant": "^2.2.4", "jest-worker": "^27.2.0", + "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.72.3", "metro-cache": "0.72.3", diff --git a/packages/metro/src/Server.js b/packages/metro/src/Server.js index 45905f2e89..1f8e1987a6 100644 --- a/packages/metro/src/Server.js +++ b/packages/metro/src/Server.js @@ -65,6 +65,8 @@ const {codeFrameColumns} = require('@babel/code-frame'); const MultipartResponse = require('./Server/MultipartResponse'); const debug = require('debug')('Metro:Server'); const fs = require('graceful-fs'); +const invariant = require('invariant'); +const jscSafeUrl = require('jsc-safe-url'); const { Logger, Logger: {createActionStartEntry, createActionEndEntry, log}, @@ -474,14 +476,19 @@ class Server { ); } + _rewriteAndNormalizeUrl(requestUrl: string): string { + return jscSafeUrl.toNormalUrl( + this._config.server.rewriteRequestUrl(jscSafeUrl.toNormalUrl(requestUrl)), + ); + } + async _processRequest( req: IncomingMessage, res: ServerResponse, next: (?Error) => mixed, ) { const originalUrl = req.url; - req.url = this._config.server.rewriteRequestUrl(req.url); - + req.url = this._rewriteAndNormalizeUrl(req.url); const urlObj = url.parse(req.url, true); const {host} = req.headers; debug( @@ -1145,19 +1152,34 @@ class Server { debug('Start symbolication'); /* $FlowFixMe: where is `rawBody` defined? Is it added by the `connect` framework? */ const body = await req.rawBody; - const stack = JSON.parse(body).stack.map(frame => { - if (frame.file && frame.file.includes('://')) { + const parsedBody = JSON.parse(body); + + const rewriteAndNormalizeStackFrame = ( + frame: T, + lineNumber: number, + ): T => { + invariant( + frame != null && typeof frame === 'object', + 'Bad stack frame at line %d, expected object, received: %s', + lineNumber, + typeof frame, + ); + const frameFile = frame.file; + if (typeof frameFile === 'string' && frameFile.includes('://')) { return { ...frame, - file: this._config.server.rewriteRequestUrl(frame.file), + file: this._rewriteAndNormalizeUrl(frameFile), }; } return frame; - }); + }; + + const stack = parsedBody.stack.map(rewriteAndNormalizeStackFrame); // In case of multiple bundles / HMR, some stack frames can have different URLs from others const urls = new Set(); stack.forEach(frame => { + // These urls have been rewritten and normalized above. const sourceUrl = frame.file; // Skip `/debuggerWorker.js` which does not need symbolication. if ( @@ -1172,8 +1194,11 @@ class Server { debug('Getting source maps for symbolication'); const sourceMaps = await Promise.all( - // $FlowFixMe[method-unbinding] added when improving typing for this parameters - Array.from(urls.values()).map(this._explodedSourceMapForURL, this), + Array.from(urls.values()).map(normalizedUrl => + this._explodedSourceMapForBundleOptions( + this._parseOptions(normalizedUrl), + ), + ), ); debug('Performing fast symbolication'); @@ -1200,13 +1225,9 @@ class Server { } } - async _explodedSourceMapForURL(reqUrl: string): Promise { - const options = parseOptionsFromUrl( - reqUrl, - new Set(this._config.resolver.platforms), - getBytecodeVersion(), - ); - + async _explodedSourceMapForBundleOptions( + bundleOptions: BundleOptions, + ): Promise { const { entryFile, graphOptions, @@ -1214,7 +1235,7 @@ class Server { resolverOptions, serializerOptions, transformOptions, - } = splitBundleOptions(options); + } = splitBundleOptions(bundleOptions); /** * `entryFile` is relative to projectRoot, we need to use resolution function diff --git a/packages/metro/src/Server/__tests__/Server-test.js b/packages/metro/src/Server/__tests__/Server-test.js index d7f42d1a10..09e2938adb 100644 --- a/packages/metro/src/Server/__tests__/Server-test.js +++ b/packages/metro/src/Server/__tests__/Server-test.js @@ -116,13 +116,13 @@ describe('processRequest', () => { reporter: require('../../lib/reporting').nullReporter, server: { - rewriteRequestUrl(requrl) { + rewriteRequestUrl: jest.fn().mockImplementation(requrl => { const rewritten = requrl.replace(/__REMOVE_THIS_WHEN_REWRITING__/g, ''); if (rewritten !== requrl) { return rewritten + '&TEST_URL_WAS_REWRITTEN=true'; } return requrl; - }, + }), }, symbolicator: { customizeFrame: ({file}) => { @@ -303,20 +303,26 @@ describe('processRequest', () => { fs.realpath = jest.fn((file, cb) => cb(null, '/root/foo.js')); }); - it('returns JS bundle source on request of *.bundle', async () => { - const response = await makeRequest('mybundle.bundle?runModule=true', null); + it.each(['?', '//&'])( + 'returns JS bundle source on request of *.bundle (delimiter: %s)', + async delimiter => { + const response = await makeRequest( + `mybundle.bundle${delimiter}runModule=true`, + null, + ); - expect(response._getString()).toEqual( - [ - 'function () {require();}', - '__d(function() {entry();},0,[1],"mybundle.js");', - '__d(function() {foo();},1,[],"foo.js");', - 'require(0);', - '//# sourceMappingURL=//localhost:8081/mybundle.map?runModule=true', - '//# sourceURL=http://localhost:8081/mybundle.bundle?runModule=true', - ].join('\n'), - ); - }); + expect(response._getString()).toEqual( + [ + 'function () {require();}', + '__d(function() {entry();},0,[1],"mybundle.js");', + '__d(function() {foo();},1,[],"foo.js");', + 'require(0);', + '//# sourceMappingURL=//localhost:8081/mybundle.map?runModule=true', + '//# sourceURL=http://localhost:8081/mybundle.bundle?runModule=true', + ].join('\n'), + ); + }, + ); it('returns a bytecode bundle source on request of *.bundle?runtimeBytecodeVersion', async () => { const response = await makeRequest( @@ -714,23 +720,32 @@ describe('processRequest', () => { }); }); - it('rewrites URLs before bundling', async () => { - const response = await makeRequest( - 'mybundle.bundle?runModule=true__REMOVE_THIS_WHEN_REWRITING__', - null, - ); + it.each(['?', '//&'])( + 'rewrites URLs before bundling (query delimiter: %s)', + async delimiter => { + jest.clearAllMocks(); - expect(response._getString()).toEqual( - [ - 'function () {require();}', - '__d(function() {entry();},0,[1],"mybundle.js");', - '__d(function() {foo();},1,[],"foo.js");', - 'require(0);', - '//# sourceMappingURL=//localhost:8081/mybundle.map?runModule=true&TEST_URL_WAS_REWRITTEN=true', - '//# sourceURL=http://localhost:8081/mybundle.bundle?runModule=true&TEST_URL_WAS_REWRITTEN=true', - ].join('\n'), - ); - }); + const response = await makeRequest( + `mybundle.bundle${delimiter}runModule=true__REMOVE_THIS_WHEN_REWRITING__`, + null, + ); + + expect(config.server.rewriteRequestUrl).toHaveBeenCalledWith( + 'mybundle.bundle?runModule=true__REMOVE_THIS_WHEN_REWRITING__', + ); + + expect(response._getString()).toEqual( + [ + 'function () {require();}', + '__d(function() {entry();},0,[1],"mybundle.js");', + '__d(function() {foo();},1,[],"foo.js");', + 'require(0);', + '//# sourceMappingURL=//localhost:8081/mybundle.map?runModule=true&TEST_URL_WAS_REWRITTEN=true', + '//# sourceURL=http://localhost:8081/mybundle.bundle?runModule=true&TEST_URL_WAS_REWRITTEN=true', + ].join('\n'), + ); + }, + ); it('does not rebuild the bundle when making concurrent requests', async () => { // Delay the response of the buildGraph method. @@ -900,31 +915,33 @@ describe('processRequest', () => { }); }); - describe('/symbolicate endpoint', () => { - beforeEach(() => { - fs.mkdirSync('/root'); - fs.writeFileSync( - '/root/mybundle.js', - 'this\nis\njust an example and it is all fake data, yay!', - ); - }); - - it('should symbolicate given stack trace', async () => { - const response = await makeRequest('/symbolicate', { - rawBody: JSON.stringify({ - stack: [ - { - file: 'http://localhost:8081/mybundle.bundle?runModule=true', - lineNumber: 2, - column: 18, - customPropShouldBeLeftUnchanged: 'foo', - methodName: 'clientSideMethodName', - }, - ], - }), + describe.each(['?', '//&'])( + '/symbolicate endpoint (query delimiter: %s)', + queryDelimiter => { + beforeEach(() => { + fs.mkdirSync('/root'); + fs.writeFileSync( + '/root/mybundle.js', + 'this\nis\njust an example and it is all fake data, yay!', + ); }); - expect(response._getJSON()).toMatchInlineSnapshot(` + it('should symbolicate given stack trace', async () => { + const response = await makeRequest('/symbolicate', { + rawBody: JSON.stringify({ + stack: [ + { + file: `http://localhost:8081/mybundle.bundle${queryDelimiter}runModule=true`, + lineNumber: 2, + column: 18, + customPropShouldBeLeftUnchanged: 'foo', + methodName: 'clientSideMethodName', + }, + ], + }), + }); + + expect(response._getJSON()).toMatchInlineSnapshot(` Object { "codeFrame": Object { "content": "> 1 | this @@ -949,145 +966,148 @@ describe('processRequest', () => { ], } `); - }); + }); - describe('should rewrite URLs before symbolicating', () => { - test('mapped location symbolicates correctly', async () => { - const mappedLocation = { - lineNumber: 2, - column: 18, - customPropShouldBeLeftUnchanged: 'foo', - methodName: 'clientSideMethodName', - }; + describe('should rewrite URLs before symbolicating', () => { + test('mapped location symbolicates correctly', async () => { + const mappedLocation = { + lineNumber: 2, + column: 18, + customPropShouldBeLeftUnchanged: 'foo', + methodName: 'clientSideMethodName', + }; + + const response = await makeRequest('/symbolicate', { + rawBody: JSON.stringify({ + stack: [ + { + file: `http://localhost:8081/my__REMOVE_THIS_WHEN_REWRITING__bundle.bundle${queryDelimiter}runModule=true`, + ...mappedLocation, + }, + ], + }), + }); - const response = await makeRequest('/symbolicate', { + expect(response._getJSON()).toEqual( + JSON.parse( + ( + await makeRequest('/symbolicate', { + rawBody: JSON.stringify({ + stack: [ + { + file: `http://localhost:8081/mybundle.bundle${queryDelimiter}runModule=true`, + ...mappedLocation, + }, + ], + }), + }) + )._getString(), + ), + ); + }); + + test('unmapped location returns the rewritten URL', async () => { + const unmappedLocation = { + lineNumber: 200000, + column: 18, + customPropShouldBeLeftUnchanged: 'foo', + methodName: 'clientSideMethodName', + }; + + const response = await makeRequest('/symbolicate', { + rawBody: JSON.stringify({ + stack: [ + { + file: `http://localhost:8081/my__REMOVE_THIS_WHEN_REWRITING__bundle.bundle${queryDelimiter}runModule=true`, + ...unmappedLocation, + }, + ], + }), + }); + + expect(response._getJSON().stack[0].file).toBe( + 'http://localhost:8081/mybundle.bundle?runModule=true&TEST_URL_WAS_REWRITTEN=true', + ); + }); + }); + + it('should update the graph when symbolicating a second time', async () => { + const requestData = { rawBody: JSON.stringify({ stack: [ { - file: 'http://localhost:8081/my__REMOVE_THIS_WHEN_REWRITING__bundle.bundle?runModule=true', - ...mappedLocation, + file: `http://localhost:8081/mybundle.bundle${queryDelimiter}runModule=true`, + lineNumber: 2, + column: 18, + customPropShouldBeLeftUnchanged: 'foo', + methodName: 'clientSideMethodName', }, ], }), - }); + }; - expect(response._getJSON()).toEqual( - JSON.parse( - ( - await makeRequest('/symbolicate', { - rawBody: JSON.stringify({ - stack: [ - { - file: 'http://localhost:8081/mybundle.bundle?runModule=true', - ...mappedLocation, - }, - ], - }), - }) - )._getString(), - ), + const IncrementalBundler = require('../../IncrementalBundler'); + const updateSpy = jest.spyOn( + IncrementalBundler.prototype, + 'updateGraph', + ); + const initSpy = jest.spyOn( + IncrementalBundler.prototype, + 'initializeGraph', ); - }); - test('unmapped location returns the rewritten URL', async () => { - const unmappedLocation = { - lineNumber: 200000, - column: 18, - customPropShouldBeLeftUnchanged: 'foo', - methodName: 'clientSideMethodName', - }; + // When symbolicating a bundle the first time, we expect to create a graph for it. + await makeRequest('/symbolicate', requestData); + expect(initSpy).toBeCalledTimes(1); + expect(updateSpy).not.toBeCalled(); + + // When symbolicating the same bundle a second time, the bundle graph may be out of date. + // Let's be sure to update the bundle graph. + await makeRequest('/symbolicate', requestData); + expect(initSpy).toBeCalledTimes(1); + expect(updateSpy).toBeCalledTimes(1); + }); + it('supports the `modulesOnly` option', async () => { const response = await makeRequest('/symbolicate', { rawBody: JSON.stringify({ stack: [ { - file: 'http://localhost:8081/my__REMOVE_THIS_WHEN_REWRITING__bundle.bundle?runModule=true', - ...unmappedLocation, + file: `http://localhost:8081/mybundle.bundle${queryDelimiter}runModule=true&modulesOnly=true`, + lineNumber: 2, + column: 16, }, ], }), }); - expect(response._getJSON().stack[0].file).toBe( - 'http://localhost:8081/mybundle.bundle?runModule=true&TEST_URL_WAS_REWRITTEN=true', - ); - }); - }); - - it('should update the graph when symbolicating a second time', async () => { - const requestData = { - rawBody: JSON.stringify({ + expect(response._getJSON()).toMatchObject({ stack: [ - { - file: 'http://localhost:8081/mybundle.bundle?runModule=true', - lineNumber: 2, - column: 18, - customPropShouldBeLeftUnchanged: 'foo', - methodName: 'clientSideMethodName', - }, + expect.objectContaining({ + column: 0, + file: '/root/foo.js', + lineNumber: 1, + }), ], - }), - }; - - const IncrementalBundler = require('../../IncrementalBundler'); - const updateSpy = jest.spyOn(IncrementalBundler.prototype, 'updateGraph'); - const initSpy = jest.spyOn( - IncrementalBundler.prototype, - 'initializeGraph', - ); - - // When symbolicating a bundle the first time, we expect to create a graph for it. - await makeRequest('/symbolicate', requestData); - expect(initSpy).toBeCalledTimes(1); - expect(updateSpy).not.toBeCalled(); - - // When symbolicating the same bundle a second time, the bundle graph may be out of date. - // Let's be sure to update the bundle graph. - await makeRequest('/symbolicate', requestData); - expect(initSpy).toBeCalledTimes(1); - expect(updateSpy).toBeCalledTimes(1); - }); - - it('supports the `modulesOnly` option', async () => { - const response = await makeRequest('/symbolicate', { - rawBody: JSON.stringify({ - stack: [ - { - file: 'http://localhost:8081/mybundle.bundle?runModule=true&modulesOnly=true', - lineNumber: 2, - column: 16, - }, - ], - }), + }); }); - expect(response._getJSON()).toMatchObject({ - stack: [ - expect.objectContaining({ - column: 0, - file: '/root/foo.js', - lineNumber: 1, + it('supports the `shallow` option', async () => { + const response = await makeRequest('/symbolicate', { + rawBody: JSON.stringify({ + stack: [ + { + file: `http://localhost:8081/mybundle.bundle${queryDelimiter}runModule=true&shallow=true`, + lineNumber: 2, + column: 18, + customPropShouldBeLeftUnchanged: 'foo', + methodName: 'clientSideMethodName', + }, + ], }), - ], - }); - }); - - it('supports the `shallow` option', async () => { - const response = await makeRequest('/symbolicate', { - rawBody: JSON.stringify({ - stack: [ - { - file: 'http://localhost:8081/mybundle.bundle?runModule=true&shallow=true', - lineNumber: 2, - column: 18, - customPropShouldBeLeftUnchanged: 'foo', - methodName: 'clientSideMethodName', - }, - ], - }), - }); + }); - expect(response._getJSON()).toMatchInlineSnapshot(` + expect(response._getJSON()).toMatchInlineSnapshot(` Object { "codeFrame": Object { "content": "> 1 | this @@ -1112,71 +1132,73 @@ describe('processRequest', () => { ], } `); - }); - - it('should symbolicate function name if available', async () => { - const response = await makeRequest('/symbolicate', { - rawBody: JSON.stringify({ - stack: [ - { - file: 'http://localhost:8081/mybundle.bundle?runModule=true', - lineNumber: 3, - column: 18, - }, - ], - }), }); - expect(response._getJSON()).toMatchObject({ - stack: [ - expect.objectContaining({ - methodName: '', + it('should symbolicate function name if available', async () => { + const response = await makeRequest('/symbolicate', { + rawBody: JSON.stringify({ + stack: [ + { + file: `http://localhost:8081/mybundle.bundle${queryDelimiter}runModule=true`, + lineNumber: 3, + column: 18, + }, + ], }), - ], - }); - }); - - it('should collapse frames as specified in customizeFrame', async () => { - // NOTE: See implementation of symbolicator.customizeFrame above. + }); - const response = await makeRequest('/symbolicate', { - rawBody: JSON.stringify({ + expect(response._getJSON()).toMatchObject({ stack: [ - { - file: 'http://localhost:8081/mybundle.bundle?runModule=true', - lineNumber: 3, - column: 18, - }, + expect.objectContaining({ + methodName: '', + }), ], - }), + }); }); - expect(response._getJSON()).toMatchObject({ - stack: [ - expect.objectContaining({ - file: '/root/foo.js', - collapse: true, + it('should collapse frames as specified in customizeFrame', async () => { + // NOTE: See implementation of symbolicator.customizeFrame above. + + const response = await makeRequest('/symbolicate', { + rawBody: JSON.stringify({ + stack: [ + { + file: `http://localhost:8081/mybundle.bundle${queryDelimiter}runModule=true`, + lineNumber: 3, + column: 18, + }, + ], }), - ], - }); - }); + }); - it('should leave original file and position when cannot symbolicate', async () => { - const response = await makeRequest('/symbolicate', { - rawBody: JSON.stringify({ + expect(response._getJSON()).toMatchObject({ stack: [ - { - file: 'http://localhost:8081/mybundle.bundle?runModule=true', - lineNumber: 200, - column: 18, - customPropShouldBeLeftUnchanged: 'foo', - methodName: 'clientSideMethodName', - }, + expect.objectContaining({ + file: '/root/foo.js', + collapse: true, + }), ], - }), + }); }); - expect(response._getJSON()).toMatchInlineSnapshot(` + // TODO: This probably should restore the *original* file before rewrite + // or normalisation. + it('should leave original file and position when cannot symbolicate (after normalisation and rewriting?)', async () => { + const response = await makeRequest('/symbolicate', { + rawBody: JSON.stringify({ + stack: [ + { + file: `http://localhost:8081/mybundle.bundle${queryDelimiter}runModule=true&foo__REMOVE_THIS_WHEN_REWRITING__=bar`, + lineNumber: 200, + column: 18, + customPropShouldBeLeftUnchanged: 'foo', + methodName: 'clientSideMethodName', + }, + ], + }), + }); + + expect(response._getJSON()).toMatchInlineSnapshot(` Object { "codeFrame": null, "stack": Array [ @@ -1184,15 +1206,16 @@ describe('processRequest', () => { "collapse": false, "column": 18, "customPropShouldBeLeftUnchanged": "foo", - "file": "http://localhost:8081/mybundle.bundle?runModule=true", + "file": "http://localhost:8081/mybundle.bundle?runModule=true&foo=bar&TEST_URL_WAS_REWRITTEN=true", "lineNumber": 200, "methodName": "clientSideMethodName", }, ], } `); - }); - }); + }); + }, + ); describe('/symbolicate handles errors', () => { it('should symbolicate given stack trace', async () => { diff --git a/packages/metro/src/lib/parseOptionsFromUrl.js b/packages/metro/src/lib/parseOptionsFromUrl.js index b0eafd8114..0e2d50e025 100644 --- a/packages/metro/src/lib/parseOptionsFromUrl.js +++ b/packages/metro/src/lib/parseOptionsFromUrl.js @@ -47,11 +47,11 @@ const getTransformProfile = (transformProfile: string): TransformProfile => : 'default'; module.exports = function parseOptionsFromUrl( - requestUrl: string, + normalizedRequestUrl: string, platforms: Set, bytecodeVersion: number, ): BundleOptions { - const parsedURL = nullthrows(url.parse(requestUrl, true)); // `true` to parse the query param as an object. + const parsedURL = nullthrows(url.parse(normalizedRequestUrl, true)); // `true` to parse the query param as an object. const query = nullthrows(parsedURL.query); const pathname = query.bundleEntry || @@ -92,7 +92,7 @@ module.exports = function parseOptionsFromUrl( platform != null && platform.match(/^(android|ios)$/) ? 'http' : '', pathname: pathname.replace(/\.(bundle|delta)$/, '.map'), }), - sourceUrl: requestUrl, + sourceUrl: normalizedRequestUrl, unstable_transformProfile: getTransformProfile( query.unstable_transformProfile, ), diff --git a/yarn.lock b/yarn.lock index cd6fe38960..46cd5b62ca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5126,6 +5126,11 @@ js-yaml@^4.1.0: dependencies: argparse "^2.0.1" +jsc-safe-url@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/jsc-safe-url/-/jsc-safe-url-0.2.2.tgz#9ce79116d6271fce4b3d1b59b879e66b82457be1" + integrity sha512-F6ezJ+Ys7yUaZ2tG7VVGwDgmCB8T1kaDB2AlxhLnPIfTpJqgFWSjptCAU04wz7RB3oEta/SiDuy4vQxh2F4jXg== + jsdom@^16.4.0: version "16.7.0" resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-16.7.0.tgz#918ae71965424b197c819f8183a754e18977b710"