diff --git a/docs/Configuration.md b/docs/Configuration.md index 368afcd4ee..317f266cfd 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -359,7 +359,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 df3f00a610..a51de2d791 100644 --- a/packages/metro/package.json +++ b/packages/metro/package.json @@ -36,6 +36,7 @@ "invariant": "^2.2.4", "jest-haste-map": "^27.3.1", "jest-worker": "^27.2.0", + "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", "metro-babel-transformer": "0.70.3", "metro-cache": "0.70.3", diff --git a/packages/metro/src/Server.js b/packages/metro/src/Server.js index 2c78b96ad1..d94b49e7ad 100644 --- a/packages/metro/src/Server.js +++ b/packages/metro/src/Server.js @@ -60,6 +60,8 @@ const symbolicate = require('./Server/symbolicate'); const {codeFrameColumns} = require('@babel/code-frame'); 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}, @@ -444,14 +446,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( @@ -1051,19 +1058,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 ( @@ -1078,8 +1100,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'); @@ -1106,20 +1131,16 @@ class Server { } } - async _explodedSourceMapForURL(reqUrl: string): Promise { - const options = parseOptionsFromUrl( - reqUrl, - new Set(this._config.resolver.platforms), - BYTECODE_VERSION, - ); - + async _explodedSourceMapForBundleOptions( + bundleOptions: BundleOptions, + ): Promise { const { entryFile, transformOptions, serializerOptions, graphOptions, onProgress, - } = 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 34d44c907f..da882a15e0 100644 --- a/packages/metro/src/Server/__tests__/Server-test.js +++ b/packages/metro/src/Server/__tests__/Server-test.js @@ -270,32 +270,35 @@ describe('processRequest', () => { ); }); - it('returns a bytecode bundle source on request of *.bundle?runtimeBytecodeVersion', async () => { - const response = await makeRequest( - `mybundle.bundle?runtimeBytecodeVersion=${BYTECODE_VERSION}&runModule=true`, - null, - ); + it.each(['?', '//&'])( + 'returns a bytecode bundle source on request of *.bundle?runtimeBytecodeVersion (delimiter: %s)', + async delimiter => { + const response = await makeRequest( + `mybundle.bundle${delimiter}runtimeBytecodeVersion=${BYTECODE_VERSION}&runModule=true`, + null, + ); - expect(response.getHeader('Content-Type')).toEqual( - 'application/x-metro-bytecode-bundle', - ); + expect(response.getHeader('Content-Type')).toEqual( + 'application/x-metro-bytecode-bundle', + ); - const buffer = response.body; - const numberOfModules = buffer.readUInt32LE(4); + const buffer = response.body; + const numberOfModules = buffer.readUInt32LE(4); - expect(buffer.readUInt32LE(0)).toEqual(MAGIC_NUMBER); - expect(numberOfModules).toEqual(8); + expect(buffer.readUInt32LE(0)).toEqual(MAGIC_NUMBER); + expect(numberOfModules).toEqual(8); - let offset = 8; - let modules = 0; - while (offset < buffer.length) { - expect(() => validateBytecodeModule(buffer, offset + 4)).not.toThrow(); - offset = align(offset + buffer.readUInt32LE(offset)) + 4; - modules++; - } + let offset = 8; + let modules = 0; + while (offset < buffer.length) { + expect(() => validateBytecodeModule(buffer, offset + 4)).not.toThrow(); + offset = align(offset + buffer.readUInt32LE(offset)) + 4; + modules++; + } - expect(modules).toEqual(numberOfModules); - }); + expect(modules).toEqual(numberOfModules); + }, + ); it('returns JS bundle without the initial require() call', async () => { const response = await makeRequest('mybundle.bundle?runModule=false', null); @@ -664,23 +667,26 @@ 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 => { + const response = await makeRequest( + `mybundle.bundle${delimiter}runModule=true__REMOVE_THIS_WHEN_REWRITING__`, + null, + ); - expect(response.body).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'), - ); - }); + expect(response.body).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. @@ -886,31 +892,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(JSON.parse(response.body)).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(JSON.parse(response.body)).toMatchInlineSnapshot(` Object { "codeFrame": Object { "content": "> 1 | this @@ -935,145 +943,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(JSON.parse(response.body)).toEqual( + JSON.parse( + ( + await makeRequest('/symbolicate', { + rawBody: JSON.stringify({ + stack: [ + { + file: `http://localhost:8081/mybundle.bundle${queryDelimiter}runModule=true`, + ...mappedLocation, + }, + ], + }), + }) + ).body, + ), + ); + }); + + 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(JSON.parse(response.body).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(JSON.parse(response.body)).toEqual( - JSON.parse( - ( - await makeRequest('/symbolicate', { - rawBody: JSON.stringify({ - stack: [ - { - file: 'http://localhost:8081/mybundle.bundle?runModule=true', - ...mappedLocation, - }, - ], - }), - }) - ).body, - ), + 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(JSON.parse(response.body).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(JSON.parse(response.body)).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(JSON.parse(response.body)).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(JSON.parse(response.body)).toMatchInlineSnapshot(` + expect(JSON.parse(response.body)).toMatchInlineSnapshot(` Object { "codeFrame": Object { "content": "> 1 | this @@ -1098,71 +1109,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(JSON.parse(response.body)).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(JSON.parse(response.body)).toMatchObject({ stack: [ - { - file: 'http://localhost:8081/mybundle.bundle?runModule=true', - lineNumber: 3, - column: 18, - }, + expect.objectContaining({ + methodName: '', + }), ], - }), + }); }); - expect(JSON.parse(response.body)).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(JSON.parse(response.body)).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(JSON.parse(response.body)).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(JSON.parse(response.body)).toMatchInlineSnapshot(` Object { "codeFrame": null, "stack": Array [ @@ -1170,15 +1183,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 2d9d223e02..ef86b6a58e 100644 --- a/packages/metro/src/lib/parseOptionsFromUrl.js +++ b/packages/metro/src/lib/parseOptionsFromUrl.js @@ -57,11 +57,11 @@ const getTransformProfile = ( : '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 || @@ -101,7 +101,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 12afbcd8f0..fc2ca067ce 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5043,6 +5043,11 @@ jsbn@~0.1.0: resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM= +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.4.0" resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-16.4.0.tgz#36005bde2d136f73eee1a830c6d45e55408edddb"