diff --git a/local-cli/server/middleware/unless.js b/local-cli/server/middleware/unless.js new file mode 100644 index 000000000..c074a6c13 --- /dev/null +++ b/local-cli/server/middleware/unless.js @@ -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(); + } + }; +}; diff --git a/local-cli/server/runServer.js b/local-cli/server/runServer.js index 2490aecd9..5d2ae1730 100644 --- a/local-cli/server/runServer.js +++ b/local-cli/server/runServer.js @@ -26,12 +26,15 @@ const path = require('path'); const statusPageMiddleware = require('./middleware/statusPageMiddleware.js'); const systraceProfileMiddleware = require('./middleware/systraceProfileMiddleware.js'); const webSocketProxy = require('./util/webSocketProxy.js'); +const InspectorProxy = require('./util/inspectorProxy.js'); const defaultAssetExts = require('../../packager/defaults').assetExts; +const unless = require('./middleware/unless'); function runServer(args, config, readyCallback) { var wsProxy = null; var ms = null; const packagerServer = getPackagerServer(args, config); + const inspectorProxy = new InspectorProxy(); const app = connect() .use(loadRawBodyMiddleware) .use(connect.compress()) @@ -45,6 +48,7 @@ function runServer(args, config, readyCallback) { .use(cpuProfilerMiddleware) .use(jscProfilerMiddleware) .use(indexPageMiddleware) + .use(unless('/inspector', inspectorProxy.processRequest.bind(inspectorProxy))) .use(packagerServer.processRequest.bind(packagerServer)); args.projectRoots.forEach(root => app.use(connect.static(root))); @@ -65,6 +69,7 @@ function runServer(args, config, readyCallback) { wsProxy = webSocketProxy.attachToServer(serverInstance, '/debugger-proxy'); ms = messageSocket.attachToServer(serverInstance, '/message'); webSocketProxy.attachToServer(serverInstance, '/devtools'); + inspectorProxy.attachToServer(serverInstance, '/inspector'); readyCallback(); } ); diff --git a/local-cli/server/util/inspectorProxy.js b/local-cli/server/util/inspectorProxy.js new file mode 100644 index 000000000..855ac1bef --- /dev/null +++ b/local-cli/server/util/inspectorProxy.js @@ -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: '', 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 = { + 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>; + +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 void>; + _connections: Map; + + 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> { + 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 { + 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; + _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> { + 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) => { + 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) => { + 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): string { + const pagesHtml = pages.map((page) => { + return escapeHtml` +
  • + + ${page.deviceName} / ${page.title} + +
  • + `; + }).join('\n'); + + return ` + + Pages + +

    Pages

    +
    +
      + ${pagesHtml} +
    + + + `; +} + +function escapeHtml(pieces: Array, ...substitutions: Array): 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;