Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ describe.each(['HTTP', 'HTTPS'])(
}
});

describe.each(['10.0.2.2', '10.0.3.2'])(
describe.each(['10.0.2.2', '10.0.3.2', '127.0.0.1'])(
'%s aliasing to and from localhost',
sourceHost => {
test('in source map fetching during Debugger.scriptParsed', async () => {
Expand Down
35 changes: 23 additions & 12 deletions packages/dev-middleware/src/inspector-proxy/Device.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,21 @@ const debug = require('debug')('Metro:InspectorProxy');

const PAGES_POLLING_INTERVAL = 1000;

// Android's stock emulator and other emulators such as genymotion use a standard localhost alias.
const EMULATOR_LOCALHOST_ADDRESSES: Array<string> = ['10.0.2.2', '10.0.3.2'];
// Replace hosts appearing in the `url` and `sourceMapURL` fields of
// `Debugger.scriptParsed`, and back again in messages from the debugger,
// to account for device/debugger/proxy running on different networks.
const REWRITE_HOSTS_TO_LOCALHOST: Array<string> = [
// A device may retrieve a bundle through 127.0.0.1 via a (SSH) tunnel, but
// the (remote) Metro server may be on a host without an IPv4 loopback, so
// 127.0.0.1 may not be addressible locally for (e.g., for source map
// fetching). Replacing with the more general 'localhost' should always be
// safe while also more compatible with IPv6-only setups.
'127.0.0.1',
// Android's stock emulator and other emulators such as genymotion use a
// standard localhost alias.
'10.0.2.2',
'10.0.3.2',
];

// Prefix for script URLs that are alphanumeric IDs. See comment in #processMessageFromDeviceLegacy method for
// more details.
Expand Down Expand Up @@ -621,15 +634,14 @@ export default class Device {
) {
const params = payload.params;
if ('sourceMapURL' in params) {
for (let i = 0; i < EMULATOR_LOCALHOST_ADDRESSES.length; ++i) {
const address = EMULATOR_LOCALHOST_ADDRESSES[i];
if (params.sourceMapURL.includes(address)) {
for (const hostToRewrite of REWRITE_HOSTS_TO_LOCALHOST) {
if (params.sourceMapURL.includes(hostToRewrite)) {
// $FlowFixMe[cannot-write]
payload.params.sourceMapURL = params.sourceMapURL.replace(
address,
hostToRewrite,
'localhost',
);
debuggerInfo.originalSourceURLAddress = address;
debuggerInfo.originalSourceURLAddress = hostToRewrite;
}
}

Expand All @@ -654,12 +666,11 @@ export default class Device {
}
}
if ('url' in params) {
for (let i = 0; i < EMULATOR_LOCALHOST_ADDRESSES.length; ++i) {
const address = EMULATOR_LOCALHOST_ADDRESSES[i];
if (params.url.indexOf(address) >= 0) {
for (const hostToRewrite of REWRITE_HOSTS_TO_LOCALHOST) {
if (params.url.includes(hostToRewrite)) {
// $FlowFixMe[cannot-write]
payload.params.url = params.url.replace(address, 'localhost');
debuggerInfo.originalSourceURLAddress = address;
payload.params.url = params.url.replace(hostToRewrite, 'localhost');
debuggerInfo.originalSourceURLAddress = hostToRewrite;
}
}

Expand Down
50 changes: 50 additions & 0 deletions packages/dev-middleware/src/inspector-proxy/InspectorProxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,14 @@ import type {
PageDescription,
} from './types';
import type {IncomingMessage, ServerResponse} from 'http';
// $FlowFixMe[cannot-resolve-module] libdef missing in RN OSS
import type {Timeout} from 'timers';

import Device from './Device';
import nullthrows from 'nullthrows';
// Import these from node:timers to get the correct Flow types.
// $FlowFixMe[cannot-resolve-module] libdef missing in RN OSS
import {clearTimeout, setTimeout} from 'timers';
import url from 'url';
import WS from 'ws';

Expand All @@ -32,6 +37,8 @@ const WS_DEBUGGER_URL = '/inspector/debug';
const PAGES_LIST_JSON_URL = '/json';
const PAGES_LIST_JSON_URL_2 = '/json/list';
const PAGES_LIST_JSON_VERSION_URL = '/json/version';
const MAX_PONG_LATENCY_MS = 5000;
const DEBUGGER_HEARTBEAT_INTERVAL_MS = 10000;

const INTERNAL_ERROR_CODE = 1011;

Expand Down Expand Up @@ -264,6 +271,8 @@ export default class InspectorProxy implements InspectorProxyQueries {
throw new Error('Unknown device with ID ' + deviceId);
}

this.#startHeartbeat(socket, DEBUGGER_HEARTBEAT_INTERVAL_MS);

device.handleDebuggerConnection(socket, pageId, {
userAgent: req.headers['user-agent'] ?? query.userAgent ?? null,
});
Expand All @@ -279,4 +288,45 @@ export default class InspectorProxy implements InspectorProxyQueries {
});
return wss;
}

// Starts pinging the socket at the given interval. Compliant clients will
// respond with pong frame. This serves both to detect when the client
// has gone away without sending a close frame, and as a keepalive in cases
// where proxies may drop idle connections (e.g., VS Code tunnels).
//
// https://datatracker.ietf.org/doc/html/rfc6455#section-5.5.2
#startHeartbeat(socket: WS, intervalMs: number) {
let terminateTimeout = null;

const pingTimeout: Timeout = setTimeout(() => {
if (socket.readyState !== WS.OPEN) {
// May be connecting or closing, try again later.
pingTimeout.refresh();
return;
}
socket.ping();
terminateTimeout = setTimeout(() => {
if (socket.readyState !== WS.OPEN) {
return;
}
// We don't use close() here because that initiates a closing handshake,
// which will not complete if the other end has gone away - 'close'
// would not be emitted.
//
// terminate() emits 'close' immediately, allowing us to handle it and
// inform any clients.
socket.terminate();
}, MAX_PONG_LATENCY_MS).unref();
}, intervalMs).unref();

socket.on('pong', () => {
terminateTimeout && clearTimeout(terminateTimeout);
pingTimeout.refresh();
});

socket.on('close', () => {
terminateTimeout && clearTimeout(terminateTimeout);
clearTimeout(pingTimeout);
});
}
}