mirror of
https://github.com/facebook/react-native.git
synced 2025-11-01 09:14:26 +00:00
Summary: This change removes Content-Length header from proxy inspector response. The presence of this header was resulting in the response being cropped under some circumstances because of erroneously calculated length. The `Content-Length` header value represents the number of bytes in the response. In the code, `string.length` was used to calculate that value, but in JavaScript it gives the number of characters in a string instead of its size in bytes. Specifically, if there are some UTF characters in the string that occupy more than byte, there would be a mismatch in this size. This mismatch resulted in the response being cropped. The easiest way to reproduce this problem is to set the simulator name to contain a two-byte UTF character. This change works according to the HTTP spec, which states that when Content-Length is not present, the end of the response stream indicates the end of the response. Since in the code `response.end(data)` is use, it terminates the stream and hence there is no need to provide the length in the header. ## Changelog: [GENERAL] [FIXED] - fix issue with debugger not working when device name contain two-byte UTF characters Pull Request resolved: https://github.com/facebook/react-native/pull/42590 Test Plan: 1. Change your iOS simulator name to contain some two-byte UTF character (for example this one: "–") 2. Run metro and connect your app with it 3. Go to http://localhost:8081/json/list in your browser – see the response being marked invalid as it is cropped 4. Apply the change and see that the resulting JSON in the response is now correct 5. Open debugger workflow to confirm it sees the connected device Reviewed By: robhogan Differential Revision: D52958725 Pulled By: motiz88 fbshipit-source-id: 92c32893cbbf8552237585d824e4a44737fa3968
275 lines
8.5 KiB
JavaScript
275 lines
8.5 KiB
JavaScript
/**
|
|
* 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 type {EventReporter} from '../types/EventReporter';
|
|
import type {Experiments} from '../types/Experiments';
|
|
import type {
|
|
JsonPagesListResponse,
|
|
JsonVersionResponse,
|
|
Page,
|
|
PageDescription,
|
|
} from './types';
|
|
import type {IncomingMessage, ServerResponse} from 'http';
|
|
|
|
import Device from './Device';
|
|
import nullthrows from 'nullthrows';
|
|
import url from 'url';
|
|
import WS from 'ws';
|
|
|
|
const debug = require('debug')('Metro:InspectorProxy');
|
|
|
|
const WS_DEVICE_URL = '/inspector/device';
|
|
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 INTERNAL_ERROR_CODE = 1011;
|
|
|
|
export interface InspectorProxyQueries {
|
|
getPageDescriptions(): Array<PageDescription>;
|
|
}
|
|
|
|
/**
|
|
* Main Inspector Proxy class that connects JavaScript VM inside Android/iOS apps and JS debugger.
|
|
*/
|
|
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;
|
|
|
|
// Maps device ID to Device instance.
|
|
#devices: Map<string, Device>;
|
|
|
|
// Internal counter for device IDs -- just gets incremented for each new device.
|
|
#deviceCounter: number = 0;
|
|
|
|
#eventReporter: ?EventReporter;
|
|
|
|
#experiments: Experiments;
|
|
|
|
constructor(
|
|
projectRoot: string,
|
|
serverBaseUrl: string,
|
|
eventReporter: ?EventReporter,
|
|
experiments: Experiments,
|
|
) {
|
|
this.#projectRoot = projectRoot;
|
|
this.#serverBaseUrl = serverBaseUrl;
|
|
this.#devices = new Map();
|
|
this.#eventReporter = eventReporter;
|
|
this.#experiments = experiments;
|
|
}
|
|
|
|
getPageDescriptions(): Array<PageDescription> {
|
|
// Build list of pages from all devices.
|
|
let result: Array<PageDescription> = [];
|
|
Array.from(this.#devices.entries()).forEach(([deviceId, device]) => {
|
|
result = result.concat(
|
|
device
|
|
.getPagesList()
|
|
.map((page: Page) =>
|
|
this.#buildPageDescription(deviceId, device, page),
|
|
),
|
|
);
|
|
});
|
|
return result;
|
|
}
|
|
|
|
// Process HTTP request sent to server. We only respond to 2 HTTP requests:
|
|
// 1. /json/version returns Chrome debugger protocol version that we use
|
|
// 2. /json and /json/list returns list of page descriptions (list of inspectable apps).
|
|
// This list is combined from all the connected devices.
|
|
processRequest(
|
|
request: IncomingMessage,
|
|
response: ServerResponse,
|
|
next: (?Error) => mixed,
|
|
) {
|
|
const pathname = url.parse(request.url).pathname;
|
|
if (
|
|
pathname === PAGES_LIST_JSON_URL ||
|
|
pathname === PAGES_LIST_JSON_URL_2
|
|
) {
|
|
this.#sendJsonResponse(response, this.getPageDescriptions());
|
|
} else if (pathname === PAGES_LIST_JSON_VERSION_URL) {
|
|
this.#sendJsonResponse(response, {
|
|
Browser: 'Mobile JavaScript',
|
|
'Protocol-Version': '1.1',
|
|
});
|
|
} else {
|
|
next();
|
|
}
|
|
}
|
|
|
|
createWebSocketListeners(): {
|
|
[path: string]: WS.Server,
|
|
} {
|
|
return {
|
|
[WS_DEVICE_URL]: this.#createDeviceConnectionWSServer(),
|
|
[WS_DEBUGGER_URL]: this.#createDebuggerConnectionWSServer(),
|
|
};
|
|
}
|
|
|
|
// Converts page information received from device into PageDescription object
|
|
// that is sent to debugger.
|
|
#buildPageDescription(
|
|
deviceId: string,
|
|
device: Device,
|
|
page: Page,
|
|
): PageDescription {
|
|
const {host, protocol} = new URL(this.#serverBaseUrl);
|
|
const webSocketScheme = protocol === 'https:' ? 'wss' : 'ws';
|
|
|
|
const webSocketUrlWithoutProtocol = `${host}${WS_DEBUGGER_URL}?device=${deviceId}&page=${page.id}`;
|
|
const webSocketDebuggerUrl = `${webSocketScheme}://${webSocketUrlWithoutProtocol}`;
|
|
|
|
// For now, `/json/list` returns the legacy built-in `devtools://` URL, to
|
|
// preserve existing handling by Flipper. This may return a placeholder in
|
|
// future -- please use the `/open-debugger` endpoint.
|
|
const devtoolsFrontendUrl =
|
|
`devtools://devtools/bundled/js_app.html?experiments=true&v8only=true&${webSocketScheme}=` +
|
|
encodeURIComponent(webSocketUrlWithoutProtocol);
|
|
|
|
return {
|
|
id: `${deviceId}-${page.id}`,
|
|
description: page.app,
|
|
title: page.title,
|
|
faviconUrl: 'https://reactjs.org/favicon.ico',
|
|
devtoolsFrontendUrl,
|
|
type: 'node',
|
|
webSocketDebuggerUrl,
|
|
vm: page.vm,
|
|
deviceName: device.getName(),
|
|
reactNative: {
|
|
logicalDeviceId: deviceId,
|
|
type: nullthrows(page.type),
|
|
},
|
|
};
|
|
}
|
|
|
|
// Sends object as response to HTTP request.
|
|
// Just serializes object using JSON and sets required headers.
|
|
#sendJsonResponse(
|
|
response: ServerResponse,
|
|
object: JsonPagesListResponse | JsonVersionResponse,
|
|
) {
|
|
const data = JSON.stringify(object, null, 2);
|
|
response.writeHead(200, {
|
|
'Content-Type': 'application/json; charset=UTF-8',
|
|
'Cache-Control': 'no-cache',
|
|
Connection: 'close',
|
|
});
|
|
response.end(data);
|
|
}
|
|
|
|
// Adds websocket handler for device connections.
|
|
// Device connects to /inspector/device and passes device and app names as
|
|
// HTTP GET params.
|
|
// For each new websocket connection we parse device and app names and create
|
|
// new instance of Device class.
|
|
#createDeviceConnectionWSServer(): ws$WebSocketServer {
|
|
const wss = new WS.Server({
|
|
noServer: true,
|
|
perMessageDeflate: true,
|
|
// Don't crash on exceptionally large messages - assume the device is
|
|
// well-behaved and the debugger is prepared to handle large messages.
|
|
maxPayload: 0,
|
|
});
|
|
// $FlowFixMe[value-as-type]
|
|
wss.on('connection', async (socket: WS, req) => {
|
|
try {
|
|
const fallbackDeviceId = String(this.#deviceCounter++);
|
|
|
|
const query = url.parse(req.url || '', true).query || {};
|
|
const deviceId = query.device || fallbackDeviceId;
|
|
const deviceName = query.name || 'Unknown';
|
|
const appName = query.app || 'Unknown';
|
|
|
|
const oldDevice = this.#devices.get(deviceId);
|
|
const newDevice = new Device(
|
|
deviceId,
|
|
deviceName,
|
|
appName,
|
|
socket,
|
|
this.#projectRoot,
|
|
this.#eventReporter,
|
|
);
|
|
|
|
if (oldDevice) {
|
|
oldDevice.handleDuplicateDeviceConnection(newDevice);
|
|
}
|
|
|
|
this.#devices.set(deviceId, newDevice);
|
|
|
|
debug(
|
|
`Got new connection: name=${deviceName}, app=${appName}, device=${deviceId}`,
|
|
);
|
|
|
|
socket.on('close', () => {
|
|
this.#devices.delete(deviceId);
|
|
debug(`Device ${deviceName} disconnected.`);
|
|
});
|
|
} catch (e) {
|
|
console.error('error', e);
|
|
socket.close(INTERNAL_ERROR_CODE, e?.toString() ?? 'Unknown error');
|
|
}
|
|
});
|
|
return wss;
|
|
}
|
|
|
|
// Returns websocket handler for debugger connections.
|
|
// Debugger connects to webSocketDebuggerUrl that we return as part of page description
|
|
// in /json response.
|
|
// When debugger connects we try to parse device and page IDs from the query and pass
|
|
// websocket object to corresponding Device instance.
|
|
#createDebuggerConnectionWSServer(): ws$WebSocketServer {
|
|
const wss = new WS.Server({
|
|
noServer: true,
|
|
perMessageDeflate: false,
|
|
// Don't crash on exceptionally large messages - assume the debugger is
|
|
// well-behaved and the device is prepared to handle large messages.
|
|
maxPayload: 0,
|
|
});
|
|
// $FlowFixMe[value-as-type]
|
|
wss.on('connection', async (socket: WS, req) => {
|
|
try {
|
|
const query = url.parse(req.url || '', true).query || {};
|
|
const deviceId = query.device;
|
|
const pageId = query.page;
|
|
|
|
if (deviceId == null || pageId == null) {
|
|
throw new Error('Incorrect URL - must provide device and page IDs');
|
|
}
|
|
|
|
const device = this.#devices.get(deviceId);
|
|
if (device == null) {
|
|
throw new Error('Unknown device with ID ' + deviceId);
|
|
}
|
|
|
|
device.handleDebuggerConnection(socket, pageId, {
|
|
userAgent: req.headers['user-agent'] ?? null,
|
|
});
|
|
} catch (e) {
|
|
console.error(e);
|
|
socket.close(INTERNAL_ERROR_CODE, e?.toString() ?? 'Unknown error');
|
|
this.#eventReporter?.logEvent({
|
|
type: 'connect_debugger_frontend',
|
|
status: 'error',
|
|
error: e,
|
|
});
|
|
}
|
|
});
|
|
return wss;
|
|
}
|
|
}
|