diff --git a/packages/dev-middleware/src/__tests__/getBaseUrlFromRequest-test.js b/packages/dev-middleware/src/__tests__/getBaseUrlFromRequest-test.js new file mode 100644 index 00000000000000..155f3489ab9aa9 --- /dev/null +++ b/packages/dev-middleware/src/__tests__/getBaseUrlFromRequest-test.js @@ -0,0 +1,49 @@ +/** + * 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 + */ + +import getBaseUrlFromRequest from '../utils/getBaseUrlFromRequest'; + +test('returns a base url based on req.headers.host', () => { + expect( + getBaseUrlFromRequest(makeRequest('localhost:8081', false))?.href, + ).toEqual('http://localhost:8081/'); +}); + +test('identifies https using socket.encrypted', () => { + expect( + getBaseUrlFromRequest(makeRequest('secure.net:8443', true))?.href, + ).toEqual('https://secure.net:8443/'); +}); + +test('works with ipv6 hosts', () => { + expect(getBaseUrlFromRequest(makeRequest('[::1]:8081', false))?.href).toEqual( + 'http://[::1]:8081/', + ); +}); + +test('returns null on an invalid host header', () => { + expect(getBaseUrlFromRequest(makeRequest('local[]host', false))).toBeNull(); +}); + +test('returns null on an empty host header', () => { + expect(getBaseUrlFromRequest(makeRequest(null, false))).toBeNull(); +}); + +function makeRequest( + host: ?string, + encrypted: boolean, +): http$IncomingMessage<> | http$IncomingMessage { + // $FlowIgnore[incompatible-return] Partial mock of request + return { + socket: encrypted ? {encrypted: true} : {}, + headers: host != null ? {host} : {}, + }; +} diff --git a/packages/dev-middleware/src/createDevMiddleware.js b/packages/dev-middleware/src/createDevMiddleware.js index cee44c37c7ec1a..ac66be3fbcacd9 100644 --- a/packages/dev-middleware/src/createDevMiddleware.js +++ b/packages/dev-middleware/src/createDevMiddleware.js @@ -28,11 +28,8 @@ type Options = $ReadOnly<{ projectRoot: string, /** - * The base URL to the dev server, as addressible from the local developer - * machine. This is used in responses which return URLs to other endpoints, - * e.g. the debugger frontend and inspector proxy targets. - * - * Example: `'http://localhost:8081'`. + * The base URL to the dev server, as reachable from the machine on which + * dev-middleware is hosted. Typically `http://localhost:${metroPort}`. */ serverBaseUrl: string, diff --git a/packages/dev-middleware/src/inspector-proxy/Device.js b/packages/dev-middleware/src/inspector-proxy/Device.js index 54fe5397fce4f5..190103030d012b 100644 --- a/packages/dev-middleware/src/inspector-proxy/Device.js +++ b/packages/dev-middleware/src/inspector-proxy/Device.js @@ -133,6 +133,7 @@ export default class Device { projectRoot: string, eventReporter: ?EventReporter, createMessageMiddleware: ?CreateCustomMessageHandlerFn, + serverBaseUrl?: URL, ) { this.#dangerouslyConstruct( id, diff --git a/packages/dev-middleware/src/inspector-proxy/InspectorProxy.js b/packages/dev-middleware/src/inspector-proxy/InspectorProxy.js index c24fb758312e10..b79c17635dfe84 100644 --- a/packages/dev-middleware/src/inspector-proxy/InspectorProxy.js +++ b/packages/dev-middleware/src/inspector-proxy/InspectorProxy.js @@ -22,6 +22,7 @@ import type {IncomingMessage, ServerResponse} from 'http'; // $FlowFixMe[cannot-resolve-module] libdef missing in RN OSS import type {Timeout} from 'timers'; +import getBaseUrlFromRequest from '../utils/getBaseUrlFromRequest'; import Device from './Device'; import nullthrows from 'nullthrows'; // Import these from node:timers to get the correct Flow types. @@ -47,7 +48,7 @@ export interface InspectorProxyQueries { * Returns list of page descriptions ordered by device connection order, then * page addition order. */ - getPageDescriptions(): Array; + getPageDescriptions(requestorRelativeBaseUrl: URL): Array; } /** @@ -57,8 +58,8 @@ export default class InspectorProxy implements InspectorProxyQueries { // Root of the project used for relative to absolute source path conversion. #projectRoot: string; - /** The base URL to the dev server from the developer machine. */ - #serverBaseUrl: string; + // The base URL to the dev server from the dev-middleware host. + #serverBaseUrl: URL; // Maps device ID to Device instance. #devices: Map; @@ -81,14 +82,14 @@ export default class InspectorProxy implements InspectorProxyQueries { customMessageHandler: ?CreateCustomMessageHandlerFn, ) { this.#projectRoot = projectRoot; - this.#serverBaseUrl = serverBaseUrl; + this.#serverBaseUrl = new URL(serverBaseUrl); this.#devices = new Map(); this.#eventReporter = eventReporter; this.#experiments = experiments; this.#customMessageHandler = customMessageHandler; } - getPageDescriptions(): Array { + getPageDescriptions(requestorRelativeBaseUrl: URL): Array { // Build list of pages from all devices. let result: Array = []; Array.from(this.#devices.entries()).forEach(([deviceId, device]) => { @@ -96,7 +97,12 @@ export default class InspectorProxy implements InspectorProxyQueries { device .getPagesList() .map((page: Page) => - this.#buildPageDescription(deviceId, device, page), + this.#buildPageDescription( + deviceId, + device, + page, + requestorRelativeBaseUrl, + ), ), ); }); @@ -117,7 +123,12 @@ export default class InspectorProxy implements InspectorProxyQueries { pathname === PAGES_LIST_JSON_URL || pathname === PAGES_LIST_JSON_URL_2 ) { - this.#sendJsonResponse(response, this.getPageDescriptions()); + this.#sendJsonResponse( + response, + this.getPageDescriptions( + getBaseUrlFromRequest(request) ?? this.#serverBaseUrl, + ), + ); } else if (pathname === PAGES_LIST_JSON_VERSION_URL) { this.#sendJsonResponse(response, { Browser: 'Mobile JavaScript', @@ -143,8 +154,9 @@ export default class InspectorProxy implements InspectorProxyQueries { deviceId: string, device: Device, page: Page, + requestorRelativeBaseUrl: URL, ): PageDescription { - const {host, protocol} = new URL(this.#serverBaseUrl); + const {host, protocol} = requestorRelativeBaseUrl; const webSocketScheme = protocol === 'https:' ? 'wss' : 'ws'; const webSocketUrlWithoutProtocol = `${host}${WS_DEBUGGER_URL}?device=${deviceId}&page=${page.id}`; diff --git a/packages/dev-middleware/src/middleware/openDebuggerMiddleware.js b/packages/dev-middleware/src/middleware/openDebuggerMiddleware.js index 8936e42119ef7b..8b48ef811ee684 100644 --- a/packages/dev-middleware/src/middleware/openDebuggerMiddleware.js +++ b/packages/dev-middleware/src/middleware/openDebuggerMiddleware.js @@ -72,12 +72,14 @@ export default function openDebuggerMiddleware({ ... } = query; - const targets = inspectorProxy.getPageDescriptions().filter( - // Only use targets with better reloading support - app => - app.title === LEGACY_SYNTHETIC_PAGE_TITLE || - app.reactNative.capabilities?.nativePageReloads === true, - ); + const targets = inspectorProxy + .getPageDescriptions(new URL(serverBaseUrl)) + .filter( + // Only use targets with better reloading support + app => + app.title === LEGACY_SYNTHETIC_PAGE_TITLE || + app.reactNative.capabilities?.nativePageReloads === true, + ); let target; diff --git a/packages/dev-middleware/src/types/BrowserLauncher.js b/packages/dev-middleware/src/types/BrowserLauncher.js index d76b31ec59655f..dd2bd89ef679ee 100644 --- a/packages/dev-middleware/src/types/BrowserLauncher.js +++ b/packages/dev-middleware/src/types/BrowserLauncher.js @@ -18,6 +18,10 @@ export interface BrowserLauncher { * Attempt to open a debugger frontend URL in a browser app window, * optionally returning an object to control the launched browser instance. * The browser used should be capable of running Chrome DevTools. + * + * The provided url is based on serverBaseUrl, and therefore reachable from + * the host of dev-middleware. Implementations are responsible for rewriting + * this as necessary where the server is remote. */ launchDebuggerAppWindow: (url: string) => Promise; } diff --git a/packages/dev-middleware/src/utils/getBaseUrlFromRequest.js b/packages/dev-middleware/src/utils/getBaseUrlFromRequest.js new file mode 100644 index 00000000000000..a43bb7c3a767e1 --- /dev/null +++ b/packages/dev-middleware/src/utils/getBaseUrlFromRequest.js @@ -0,0 +1,28 @@ +/** + * 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 + */ + +// Determine the base URL (scheme and host) used by a client to reach this +// server. +// +// TODO: Support X-Forwarded-Host, etc. for trusted proxies +export default function getBaseUrlFromRequest( + req: http$IncomingMessage | http$IncomingMessage, +): ?URL { + const hostHeader = req.headers.host; + if (hostHeader == null) { + return null; + } + // `encrypted` is always true for TLS sockets and undefined for net + // https://github.com/nodejs/node/issues/41863#issuecomment-1030709186 + const scheme = req.socket.encrypted === true ? 'https' : 'http'; + const url = `${scheme}://${req.headers.host}`; + return URL.canParse(url) ? new URL(url) : null; +} diff --git a/packages/dev-middleware/src/utils/getDevToolsFrontendUrl.js b/packages/dev-middleware/src/utils/getDevToolsFrontendUrl.js index ab61784f765939..14649ed14a9bc2 100644 --- a/packages/dev-middleware/src/utils/getDevToolsFrontendUrl.js +++ b/packages/dev-middleware/src/utils/getDevToolsFrontendUrl.js @@ -65,7 +65,11 @@ function getWsParam({ const serverHost = new URL(devServerUrl).host; let value; if (wsUrl.host === serverHost) { - // Use a path-absolute (host-relative) URL + // Use a path-absolute (host-relative) URL if the WS server and frontend + // server are colocated. This is more robust for cases where the frontend + // may actually load through a tunnel or proxy, and the WS connection + // should therefore do the same. + // // Depends on https://github.com/facebookexperimental/rn-chrome-devtools-frontend/pull/4 value = wsUrl.pathname + wsUrl.search + wsUrl.hash; } else {