mirror of
https://github.com/status-im/react-native.git
synced 2025-01-14 11:34:23 +00:00
Add packager inspector proxy
Summary: Implements a multi-device proxy for the Chrome debugging protocol. Each device connects to the proxy over a single websocket connection that is able to multiplex messages to multiple Javascript VMs. An inspector instance running in Chrome can connect to a specific VM via this proxy. Reviewed By: davidaurelio Differential Revision: D4088492 fbshipit-source-id: 3ee934e98604b5a378da732e687ca05fe3d23ce0
This commit is contained in:
parent
c94a71e5bd
commit
655fe2796a
19
local-cli/server/middleware/unless.js
Normal file
19
local-cli/server/middleware/unless.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2015-present, Facebook, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the BSD-style license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree. An additional grant
|
||||||
|
* of patent rights can be found in the PATENTS file in the same directory.
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = (url, middleware) => {
|
||||||
|
return (req, res, next) => {
|
||||||
|
if (req.url === url || req.url.startsWith(url + '/')) {
|
||||||
|
middleware(req, res, next);
|
||||||
|
} else {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
@ -26,12 +26,15 @@ const path = require('path');
|
|||||||
const statusPageMiddleware = require('./middleware/statusPageMiddleware.js');
|
const statusPageMiddleware = require('./middleware/statusPageMiddleware.js');
|
||||||
const systraceProfileMiddleware = require('./middleware/systraceProfileMiddleware.js');
|
const systraceProfileMiddleware = require('./middleware/systraceProfileMiddleware.js');
|
||||||
const webSocketProxy = require('./util/webSocketProxy.js');
|
const webSocketProxy = require('./util/webSocketProxy.js');
|
||||||
|
const InspectorProxy = require('./util/inspectorProxy.js');
|
||||||
const defaultAssetExts = require('../../packager/defaults').assetExts;
|
const defaultAssetExts = require('../../packager/defaults').assetExts;
|
||||||
|
const unless = require('./middleware/unless');
|
||||||
|
|
||||||
function runServer(args, config, readyCallback) {
|
function runServer(args, config, readyCallback) {
|
||||||
var wsProxy = null;
|
var wsProxy = null;
|
||||||
var ms = null;
|
var ms = null;
|
||||||
const packagerServer = getPackagerServer(args, config);
|
const packagerServer = getPackagerServer(args, config);
|
||||||
|
const inspectorProxy = new InspectorProxy();
|
||||||
const app = connect()
|
const app = connect()
|
||||||
.use(loadRawBodyMiddleware)
|
.use(loadRawBodyMiddleware)
|
||||||
.use(connect.compress())
|
.use(connect.compress())
|
||||||
@ -45,6 +48,7 @@ function runServer(args, config, readyCallback) {
|
|||||||
.use(cpuProfilerMiddleware)
|
.use(cpuProfilerMiddleware)
|
||||||
.use(jscProfilerMiddleware)
|
.use(jscProfilerMiddleware)
|
||||||
.use(indexPageMiddleware)
|
.use(indexPageMiddleware)
|
||||||
|
.use(unless('/inspector', inspectorProxy.processRequest.bind(inspectorProxy)))
|
||||||
.use(packagerServer.processRequest.bind(packagerServer));
|
.use(packagerServer.processRequest.bind(packagerServer));
|
||||||
|
|
||||||
args.projectRoots.forEach(root => app.use(connect.static(root)));
|
args.projectRoots.forEach(root => app.use(connect.static(root)));
|
||||||
@ -65,6 +69,7 @@ function runServer(args, config, readyCallback) {
|
|||||||
wsProxy = webSocketProxy.attachToServer(serverInstance, '/debugger-proxy');
|
wsProxy = webSocketProxy.attachToServer(serverInstance, '/debugger-proxy');
|
||||||
ms = messageSocket.attachToServer(serverInstance, '/message');
|
ms = messageSocket.attachToServer(serverInstance, '/message');
|
||||||
webSocketProxy.attachToServer(serverInstance, '/devtools');
|
webSocketProxy.attachToServer(serverInstance, '/devtools');
|
||||||
|
inspectorProxy.attachToServer(serverInstance, '/inspector');
|
||||||
readyCallback();
|
readyCallback();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
417
local-cli/server/util/inspectorProxy.js
Normal file
417
local-cli/server/util/inspectorProxy.js
Normal file
@ -0,0 +1,417 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2015-present, Facebook, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the BSD-style license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree. An additional grant
|
||||||
|
* of patent rights can be found in the PATENTS file in the same directory.
|
||||||
|
*
|
||||||
|
* @flow
|
||||||
|
*
|
||||||
|
* This file implements a multi-device proxy for the Chrome debugging protocol. Each device connects
|
||||||
|
* to the proxy over a single websocket connection that is able to multiplex messages to multiple
|
||||||
|
* Javascript VMs. An inspector instance running in Chrome can connect to a specific VM via this
|
||||||
|
* proxy.
|
||||||
|
*
|
||||||
|
* The connection to from device to the proxy uses a simple JSON-based protocol with the basic
|
||||||
|
* structure { event: '<event-name>', payload: { ... }}. All events are send without acknowledgment
|
||||||
|
* except the 'getPages' event where the device responds with the available pages. Most events will
|
||||||
|
* be wrapped inspector events going to a specific 'page' on the device.
|
||||||
|
*
|
||||||
|
* See below for a diagram of how the devices, inspector(s) and proxy interact.
|
||||||
|
* +--------+
|
||||||
|
* | Device | +------------+
|
||||||
|
* | #1 | +---------+ |Chrome |
|
||||||
|
* | | | | Chrome debugging +------------+
|
||||||
|
* +--+--+ | Custom | | protocol |Inspector |
|
||||||
|
* |VM|VM| |------+------->| Proxy |<----------+------| |
|
||||||
|
* +--+--+--+ | | | | | |
|
||||||
|
* | | | | +------------+
|
||||||
|
* +--------+ | +---------+ | |Inspector |
|
||||||
|
* | Device |------+ +------| |
|
||||||
|
* | #2 | | |
|
||||||
|
* | | +------------+
|
||||||
|
* +--+ |
|
||||||
|
* |VM| |
|
||||||
|
* +--+-----+
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const flatMapArray = require('fbjs/lib/flatMapArray');
|
||||||
|
const http = require('http');
|
||||||
|
const nullthrows = require('fbjs/lib/nullthrows');
|
||||||
|
const querystring = require('querystring');
|
||||||
|
|
||||||
|
const parseUrl = require('url').parse;
|
||||||
|
const WebSocket = require('ws');
|
||||||
|
|
||||||
|
const debug = require('debug')('ReactNativePackager:InspectorProxy');
|
||||||
|
|
||||||
|
type DevicePage = {
|
||||||
|
id: string,
|
||||||
|
title: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
type Page = {
|
||||||
|
id: string,
|
||||||
|
title: string,
|
||||||
|
description: string,
|
||||||
|
devtoolsFrontendUrl: string,
|
||||||
|
webSocketDebuggerUrl: string,
|
||||||
|
deviceId: string,
|
||||||
|
deviceName: string,
|
||||||
|
};
|
||||||
|
|
||||||
|
type Message<Name, Payload> = {
|
||||||
|
event?: Name,
|
||||||
|
payload?: Payload,
|
||||||
|
};
|
||||||
|
|
||||||
|
type WrappedEvent = Message<'wrappedEvent', {
|
||||||
|
pageId?: string,
|
||||||
|
wrappedEvent?: string,
|
||||||
|
}>;
|
||||||
|
|
||||||
|
type ConnectEvent = Message<'connect', {
|
||||||
|
pageId?: string,
|
||||||
|
}>;
|
||||||
|
|
||||||
|
type DisconnectEvent = Message<'disconnect', {
|
||||||
|
pageId?: string,
|
||||||
|
}>;
|
||||||
|
|
||||||
|
type GetPages = Message<'getPages', ?Array<DevicePage>>;
|
||||||
|
|
||||||
|
type Event = WrappedEvent | ConnectEvent | DisconnectEvent | GetPages;
|
||||||
|
|
||||||
|
type Address = {
|
||||||
|
address: string,
|
||||||
|
port: number,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEVICE_TIMEOUT = 5000;
|
||||||
|
|
||||||
|
// FIXME: This is the url we want to use as it more closely matches the actual protocol we use.
|
||||||
|
// However, it's broken in Chrome 54+ due to it using 'KeyboardEvent.keyIdentifier'.
|
||||||
|
// const DEVTOOLS_URL_BASE = 'https://chrome-devtools-frontend.appspot.com/serve_rev/@178469/devtools.html?ws=';
|
||||||
|
const DEVTOOLS_URL_BASE = 'https://chrome-devtools-frontend.appspot.com/serve_file/@60cd6e859b9f557d2312f5bf532f6aec5f284980/inspector.html?ws=';
|
||||||
|
|
||||||
|
class Device {
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
_id: string;
|
||||||
|
_socket: WebSocket;
|
||||||
|
_handlers: Map<string, (result?: Object) => void>;
|
||||||
|
_connections: Map<string, WebSocket>;
|
||||||
|
|
||||||
|
constructor(id: string, name: string, socket: WebSocket) {
|
||||||
|
this.name = name;
|
||||||
|
this._id = id;
|
||||||
|
this._socket = socket;
|
||||||
|
this._handlers = new Map();
|
||||||
|
this._connections = new Map();
|
||||||
|
|
||||||
|
this._socket.on('message', this._onMessage.bind(this));
|
||||||
|
this._socket.on('close', this._onDeviceDisconnected.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
getPages(): Promise<Array<DevicePage>> {
|
||||||
|
return this._callMethod('getPages');
|
||||||
|
}
|
||||||
|
|
||||||
|
connect(pageId: string, socket: WebSocket) {
|
||||||
|
socket.on('message', (message: string) => {
|
||||||
|
if (!this._connections.has(pageId)) {
|
||||||
|
// Not connected, silently ignoring
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._send({
|
||||||
|
event: 'wrappedEvent',
|
||||||
|
payload: {
|
||||||
|
pageId,
|
||||||
|
wrappedEvent: message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
socket.on('close', () => {
|
||||||
|
if (this._connections.has(pageId)) {
|
||||||
|
this._send({event: 'disconnect', payload: {pageId: pageId}});
|
||||||
|
this._removeConnection(pageId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this._connections.set(pageId, socket);
|
||||||
|
this._send({event: 'connect', payload: {pageId: pageId}});
|
||||||
|
}
|
||||||
|
|
||||||
|
_callMethod(name: 'getPages'): Promise<any> {
|
||||||
|
const promise = new Promise((fulfill, reject) => {
|
||||||
|
const timerId = setTimeout(() => {
|
||||||
|
this._handlers.delete(name);
|
||||||
|
reject(new Error('Timeout waiting for device'));
|
||||||
|
}, DEVICE_TIMEOUT);
|
||||||
|
this._handlers.set(name, (...args) => {
|
||||||
|
clearTimeout(timerId);
|
||||||
|
fulfill(...args);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this._send({event: name});
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
_send(message: Event) {
|
||||||
|
debug('-> device', this._id, message);
|
||||||
|
this._socket.send(JSON.stringify(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
_onMessage(json: string) {
|
||||||
|
debug('<- device', this._id, json);
|
||||||
|
const message = JSON.parse(json);
|
||||||
|
const handler = this._handlers.get(message.event);
|
||||||
|
if (handler) {
|
||||||
|
this._handlers.delete(message.event);
|
||||||
|
handler(message.payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.event === 'wrappedEvent') {
|
||||||
|
this._handleWrappedEvent(message);
|
||||||
|
} else if (message.event === 'disconnect') {
|
||||||
|
this._handleDisconnect(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleWrappedEvent(event: WrappedEvent) {
|
||||||
|
const payload = nullthrows(event.payload);
|
||||||
|
const socket = this._connections.get(payload.pageId);
|
||||||
|
if (!socket) {
|
||||||
|
console.error('Invalid pageId from device:', payload.pageId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
socket.send(payload.wrappedEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleDisconnect(event: DisconnectEvent) {
|
||||||
|
const payload = nullthrows(event.payload);
|
||||||
|
const pageId = nullthrows(payload.pageId);
|
||||||
|
this._removeConnection(pageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
_removeConnection(pageId: string) {
|
||||||
|
const socket = this._connections.get(pageId);
|
||||||
|
if (socket) {
|
||||||
|
this._connections.delete(pageId);
|
||||||
|
socket.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_onDeviceDisconnected() {
|
||||||
|
for (const pageId of this._connections.keys()) {
|
||||||
|
this._removeConnection(pageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class InspectorProxy {
|
||||||
|
_devices: Map<string, Device>;
|
||||||
|
_devicesCounter: number;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this._devices = new Map();
|
||||||
|
this._devicesCounter = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
attachToServer(server: http.Server, pathPrefix: string) {
|
||||||
|
this._createPageHandler(server, pathPrefix + '/page');
|
||||||
|
this._createDeviceHandler(server, pathPrefix + '/device');
|
||||||
|
this._createPagesListHandler(server, pathPrefix + '/');
|
||||||
|
this._createPagesJsonHandler(server, pathPrefix + '/json');
|
||||||
|
}
|
||||||
|
|
||||||
|
_makePage(server: Address, deviceId: string, deviceName: string, devicePage: DevicePage): Page {
|
||||||
|
// The inspector frontend doesn't handle urlencoded params so we
|
||||||
|
// manually urlencode it and decode it on the other side in _createPageHandler
|
||||||
|
const query = querystring.escape(`device=${deviceId}&page=${devicePage.id}`);
|
||||||
|
const wsUrl = `localhost:${server.port}/inspector/page?${query}`;
|
||||||
|
return {
|
||||||
|
id: `${deviceId}-${devicePage.id}`,
|
||||||
|
title: devicePage.title,
|
||||||
|
description: '',
|
||||||
|
devtoolsFrontendUrl: DEVTOOLS_URL_BASE + wsUrl,
|
||||||
|
webSocketDebuggerUrl: `ws://${wsUrl}`,
|
||||||
|
deviceId,
|
||||||
|
deviceName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
_getPages(localAddress: Address): Promise<Array<Page>> {
|
||||||
|
const promises = Array.from(this._devices.entries(), ([deviceId, device]) => {
|
||||||
|
return device.getPages().then((devicePages) => {
|
||||||
|
return devicePages.map(this._makePage.bind(this, localAddress, deviceId, device.name));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const flatMap = (arr) => flatMapArray(arr, (x) => x);
|
||||||
|
return Promise.all(promises).then(flatMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
processRequest(req: any, res: any, next: any) {
|
||||||
|
// TODO: Might wanna actually do the handling here
|
||||||
|
console.log(req.url);
|
||||||
|
const endpoints = [
|
||||||
|
'/inspector/',
|
||||||
|
'/inspector/page',
|
||||||
|
'/inspector/device',
|
||||||
|
'/inspector/json',
|
||||||
|
];
|
||||||
|
if (endpoints.indexOf(req.url) === -1) {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_createDeviceHandler(server: http.Server, path: string) {
|
||||||
|
const wss = new WebSocket.Server({
|
||||||
|
server,
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
wss.on('connection', (socket: WebSocket) => {
|
||||||
|
try {
|
||||||
|
const query = parseUrl(socket.upgradeReq.url, true).query || {};
|
||||||
|
const deviceName = query.name || 'Unknown';
|
||||||
|
debug('Got device connection:', deviceName);
|
||||||
|
const deviceId = String(this._devicesCounter++);
|
||||||
|
const device = new Device(deviceId, deviceName, socket);
|
||||||
|
this._devices.set(deviceId, device);
|
||||||
|
socket.on('close', () => {
|
||||||
|
this._devices.delete(deviceId);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
socket.close(1011, e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_createPageHandler(server: http.Server, path: string) {
|
||||||
|
const wss = new WebSocket.Server({
|
||||||
|
server,
|
||||||
|
path,
|
||||||
|
});
|
||||||
|
wss.on('connection', (socket: WebSocket) => {
|
||||||
|
try {
|
||||||
|
const url = parseUrl(socket.upgradeReq.url, false);
|
||||||
|
const { device, page } = querystring.parse(
|
||||||
|
querystring.unescape(nullthrows(url.query)));
|
||||||
|
if (device === undefined || page === undefined) {
|
||||||
|
throw Error('Must provide device and page');
|
||||||
|
}
|
||||||
|
const deviceObject = this._devices.get(device);
|
||||||
|
if (!deviceObject) {
|
||||||
|
throw Error('Unknown device: ' + device);
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceObject.connect(page, socket);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
socket.close(1011, e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_createPagesJsonHandler(server: http.Server, path: string) {
|
||||||
|
server.on('request', (request: http.IncomingMessage, response: http.ServerResponse) => {
|
||||||
|
if (request.url === path) {
|
||||||
|
this._getPages(server.address()).then((result: Array<Page>) => {
|
||||||
|
response.writeHead(200, {'Content-Type': 'application/json'});
|
||||||
|
response.end(JSON.stringify(result));
|
||||||
|
}, (error: Error) => {
|
||||||
|
response.writeHead(500);
|
||||||
|
response.end('Internal error: ' + error.toString());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_createPagesListHandler(server: http.Server, path: string) {
|
||||||
|
server.on('request', (request: http.IncomingMessage, response: http.ServerResponse) => {
|
||||||
|
if (request.url === path) {
|
||||||
|
this._getPages(server.address()).then((result: Array<Page>) => {
|
||||||
|
response.writeHead(200, {'Content-Type': 'text/html'});
|
||||||
|
response.end(buildPagesHtml(result));
|
||||||
|
}, (error: Error) => {
|
||||||
|
response.writeHead(500);
|
||||||
|
response.end('Internal error: ' + error.toString());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPagesHtml(pages: Array<Page>): string {
|
||||||
|
const pagesHtml = pages.map((page) => {
|
||||||
|
return escapeHtml`
|
||||||
|
<li style="padding: 5px;">
|
||||||
|
<a href="${page.devtoolsFrontendUrl}">
|
||||||
|
${page.deviceName} / ${page.title}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<html>
|
||||||
|
<head><title>Pages</title></head>
|
||||||
|
<body>
|
||||||
|
<h1>Pages</h1>
|
||||||
|
<hr>
|
||||||
|
<ul style="list-style: none;">
|
||||||
|
${pagesHtml}
|
||||||
|
</ul>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(pieces: Array<string>, ...substitutions: Array<string>): string {
|
||||||
|
let result = pieces[0];
|
||||||
|
for (let i = 0; i < substitutions.length; ++i) {
|
||||||
|
result += substitutions[i].replace(/[<&"'>]/g, escapeHtmlSpecialChar) + pieces[i + 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtmlSpecialChar(char: string): string {
|
||||||
|
return (
|
||||||
|
char === '&' ? '&' :
|
||||||
|
char === '"' ? '"' :
|
||||||
|
char === "'" ? ''' :
|
||||||
|
char === '<' ? '<' :
|
||||||
|
char === '>' ? '>' :
|
||||||
|
char
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachToServer(server: http.Server, pathPrefix: string): InspectorProxy {
|
||||||
|
const proxy = new InspectorProxy();
|
||||||
|
proxy.attachToServer(server, pathPrefix);
|
||||||
|
return proxy;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!module.parent) {
|
||||||
|
console.info('Starting server');
|
||||||
|
process.env.DEBUG = 'ReactNativePackager:Inspector';
|
||||||
|
const serverInstance = http.createServer().listen(
|
||||||
|
8081,
|
||||||
|
'localhost',
|
||||||
|
undefined,
|
||||||
|
function() {
|
||||||
|
attachToServer(serverInstance, '/inspector');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
serverInstance.timeout = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// module.exports.attachToServer = attachToServer;
|
||||||
|
module.exports = InspectorProxy;
|
Loading…
x
Reference in New Issue
Block a user