diff --git a/packages/metro/src/DeltaBundler/Serializers/__tests__/baseJSBundle-test.js b/packages/metro/src/DeltaBundler/Serializers/__tests__/baseJSBundle-test.js index 8692551378..373b931045 100644 --- a/packages/metro/src/DeltaBundler/Serializers/__tests__/baseJSBundle-test.js +++ b/packages/metro/src/DeltaBundler/Serializers/__tests__/baseJSBundle-test.js @@ -79,6 +79,23 @@ const barModule: Module<> = { getSource: () => Buffer.from('bar-source'), }; +const nonAsciiModule: Module<> = { + path: '/root/%30.бундл.Øಚ😁AA', + dependencies: new Map(), + inverseDependencies: new CountingSet(), + output: [ + { + type: 'js/module', + data: { + code: '__d(function() {/* code for ascii file with non ascii characters: %30.бундл.Øಚ😁AA */});', + map: [], + lineCount: 1, + }, + }, + ], + getSource: () => Buffer.from('bar-source'), +}; + const getRunModuleStatement = (moduleId: number | string) => `require(${JSON.stringify(moduleId)});`; @@ -144,6 +161,52 @@ test('should generate a very simple bundle', () => { `); }); +test('should generate a bundle with correct ascii characters parsing', () => { + expect( + baseJSBundle( + '/root/', + [polyfill], + { + dependencies: new Map([['/root/%30.бундл.Øಚ😁AA', nonAsciiModule]]), + entryPoints: new Set(['/root/%30.бундл.Øಚ😁AA']), + transformOptions, + }, + { + asyncRequireModulePath: '', + // $FlowFixMe[incompatible-call] createModuleId assumes numeric IDs - is this too strict? + createModuleId: filePath => path.basename(filePath), + dev: true, + getRunModuleStatement, + includeAsyncPaths: false, + inlineSourceMap: false, + modulesOnly: false, + processModuleFilter: () => true, + projectRoot: '/root', + runBeforeMainModule: [], + runModule: true, + serverRoot: '/root', + shouldAddToIgnoreList: () => false, + // expecting to receive these as already encoded URIs + sourceMapUrl: encodeURI('http://localhost/bundle.%30.бундл.Øಚ😁AA.map'), + sourceUrl: encodeURI('http://localhost/bundle.%30.бундл.Øಚ😁AA.js'), + getSourceUrl: null, + }, + ), + ).toMatchInlineSnapshot(` + Object { + "modules": Array [ + Array [ + "%30.бундл.Øಚ😁AA", + "__d(function() {/* code for ascii file with non ascii characters: %30.бундл.Øಚ😁AA */},\\"%30.бундл.Øಚ😁AA\\",[],\\"%30.бундл.Øಚ😁AA\\");", + ], + ], + "post": "//# sourceMappingURL=http://localhost/bundle.%2530.%D0%B1%D1%83%D0%BD%D0%B4%D0%BB.%C3%98%E0%B2%9A%F0%9F%98%81AA.map + //# sourceURL=http://localhost/bundle.%2530.%D0%B1%D1%83%D0%BD%D0%B4%D0%BB.%C3%98%E0%B2%9A%F0%9F%98%81AA.js", + "pre": "__d(function() {/* code for polyfill */});", + } + `); +}); + test('should add runBeforeMainModule statements if found in the graph', () => { expect( baseJSBundle( diff --git a/packages/metro/src/DeltaBundler/Serializers/__tests__/hmrJSBundle-test.js b/packages/metro/src/DeltaBundler/Serializers/__tests__/hmrJSBundle-test.js new file mode 100644 index 0000000000..50ded8e3e4 --- /dev/null +++ b/packages/metro/src/DeltaBundler/Serializers/__tests__/hmrJSBundle-test.js @@ -0,0 +1,186 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +'use strict'; + +import type {Module, ReadOnlyGraph, TransformInputOptions} from '../../types'; + +import CountingSet from '../../../lib/CountingSet'; + +const hmrJSBundle = require('../hmrJSBundle'); + +const fooModule: Module<> = { + path: '/root/foo', + dependencies: new Map([ + [ + './bar', + { + absolutePath: '/root/bar', + data: { + data: {asyncType: null, isESMImport: false, locs: [], key: './bar'}, + name: './bar', + }, + }, + ], + ]), + inverseDependencies: new CountingSet(), + output: [ + { + type: 'js/module', + data: { + code: '__d(function() {/* code for foo */});', + map: [], + lineCount: 1, + }, + }, + ], + getSource: () => Buffer.from('foo-source'), +}; + +const barModule: Module<> = { + path: '/root/bar', + dependencies: new Map(), + inverseDependencies: new CountingSet(['/root/foo']), + output: [ + { + type: 'js/module', + data: { + code: '__d(function() {/* code for bar */});', + map: [], + lineCount: 1, + }, + }, + ], + getSource: () => Buffer.from('bar-source'), +}; + +const nonAsciiModule: Module<> = { + path: '/root/%30.бундл.Øಚ😁AA', + dependencies: new Map(), + inverseDependencies: new CountingSet(), + output: [ + { + type: 'js/module', + data: { + code: '__d(function() {/* code for ascii file with non ascii characters: %30.бундл.Øಚ😁AA */});', + map: [], + lineCount: 1, + }, + }, + ], + getSource: () => Buffer.from('bar-source'), +}; + +const transformOptions: TransformInputOptions = { + customTransformOptions: {}, + dev: true, + hot: true, + minify: true, + platform: 'web', + type: 'module', + unstable_transformProfile: 'default', +}; + +const graph: ReadOnlyGraph<> = { + entryPoints: new Set(['root/foo']), + dependencies: new Map([ + ['root/foo', fooModule], + ['root/bar', barModule], + ]), + transformOptions, +}; + +const options = { + clientUrl: new URL('http://localhost/root/foo/bundle.js'), + createModuleId: (s: string) => + s.includes('foo') ? (s.includes('bar') ? 2 : 1) : 0, + includeAsyncPaths: false, + projectRoot: '/root/bar', + serverRoot: '/root/bar', +}; + +test('should generate a simple hot reload bundle from a change', () => { + expect( + hmrJSBundle( + { + added: new Map([['root/foo', fooModule]]), + modified: new Map([['root/bar', barModule]]), + deleted: new Set(), + reset: false, + }, + graph, + options, + ), + ).toMatchInlineSnapshot(` + Object { + "added": Array [ + Object { + "module": Array [ + 1, + "__d(function() {/* code for foo */},1,[0],\\"../foo\\",{}); + //# sourceMappingURL=http://localhost/foo.map + //# sourceURL=http://localhost/foo.bundle + ", + ], + "sourceMappingURL": "http://localhost/foo.map", + "sourceURL": "http://localhost/foo.bundle", + }, + ], + "deleted": Array [], + "modified": Array [ + Object { + "module": Array [ + 0, + "__d(function() {/* code for bar */},0,[],\\"\\",{}); + //# sourceMappingURL=http://localhost/bar.map + //# sourceURL=http://localhost/bar.bundle + ", + ], + "sourceMappingURL": "http://localhost/bar.map", + "sourceURL": "http://localhost/bar.bundle", + }, + ], + } + `); +}); + +test('should turn non ascii filesystem characters into proper encoded urls for source url and source map url', () => { + expect( + hmrJSBundle( + { + added: new Map(), + modified: new Map([['root/%30.бундл.Øಚ😁AA', nonAsciiModule]]), + deleted: new Set(), + reset: false, + }, + graph, + options, + ), + ).toMatchInlineSnapshot(` + Object { + "added": Array [], + "deleted": Array [], + "modified": Array [ + Object { + "module": Array [ + 0, + "__d(function() {/* code for ascii file with non ascii characters: %30.бундл.Øಚ😁AA */},0,[],\\"../%30.бундл.Øಚ😁AA\\",{}); + //# sourceMappingURL=http://localhost/%2530.%D0%B1%D1%83%D0%BD%D0%B4%D0%BB.map + //# sourceURL=http://localhost/%2530.%D0%B1%D1%83%D0%BD%D0%B4%D0%BB.bundle + ", + ], + "sourceMappingURL": "http://localhost/%2530.%D0%B1%D1%83%D0%BD%D0%B4%D0%BB.map", + "sourceURL": "http://localhost/%2530.%D0%B1%D1%83%D0%BD%D0%B4%D0%BB.bundle", + }, + ], + } + `); +}); diff --git a/packages/metro/src/DeltaBundler/Serializers/hmrJSBundle.js b/packages/metro/src/DeltaBundler/Serializers/hmrJSBundle.js index 90bbd18490..0f72027f71 100644 --- a/packages/metro/src/DeltaBundler/Serializers/hmrJSBundle.js +++ b/packages/metro/src/DeltaBundler/Serializers/hmrJSBundle.js @@ -11,7 +11,6 @@ 'use strict'; -import type {EntryPointURL} from '../../HmrServer'; import type {DeltaResult, Module, ReadOnlyGraph} from '../types'; import type {HmrModule} from 'metro-runtime/src/modules/types'; @@ -19,10 +18,9 @@ const {isJsModule, wrapModule} = require('./helpers/js'); const jscSafeUrl = require('jsc-safe-url'); const {addParamsToDefineCall} = require('metro-transform-plugins'); const path = require('path'); -const url = require('url'); type Options = $ReadOnly<{ - clientUrl: EntryPointURL, + clientUrl: URL, createModuleId: string => number, includeAsyncPaths: boolean, projectRoot: string, @@ -39,28 +37,30 @@ function generateModules( for (const module of sourceModules) { if (isJsModule(module)) { - // Construct a bundle URL for this specific module only - const getURL = (extension: 'bundle' | 'map') => { - const moduleUrl = url.parse(url.format(options.clientUrl), true); - // the legacy url object is parsed with both "search" and "query" fields. - // for the "query" field to be used when formatting the object bach to string, the "search" field must be empty. - // https://nodejs.org/api/url.html#urlformaturlobject:~:text=If%20the%20urlObject.search%20property%20is%20undefined - moduleUrl.search = ''; - moduleUrl.pathname = path.relative( - options.serverRoot ?? options.projectRoot, - path.join( - path.dirname(module.path), - path.basename(module.path, path.extname(module.path)) + - '.' + - extension, + const getPathname = (extension: 'bundle' | 'map') => { + // encoding a file path as a URL path so it could be dencoded back to a file path upon receiving + return encodeURI( + path.relative( + options.serverRoot ?? options.projectRoot, + path.join( + path.dirname(module.path), + path.basename(module.path, path.extname(module.path)) + + '.' + + extension, + ), ), ); - delete moduleUrl.query.excludeSource; - return url.format(moduleUrl); }; - const sourceMappingURL = getURL('map'); - const sourceURL = jscSafeUrl.toJscSafeUrl(getURL('bundle')); + const clientUrl = new URL(options.clientUrl); + clientUrl.searchParams.delete('excludeSource'); + + clientUrl.pathname = getPathname('map'); + const sourceMappingURL = clientUrl.toString(); + + clientUrl.pathname = getPathname('bundle'); + const sourceURL = jscSafeUrl.toJscSafeUrl(clientUrl.toString()); + const code = prepareModule(module, graph, options) + `\n//# sourceMappingURL=${sourceMappingURL}\n` + @@ -84,7 +84,7 @@ function prepareModule( ): string { const code = wrapModule(module, { ...options, - sourceUrl: url.format(options.clientUrl), + sourceUrl: options.clientUrl.toString(), dev: true, }); diff --git a/packages/metro/src/HmrServer.js b/packages/metro/src/HmrServer.js index 526aa2aca9..c56c335be9 100644 --- a/packages/metro/src/HmrServer.js +++ b/packages/metro/src/HmrServer.js @@ -19,7 +19,6 @@ import type { HmrMessage, HmrUpdateMessage, } from 'metro-runtime/src/modules/types'; -import type {UrlWithParsedQuery} from 'url'; const hmrJSBundle = require('./DeltaBundler/Serializers/hmrJSBundle'); const GraphNotFoundError = require('./IncrementalBundler/GraphNotFoundError'); @@ -27,16 +26,13 @@ const RevisionNotFoundError = require('./IncrementalBundler/RevisionNotFoundErro const debounceAsyncQueue = require('./lib/debounceAsyncQueue'); const formatBundlingError = require('./lib/formatBundlingError'); const getGraphId = require('./lib/getGraphId'); -const parseOptionsFromUrl = require('./lib/parseOptionsFromUrl'); +const parseBundleOptionsFromBundleRequestUrl = require('./lib/parseBundleOptionsFromBundleRequestUrl'); const splitBundleOptions = require('./lib/splitBundleOptions'); const transformHelpers = require('./lib/transformHelpers'); const { Logger: {createActionStartEntry, createActionEndEntry, log}, } = require('metro-core'); const nullthrows = require('nullthrows'); -const url = require('url'); - -export type EntryPointURL = UrlWithParsedQuery; export type Client = { optedIntoHMR: boolean, @@ -46,7 +42,7 @@ export type Client = { type ClientGroup = { +clients: Set, - clientUrl: EntryPointURL, + clientUrl: URL, revisionId: RevisionId, +unlisten: () => void, +graphOptions: GraphOptions, @@ -100,11 +96,12 @@ class HmrServer { sendFn: (data: string) => void, ): Promise { requestUrl = this._config.server.rewriteRequestUrl(requestUrl); - const clientUrl = nullthrows(url.parse(requestUrl, true)); - const {bundleType: _bundleType, ...options} = parseOptionsFromUrl( - requestUrl, - new Set(this._config.resolver.platforms), - ); + const clientUrl = new URL(requestUrl); + const {bundleType: _bundleType, ...options} = + parseBundleOptionsFromBundleRequestUrl( + requestUrl, + new Set(this._config.resolver.platforms), + ); const {entryFile, resolverOptions, transformOptions, graphOptions} = splitBundleOptions(options); @@ -155,29 +152,18 @@ class HmrServer { } else { // Prepare the clientUrl to be used as sourceUrl in HMR updates. clientUrl.protocol = 'http'; - const { - dev, - minify, - runModule, - bundleEntry: _bundleEntry, - ...query - } = clientUrl.query || {}; - clientUrl.query = { - ...query, - dev: dev || 'true', - minify: minify || 'false', - modulesOnly: 'true', - runModule: runModule || 'false', - shallow: 'true', - }; - // the legacy url object is parsed with both "search" and "query" fields. - // for the "query" field to be used when formatting the object bach to string, the "search" field must be empty. - // https://nodejs.org/api/url.html#urlformaturlobject:~:text=If%20the%20urlObject.search%20property%20is%20undefined - clientUrl.search = ''; + + const clientQuery = clientUrl.searchParams; + clientQuery.delete('bundleEntry'); + clientQuery.set('dev', clientQuery.get('dev') || 'true'); + clientQuery.set('minify', clientQuery.get('minify') || 'false'); + clientQuery.set('modulesOnly', 'true'); + clientQuery.set('runModule', clientQuery.get('runModule') || 'false'); + clientQuery.set('shallow', 'true'); clientGroup = { clients: new Set([client]), - clientUrl, + clientUrl: new URL(clientUrl), revisionId: id, graphOptions, unlisten: (): void => unlisten(), @@ -369,7 +355,7 @@ class HmrServer { logger?.point('serialize_start'); const hmrUpdate = hmrJSBundle(delta, revision.graph, { - clientUrl: group.clientUrl, + clientUrl: new URL(group.clientUrl), createModuleId: this._createModuleId, includeAsyncPaths: group.graphOptions.lazy, projectRoot: this._config.projectRoot, diff --git a/packages/metro/src/Server.js b/packages/metro/src/Server.js index 1b0ad1b4e0..f995bd1ff8 100644 --- a/packages/metro/src/Server.js +++ b/packages/metro/src/Server.js @@ -62,8 +62,8 @@ const ResourceNotFoundError = require('./IncrementalBundler/ResourceNotFoundErro const bundleToString = require('./lib/bundleToString'); const formatBundlingError = require('./lib/formatBundlingError'); const getGraphId = require('./lib/getGraphId'); +const parseBundleOptionsFromBundleRequestUrl = require('./lib/parseBundleOptionsFromBundleRequestUrl'); const parseJsonBody = require('./lib/parseJsonBody'); -const parseOptionsFromUrl = require('./lib/parseOptionsFromUrl'); const splitBundleOptions = require('./lib/splitBundleOptions'); const transformHelpers = require('./lib/transformHelpers'); const { @@ -86,7 +86,6 @@ const nullthrows = require('nullthrows'); const path = require('path'); const {performance} = require('perf_hooks'); const querystring = require('querystring'); -const url = require('url'); const noopLogger: RootPerfLogger = { start: () => {}, @@ -510,20 +509,29 @@ class Server { req: IncomingMessage, res: ServerResponse, ): Promise { - const urlObj = url.parse(decodeURI(req.url), true); + debug('Processing single asset request: %s', req.url); + const urlObj = new URL( + req.url, + 'mock-protocol://mock-host' /* URL expects a protocol and a host to be present to parse a URL */, + ); + const formattedUrl = urlObj.toString(); + if (req.url !== formattedUrl) { + debug('Formatted as: %s', formattedUrl); + } let [, assetPath] = - (urlObj && - urlObj.pathname && - urlObj.pathname.match(/^\/assets\/(.+)$/)) || - []; - - if (!assetPath && urlObj && urlObj.query && urlObj.query.unstable_path) { + decodeURI(urlObj.pathname).match(/^\/assets\/(.+)$/) || []; + if (!assetPath && urlObj.searchParams.get('unstable_path')) { const [, actualPath, secondaryQuery] = nullthrows( - urlObj.query.unstable_path.match(/^([^?]*)\??(.*)$/), + (urlObj.searchParams.get('unstable_path') || '').match( + /^([^?]*)\??(.*)$/, + ), ); if (secondaryQuery) { - // $FlowFixMe[unsafe-object-assign] - Object.assign(urlObj.query, querystring.parse(secondaryQuery)); + Object.entries(querystring.parse(secondaryQuery)).forEach( + ([key, value]) => { + urlObj.searchParams.set(key, value); + }, + ); } assetPath = actualPath; } @@ -544,7 +552,7 @@ class Server { assetPath, this._config.projectRoot, this._config.watchFolders, - urlObj.query.platform, + urlObj.searchParams.get('platform'), this._config.resolver.assetExts, ); // Tell clients to cache this for 1 year. @@ -578,10 +586,11 @@ class Server { }; _parseOptions(url: string): BundleOptions { - const {bundleType: _bundleType, ...bundleOptions} = parseOptionsFromUrl( - url, - new Set(this._config.resolver.platforms), - ); + const {bundleType: _bundleType, ...bundleOptions} = + parseBundleOptionsFromBundleRequestUrl( + url, + new Set(this._config.resolver.platforms), + ); return bundleOptions; } @@ -597,19 +606,21 @@ class Server { next: (?Error) => void, ) { const originalUrl = req.url; + debug('Handling request: %s', originalUrl); req.url = this._rewriteAndNormalizeUrl(req.url); - const urlObj = url.parse(decodeURI(req.url), true); + if (req.url !== originalUrl) { + debug('Rewritten to: %s', req.url); + } const {host} = req.headers; - debug( - `Handling request: ${host ? 'http://' + host : ''}${req.url}` + - (originalUrl !== req.url ? ` (rewritten from ${originalUrl})` : ''), - ); - const formattedUrl = url.format({ - ...urlObj, - host, - protocol: 'http', - }); + const urlObj = new URL(req.url, 'http://' + host); + const formattedUrl = urlObj.toString(); + if (req.url !== formattedUrl) { + debug('Formatted as: %s', formattedUrl); + } + const pathname = urlObj.pathname || ''; + const decodedPathname = decodeURI(urlObj.pathname || ''); + const buildNumber = this.getNewBuildNumber(); if (pathname.endsWith('.bundle')) { const options = this._parseOptions(formattedUrl); @@ -622,7 +633,7 @@ class Server { }); if (this._serverOptions && this._serverOptions.onBundleBuilt) { - this._serverOptions.onBundleBuilt(pathname); + this._serverOptions.onBundleBuilt(decodedPathname); } } else if (pathname.endsWith('.map')) { // Chrome dev tools may need to access the source maps. @@ -654,8 +665,10 @@ class Server { let handled = false; for (const [pathnamePrefix, normalizedRootDir] of this ._sourceRequestRoutingMap) { - if (pathname.startsWith(pathnamePrefix)) { - const relativePathname = pathname.substr(pathnamePrefix.length); + if (decodedPathname.startsWith(pathnamePrefix)) { + const relativePathname = decodedPathname.substr( + pathnamePrefix.length, + ); await this._processSourceRequest( relativePathname, normalizedRootDir, @@ -672,13 +685,13 @@ class Server { } async _processSourceRequest( - relativePathname: string, + relativeFilePathname: string, rootDir: string, res: ServerResponse, ): Promise { if ( !this._allowedSuffixesForSourceRequests.some(suffix => - relativePathname.endsWith(suffix), + relativeFilePathname.endsWith(suffix), ) ) { res.writeHead(404); @@ -686,7 +699,7 @@ class Server { return; } const depGraph = await this._bundler.getBundler().getDependencyGraph(); - const filePath = path.join(rootDir, relativePathname); + const filePath = path.join(rootDir, relativeFilePathname); try { await depGraph.getOrComputeSha1(filePath); } catch { @@ -694,7 +707,7 @@ class Server { res.end(); return; } - const mimeType = mime.lookup(path.basename(relativePathname)); + const mimeType = mime.lookup(path.basename(relativeFilePathname)); res.setHeader('Content-Type', mimeType); const stream = fs.createReadStream(filePath); stream.pipe(res); diff --git a/packages/metro/src/index.flow.js b/packages/metro/src/index.flow.js index 027e964db2..0d5a9a0a8f 100644 --- a/packages/metro/src/index.flow.js +++ b/packages/metro/src/index.flow.js @@ -52,7 +52,6 @@ const { const {Terminal} = require('metro-core'); const net = require('net'); const nullthrows = require('nullthrows'); -const {parse} = require('url'); type MetroMiddleWare = { attachHmrServer: (httpServer: HttpServer | HttpsServer) => void, @@ -237,7 +236,7 @@ const createConnectMiddleware = async function ( ), }); httpServer.on('upgrade', (request, socket, head) => { - const {pathname} = parse(request.url); + const {pathname} = new URL(request.url, 'resolve://'); if (pathname === '/hot') { wss.handleUpgrade(request, socket, head, ws => { wss.emit('connection', ws, request); @@ -351,7 +350,7 @@ exports.runServer = async ( }; httpServer.on('upgrade', (request, socket, head) => { - const {pathname} = parse(request.url); + const {pathname} = new URL(request.url, 'resolve://'); if (pathname != null && websocketEndpoints[pathname]) { websocketEndpoints[pathname].handleUpgrade( request, diff --git a/packages/metro/src/lib/__tests__/parseOptionsFromUrl-test.js b/packages/metro/src/lib/__tests__/parseBundleOptionsFromBundleRequestUrl-test.js similarity index 51% rename from packages/metro/src/lib/__tests__/parseOptionsFromUrl-test.js rename to packages/metro/src/lib/__tests__/parseBundleOptionsFromBundleRequestUrl-test.js index ea6c8f6ebd..38f39d3b50 100644 --- a/packages/metro/src/lib/__tests__/parseOptionsFromUrl-test.js +++ b/packages/metro/src/lib/__tests__/parseBundleOptionsFromBundleRequestUrl-test.js @@ -11,18 +11,21 @@ 'use strict'; -const parseOptionsFromUrl = require('../parseOptionsFromUrl'); +const parseBundleOptionsFromBundleRequestUrl = require('../parseBundleOptionsFromBundleRequestUrl'); -describe('parseOptionsFromUrl', () => { +describe('parseBundleOptionsFromBundleRequestUrl', () => { test.each([['map'], ['bundle']])('detects %s requests', type => { expect( - parseOptionsFromUrl(`http://localhost/my/bundle.${type}`, new Set([])), + parseBundleOptionsFromBundleRequestUrl( + `http://localhost/my/bundle.${type}`, + new Set([]), + ), ).toMatchObject({bundleType: type}); }); test('retrieves the platform from the query parameters', () => { expect( - parseOptionsFromUrl( + parseBundleOptionsFromBundleRequestUrl( 'http://localhost/my/bundle.bundle?platform=ios', new Set([]), ), @@ -31,22 +34,32 @@ describe('parseOptionsFromUrl', () => { test('retrieves the platform from the pathname', () => { expect( - parseOptionsFromUrl( + parseBundleOptionsFromBundleRequestUrl( 'http://localhost/my/bundle.test.bundle', new Set(['test']), ), ).toMatchObject({platform: 'test'}); }); - test('infers the source map url from the pathname', () => { - expect( - parseOptionsFromUrl('http://localhost/my/bundle.bundle', new Set([])), - ).toMatchObject({sourceMapUrl: '//localhost/my/bundle.map'}); - }); + test.each(['absolute', 'relative'])( + '%s urls- infers the source url and source map url from the pathname', + type => { + const protocol = type === 'absolute' ? 'http:' : ''; + expect( + parseBundleOptionsFromBundleRequestUrl( + `${protocol}//localhost/my/bundle.bundle`, + new Set([]), + ), + ).toMatchObject({ + sourceMapUrl: '//localhost/my/bundle.map', + sourceUrl: `${protocol}//localhost/my/bundle.bundle`, + }); + }, + ); test('forces the HTTP protocol for iOS and Android platforms', () => { expect( - parseOptionsFromUrl( + parseBundleOptionsFromBundleRequestUrl( 'http://localhost/my/bundle.bundle?platform=ios', new Set(['ios']), ), @@ -55,7 +68,7 @@ describe('parseOptionsFromUrl', () => { }); expect( - parseOptionsFromUrl( + parseBundleOptionsFromBundleRequestUrl( 'http://localhost/my/bundle.bundle?platform=android', new Set(['android']), ), @@ -64,18 +77,41 @@ describe('parseOptionsFromUrl', () => { }); }); - test('always sets the `hot` option to `true`', () => { - expect( - parseOptionsFromUrl('http://localhost/my/bundle.bundle', new Set([])), - ).toMatchObject({hot: true}); - }); - test('retrieves stuff from HMR urls', () => { - expect(parseOptionsFromUrl('my/bundle.bundle', new Set([]))).toMatchObject({ + expect( + parseBundleOptionsFromBundleRequestUrl('my/bundle.bundle', new Set([])), + ).toMatchObject({ entryFile: './my/bundle', }); }); + test.each(['absolute', 'relative'])( + '%s urls with ascii characters are encoded correctly', + type => { + const protocol = type === 'absolute' ? 'http:' : ''; + expect( + parseBundleOptionsFromBundleRequestUrl( + `${protocol}//localhost/my/%2530/%D0%B1%D1%83%D0%BD%D0%B4%D0%BB.%C3%98%E0%B2%9A%F0%9F%98%81AA.bundle`, + new Set([]), + ), + ).toMatchObject({ + sourceMapUrl: + '//localhost/my/%2530/%D0%B1%D1%83%D0%BD%D0%B4%D0%BB.%C3%98%E0%B2%9A%F0%9F%98%81AA.map', + sourceUrl: `${protocol}//localhost/my/%2530/%D0%B1%D1%83%D0%BD%D0%B4%D0%BB.%C3%98%E0%B2%9A%F0%9F%98%81AA.bundle`, + entryFile: './my/%30/бундл.Øಚ😁AA', + }); + }, + ); + + test('always sets the `hot` option to `true`', () => { + expect( + parseBundleOptionsFromBundleRequestUrl( + 'http://localhost/my/bundle.bundle', + new Set([]), + ), + ).toMatchObject({hot: true}); + }); + describe.each([ ['dev', true], ['minify', false], @@ -85,20 +121,23 @@ describe('parseOptionsFromUrl', () => { ])('boolean option `%s`', (optionName, defaultValue) => { test(`defaults to \`${String(defaultValue)}\``, () => { expect( - parseOptionsFromUrl('http://localhost/my/bundle.bundle', new Set([])), + parseBundleOptionsFromBundleRequestUrl( + 'http://localhost/my/bundle.bundle', + new Set([]), + ), ).toMatchObject({[optionName]: defaultValue}); }); test('is retrieved from the url', () => { expect( - parseOptionsFromUrl( + parseBundleOptionsFromBundleRequestUrl( `http://localhost/my/bundle.bundle?${optionName}=true`, new Set([]), ), ).toMatchObject({[optionName]: true}); expect( - parseOptionsFromUrl( + parseBundleOptionsFromBundleRequestUrl( `http://localhost/my/bundle.bundle?${optionName}=false`, new Set([]), ), diff --git a/packages/metro/src/lib/__tests__/parseCustomResolverOptions-test.js b/packages/metro/src/lib/__tests__/parseCustomResolverOptions-test.js index b0b65ae827..7849ea6ffa 100644 --- a/packages/metro/src/lib/__tests__/parseCustomResolverOptions-test.js +++ b/packages/metro/src/lib/__tests__/parseCustomResolverOptions-test.js @@ -12,13 +12,12 @@ 'use strict'; const parseCustomResolverOptions = require('../parseCustomResolverOptions'); -const url = require('url'); test('should parse some custom options from a http url', () => { const myUrl = 'http://localhost/my/bundle.bundle?dev=true&resolver.foo=value&resolver.bar=other'; - expect(parseCustomResolverOptions(url.parse(myUrl, true))).toEqual({ + expect(parseCustomResolverOptions(new URL(myUrl).searchParams)).toEqual({ foo: 'value', bar: 'other', }); @@ -27,7 +26,7 @@ test('should parse some custom options from a http url', () => { test('should parse some custom options from a websocket url', () => { const myUrl = 'ws://localhost/hot?resolver.foo=value&resolver.bar=other'; - expect(parseCustomResolverOptions(url.parse(myUrl, true))).toEqual({ + expect(parseCustomResolverOptions(new URL(myUrl).searchParams)).toEqual({ foo: 'value', bar: 'other', }); @@ -36,5 +35,5 @@ test('should parse some custom options from a websocket url', () => { test('should return an empty object if there are no custom params', () => { const myUrl = 'http://localhost/my/bundle.bundle?dev=true'; - expect(parseCustomResolverOptions(url.parse(myUrl, true))).toEqual({}); + expect(parseCustomResolverOptions(new URL(myUrl).searchParams)).toEqual({}); }); diff --git a/packages/metro/src/lib/__tests__/parseCustomTransformOptions-test.js b/packages/metro/src/lib/__tests__/parseCustomTransformOptions-test.js index 056ee9850c..9b766a3d0f 100644 --- a/packages/metro/src/lib/__tests__/parseCustomTransformOptions-test.js +++ b/packages/metro/src/lib/__tests__/parseCustomTransformOptions-test.js @@ -12,13 +12,12 @@ 'use strict'; const parseCustomTransformOptions = require('../parseCustomTransformOptions'); -const url = require('url'); test('should parse some custom options from a http url', () => { const myUrl = 'http://localhost/my/bundle.bundle?dev=true&transform.foo=value&transform.bar=other'; - expect(parseCustomTransformOptions(url.parse(myUrl, true))).toEqual({ + expect(parseCustomTransformOptions(new URL(myUrl).searchParams)).toEqual({ foo: 'value', bar: 'other', }); @@ -27,7 +26,7 @@ test('should parse some custom options from a http url', () => { test('should parse some custom options from a websocket url', () => { const myUrl = 'ws://localhost/hot?transform.foo=value&transform.bar=other'; - expect(parseCustomTransformOptions(url.parse(myUrl, true))).toEqual({ + expect(parseCustomTransformOptions(new URL(myUrl).searchParams)).toEqual({ foo: 'value', bar: 'other', }); @@ -36,5 +35,5 @@ test('should parse some custom options from a websocket url', () => { test('should return an empty object if there are no custom params', () => { const myUrl = 'http://localhost/my/bundle.bundle?dev=true'; - expect(parseCustomTransformOptions(url.parse(myUrl, true))).toEqual({}); + expect(parseCustomTransformOptions(new URL(myUrl).searchParams)).toEqual({}); }); diff --git a/packages/metro/src/lib/parseBundleOptionsFromBundleRequestUrl.js b/packages/metro/src/lib/parseBundleOptionsFromBundleRequestUrl.js new file mode 100644 index 0000000000..c08c5baaad --- /dev/null +++ b/packages/metro/src/lib/parseBundleOptionsFromBundleRequestUrl.js @@ -0,0 +1,144 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +'use strict'; + +import type {BundleOptions} from '../shared/types'; +import type {TransformProfile} from 'metro-babel-transformer'; + +import {SourcePathsMode} from '../shared/types'; + +const parsePlatformFilePath = require('../node-haste/lib/parsePlatformFilePath'); +const parseCustomResolverOptions = require('./parseCustomResolverOptions'); +const parseCustomTransformOptions = require('./parseCustomTransformOptions'); +const debug = require('debug')('Metro:Server'); +const jscSafeUrl = require('jsc-safe-url'); +const path = require('path'); + +const TRUE_STRINGS = new Set(['true', '1']); + +// This is a bit weird but this is the recommended way of getting around "URL" demanding to have a valid protocol +// for when handling relative URLs: https://nodejs.org/docs/latest-v24.x/api/url.html#urlresolvefrom-to +const RESOLVE_BASE_URL = 'resolve://'; + +const getBoolQueryParam = ( + searchParams: URLSearchParams, + opt: string, + defaultValue: boolean, +) => + searchParams.has(opt) + ? TRUE_STRINGS.has(searchParams.get(opt) || '') + : defaultValue; + +const getBundleType = (bundleType: string): 'map' | 'bundle' => + bundleType === 'map' ? bundleType : 'bundle'; + +const getTransformProfile = (transformProfile: ?string): TransformProfile => + transformProfile === 'hermes-stable' || transformProfile === 'hermes-canary' + ? transformProfile + : 'default'; + +module.exports = function parseBundleOptionsFromBundleRequestUrl( + rawNonJscSafeUrlEncodedUrl: string, + platforms: Set, +): { + ...BundleOptions, + // Retained for backwards compatibility, unused in Metro, to be removed. + bundleType: string, +} { + const { + protocol: _tempProtocol, + host, + searchParams, + pathname: requestPathname, + search, + hash, + } = new URL(rawNonJscSafeUrlEncodedUrl, RESOLVE_BASE_URL /* baseURL */); + + const isRelativeProtocol = rawNonJscSafeUrlEncodedUrl.startsWith('//'); + const isNoProtocol = + !isRelativeProtocol && _tempProtocol + '//' === RESOLVE_BASE_URL; + + // TODO: next diff (D79809398) will remove the support for "isNoProtocol" to make the requested URL more expected (either "//" or "http://") + const protocol = isNoProtocol // e.g. "./foo/bar.js" or "foo/bar.js" both converted to paths relative to root + ? '' + : isRelativeProtocol // e.g. "//localhost:8081/foo/bar.js?platform=ios" + ? '//' + : _tempProtocol + '//'; // e.g. "http://localhost:8081/foo/bar.js?platform=ios" + + const sourceUrl = jscSafeUrl.toJscSafeUrl( + protocol + host + requestPathname + search + hash, + ); + + const pathname = searchParams.get('bundleEntry') || requestPathname || ''; + + const platform = + searchParams.get('platform') || + parsePlatformFilePath(pathname, platforms).platform; + + const bundleType = getBundleType(path.extname(pathname).substr(1)); + + // The Chrome Debugger loads bundles via Blob urls, whose + // protocol is blob:http. This breaks loading source maps through + // protocol-relative URLs, which is why we must force the HTTP protocol + // when loading the bundle for either Android or iOS. + // TODO(T167298674): Remove when remote debugging is not needed in React Native + const sourceMapUrlProtocol = + platform != null && platform.match(/^(android|ios|vr|windows|macos)$/) + ? 'http://' + : '//'; + const {pathname: sourceMapPathname} = new URL( + pathname.replace(/\.(bundle|delta)$/, '.map'), + RESOLVE_BASE_URL /* baseURL */, + ); + const sourceMapUrl = + sourceMapUrlProtocol + host + sourceMapPathname + search + hash; + + // decoding URL into a file path + const entryFile = decodeURI(pathname) + .replace(/^(?:\.?\/)?/, './') + .replace(/\.[^/.]+$/, ''); + + debug( + 'Bundle options parsed from rawNonJscSafeUrlEncodedUrl: %s:\nsourceUrl: %s\nsourceMapUrl: %s\nentryFile: %s', + rawNonJscSafeUrlEncodedUrl, + sourceUrl, + sourceMapUrl, + entryFile, + ); + + return { + bundleType, + customResolverOptions: parseCustomResolverOptions(searchParams), + customTransformOptions: parseCustomTransformOptions(searchParams), + dev: getBoolQueryParam(searchParams, 'dev', true), + // absolute and relative paths are converted to paths relative to root + entryFile, + excludeSource: getBoolQueryParam(searchParams, 'excludeSource', false), + hot: true, + inlineSourceMap: getBoolQueryParam(searchParams, 'inlineSourceMap', false), + lazy: getBoolQueryParam(searchParams, 'lazy', false), + minify: getBoolQueryParam(searchParams, 'minify', false), + modulesOnly: getBoolQueryParam(searchParams, 'modulesOnly', false), + onProgress: null, + platform, + runModule: getBoolQueryParam(searchParams, 'runModule', true), + shallow: getBoolQueryParam(searchParams, 'shallow', false), + sourceMapUrl, + sourcePaths: + SourcePathsMode.cast(searchParams.get('sourcePaths')) ?? + SourcePathsMode.Absolute, + sourceUrl, + unstable_transformProfile: getTransformProfile( + searchParams.get('unstable_transformProfile'), + ), + }; +}; diff --git a/packages/metro/src/lib/parseCustomResolverOptions.js b/packages/metro/src/lib/parseCustomResolverOptions.js index 1012c48692..956581f8ec 100644 --- a/packages/metro/src/lib/parseCustomResolverOptions.js +++ b/packages/metro/src/lib/parseCustomResolverOptions.js @@ -13,24 +13,20 @@ import type {CustomResolverOptions} from '../../../metro-resolver/src/types'; -const nullthrows = require('nullthrows'); - const PREFIX = 'resolver.'; -module.exports = function parseCustomResolverOptions(urlObj: { - +query?: {[string]: string, ...}, - ... -}): CustomResolverOptions { +module.exports = function parseCustomResolverOptions( + searchParams: URLSearchParams, +): CustomResolverOptions { const customResolverOptions: { __proto__: null, [string]: mixed, ... } = Object.create(null); - const query = nullthrows(urlObj.query); - Object.keys(query).forEach((key: string) => { + searchParams.forEach((value: string, key: string) => { if (key.startsWith(PREFIX)) { - customResolverOptions[key.substr(PREFIX.length)] = query[key]; + customResolverOptions[key.substr(PREFIX.length)] = value; } }); diff --git a/packages/metro/src/lib/parseCustomTransformOptions.js b/packages/metro/src/lib/parseCustomTransformOptions.js index b327ac2190..45ae78c743 100644 --- a/packages/metro/src/lib/parseCustomTransformOptions.js +++ b/packages/metro/src/lib/parseCustomTransformOptions.js @@ -13,21 +13,20 @@ import type {CustomTransformOptions} from 'metro-transform-worker'; -const nullthrows = require('nullthrows'); - const PREFIX = 'transform.'; -module.exports = function parseCustomTransformOptions(urlObj: { - +query?: {[string]: string, ...}, - ... -}): CustomTransformOptions { - const customTransformOptions = Object.create(null); - const query = nullthrows(urlObj.query); +module.exports = function parseCustomTransformOptions( + searchParams: URLSearchParams, +): CustomTransformOptions { + const customTransformOptions: { + __proto__: null, + [string]: mixed, + ... + } = Object.create(null); - Object.keys(query).forEach((key: string) => { + searchParams.forEach((value: string, key: string) => { if (key.startsWith(PREFIX)) { - // $FlowFixMe[prop-missing] - customTransformOptions[key.substr(PREFIX.length)] = query[key]; + customTransformOptions[key.substr(PREFIX.length)] = value; } }); diff --git a/packages/metro/src/lib/parseOptionsFromUrl.js b/packages/metro/src/lib/parseOptionsFromUrl.js deleted file mode 100644 index 22e2ac19d6..0000000000 --- a/packages/metro/src/lib/parseOptionsFromUrl.js +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow strict-local - * @format - * @oncall react_native - */ - -'use strict'; - -import type {BundleOptions} from '../shared/types'; -import type {TransformProfile} from 'metro-babel-transformer'; - -import {SourcePathsMode} from '../shared/types'; - -const parsePlatformFilePath = require('../node-haste/lib/parsePlatformFilePath'); -const parseCustomResolverOptions = require('./parseCustomResolverOptions'); -const parseCustomTransformOptions = require('./parseCustomTransformOptions'); -const jscSafeUrl = require('jsc-safe-url'); -const nullthrows = require('nullthrows'); -const path = require('path'); -const url = require('url'); - -const getBoolean = ( - query: $ReadOnly<{[opt: string]: string}>, - opt: string, - defaultValue: boolean, -) => - query[opt] == null - ? defaultValue - : query[opt] === 'true' || query[opt] === '1'; - -const getBundleType = (bundleType: string): 'map' | 'bundle' => - bundleType === 'map' ? bundleType : 'bundle'; - -const getTransformProfile = (transformProfile: string): TransformProfile => - transformProfile === 'hermes-stable' || transformProfile === 'hermes-canary' - ? transformProfile - : 'default'; - -module.exports = function parseOptionsFromUrl( - normalizedRequestUrl: string, - platforms: Set, -): { - ...BundleOptions, - // Retained for backwards compatibility, unused in Metro, to be removed. - bundleType: string, -} { - 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 || - (parsedURL.pathname != null ? decodeURIComponent(parsedURL.pathname) : ''); - const platform = - query.platform || parsePlatformFilePath(pathname, platforms).platform; - const bundleType = getBundleType(path.extname(pathname).substr(1)); - - return { - bundleType, - customResolverOptions: parseCustomResolverOptions(parsedURL), - customTransformOptions: parseCustomTransformOptions(parsedURL), - dev: getBoolean(query, 'dev', true), - entryFile: pathname.replace(/^(?:\.?\/)?/, './').replace(/\.[^/.]+$/, ''), - excludeSource: getBoolean(query, 'excludeSource', false), - hot: true, - inlineSourceMap: getBoolean(query, 'inlineSourceMap', false), - lazy: getBoolean(query, 'lazy', false), - minify: getBoolean(query, 'minify', false), - modulesOnly: getBoolean(query, 'modulesOnly', false), - onProgress: null, - platform, - runModule: getBoolean(query, 'runModule', true), - shallow: getBoolean(query, 'shallow', false), - sourceMapUrl: url.format({ - ...parsedURL, - // The Chrome Debugger loads bundles via Blob urls, whose - // protocol is blob:http. This breaks loading source maps through - // protocol-relative URLs, which is why we must force the HTTP protocol - // when loading the bundle for either Android or iOS. - // TODO(T167298674): Remove when remote debugging is not needed in React Native - protocol: - platform != null && platform.match(/^(android|ios|vr|windows|macos)$/) - ? 'http' - : '', - pathname: pathname.replace(/\.(bundle|delta)$/, '.map'), - }), - sourcePaths: - SourcePathsMode.cast(query.sourcePaths) ?? SourcePathsMode.Absolute, - sourceUrl: jscSafeUrl.toJscSafeUrl(normalizedRequestUrl), - unstable_transformProfile: getTransformProfile( - query.unstable_transformProfile, - ), - }; -}; diff --git a/scripts/eslint/base.js b/scripts/eslint/base.js index eee3f394a4..a7646d4223 100644 --- a/scripts/eslint/base.js +++ b/scripts/eslint/base.js @@ -42,6 +42,23 @@ module.exports = { quotes: 'off', 'sort-keys': 'off', + 'no-restricted-imports': [ + 'error', + { + name: 'url', + message: + 'Deprecated. Please use URL instead. https://nodejs.org/docs/latest/api/url.html#legacy-url-api', + }, + ], + 'no-restricted-modules': [ + 'error', + { + name: 'url', + message: + 'Deprecated. Please use URL instead. https://nodejs.org/docs/latest/api/url.html#legacy-url-api', + }, + ], + // TODO: This was added after migrating from `eslint-plugin-prettier` to // `eslint-config-prettier`. The former used to disable this rule, so this // was added to avoid introducing lint errors during the migration. Either