diff --git a/packages/cli-server-api/package.json b/packages/cli-server-api/package.json new file mode 100644 index 000000000..c16db4494 --- /dev/null +++ b/packages/cli-server-api/package.json @@ -0,0 +1,30 @@ +{ + "name": "@react-native-community/cli-server-api", + "version": "0.0.0", + "license": "MIT", + "main": "build/index.js", + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@react-native-community/cli-debugger-ui": "^4.2.1", + "@react-native-community/cli-tools": "^4.7.0", + "compression": "^1.7.1", + "connect": "^3.6.5", + "errorhandler": "^1.5.0", + "pretty-format": "^25.1.0", + "serve-static": "^1.13.1", + "ws": "^1.1.0" + }, + "devDependencies": { + "@types/compression": "^1.0.1", + "@types/connect": "^3.4.33", + "@types/errorhandler": "^0.0.32", + "@types/ws": "^6.0.3" + }, + "files": [ + "build", + "!*.d.ts", + "!*.map" + ] +} diff --git a/packages/cli-server-api/src/devToolsMiddleware.ts b/packages/cli-server-api/src/devToolsMiddleware.ts new file mode 100644 index 000000000..face6a1b3 --- /dev/null +++ b/packages/cli-server-api/src/devToolsMiddleware.ts @@ -0,0 +1,75 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +import http from 'http'; +import {launchDebugger, logger} from '@react-native-community/cli-tools'; +import {exec} from 'child_process'; + +function launchDefaultDebugger( + host: string | undefined, + port: number, + args = '', +) { + const hostname = host || 'localhost'; + const debuggerURL = `http://${hostname}:${port}/debugger-ui${args}`; + logger.info('Launching Dev Tools...'); + launchDebugger(debuggerURL); +} + +function escapePath(pathname: string) { + // " Can escape paths with spaces in OS X, Windows, and *nix + return `"${pathname}"`; +} + +type LaunchDevToolsOptions = { + host?: string; + port: number; + watchFolders: Array; +}; + +function launchDevTools( + {host, port, watchFolders}: LaunchDevToolsOptions, + isDebuggerConnected: () => boolean, +) { + // Explicit config always wins + const customDebugger = process.env.REACT_DEBUGGER; + if (customDebugger) { + startCustomDebugger({watchFolders, customDebugger}); + } else if (!isDebuggerConnected()) { + // Debugger is not yet open; we need to open a session + launchDefaultDebugger(host, port); + } +} + +function startCustomDebugger({ + watchFolders, + customDebugger, +}: { + watchFolders: Array; + customDebugger: string; +}) { + const folders = watchFolders.map(escapePath).join(' '); + const command = `${customDebugger} ${folders}`; + logger.info('Starting custom debugger by executing:', command); + exec(command, function(error) { + if (error !== null) { + logger.error('Error while starting custom debugger:', error.stack || ''); + } + }); +} + +export default function getDevToolsMiddleware( + options: LaunchDevToolsOptions, + isDebuggerConnected: () => boolean, +) { + return function devToolsMiddleware( + _req: http.IncomingMessage, + res: http.ServerResponse, + ) { + launchDevTools(options, isDebuggerConnected); + res.end('OK'); + }; +} diff --git a/packages/cli-server-api/src/index.html b/packages/cli-server-api/src/index.html new file mode 100644 index 000000000..0173e96d4 --- /dev/null +++ b/packages/cli-server-api/src/index.html @@ -0,0 +1,10 @@ + + + + React Native + + +

React Native packager is running.

+

Visit documentation

+ + diff --git a/packages/cli-server-api/src/index.ts b/packages/cli-server-api/src/index.ts new file mode 100644 index 000000000..723385db4 --- /dev/null +++ b/packages/cli-server-api/src/index.ts @@ -0,0 +1,94 @@ +import http, {Server as HttpServer} from 'http'; +import {Server as HttpsServer} from 'https'; + +import compression from 'compression'; +import connect from 'connect'; +import errorhandler from 'errorhandler'; +import serveStatic from 'serve-static'; +import {debuggerUIMiddleware} from '@react-native-community/cli-debugger-ui'; + +import devToolsMiddleware from './devToolsMiddleware'; +import indexPageMiddleware from './indexPageMiddleware'; +import openStackFrameInEditorMiddleware from './openStackFrameInEditorMiddleware'; +import openURLMiddleware from './openURLMiddleware'; +import rawBodyMiddleware from './rawBodyMiddleware'; +import securityHeadersMiddleware from './securityHeadersMiddleware'; +import statusPageMiddleware from './statusPageMiddleware'; +import systraceProfileMiddleware from './systraceProfileMiddleware'; + +import debuggerProxyServer from './websocket/debuggerProxyServer'; +import eventsSocketServer from './websocket/eventsSocketServer'; +import messageSocketServer from './websocket/messageSocketServer'; + +export {devToolsMiddleware}; +export {indexPageMiddleware}; +export {openStackFrameInEditorMiddleware}; +export {openURLMiddleware}; +export {rawBodyMiddleware}; +export {securityHeadersMiddleware}; +export {statusPageMiddleware}; +export {systraceProfileMiddleware}; + +export {debuggerProxyServer}; +export {eventsSocketServer}; +export {messageSocketServer}; + +type MiddlewareOptions = { + host?: string; + watchFolders: Array; + port: number; +}; + +export function createDevServerMiddleware(options: MiddlewareOptions) { + let isDebuggerConnected = () => false; + let broadcast = (_event: any) => {}; + + const middleware = connect() + .use(securityHeadersMiddleware) + // @ts-ignore compression and connect types mismatch + .use(compression()) + .use('/debugger-ui', debuggerUIMiddleware()) + .use( + '/launch-js-devtools', + devToolsMiddleware(options, () => isDebuggerConnected()), + ) + .use('/open-stack-frame', openStackFrameInEditorMiddleware(options)) + .use('/open-url', openURLMiddleware) + .use('/status', statusPageMiddleware) + .use('/systrace', systraceProfileMiddleware) + .use('/reload', (_req: http.IncomingMessage, res: http.ServerResponse) => { + broadcast('reload'); + res.end('OK'); + }) + .use(errorhandler()); + + options.watchFolders.forEach(folder => { + // @ts-ignore mismatch between express and connect middleware types + middleware.use(serveStatic(folder)); + }); + + return { + attachToServer(server: HttpServer | HttpsServer) { + const debuggerProxy = debuggerProxyServer.attachToServer( + server, + '/debugger-proxy', + ); + const messageSocket = messageSocketServer.attachToServer( + server, + '/message', + ); + broadcast = messageSocket.broadcast; + const eventsSocket = eventsSocketServer.attachToServer( + server, + '/events', + messageSocket, + ); + return { + debuggerProxy, + eventsSocket, + messageSocket, + }; + }, + middleware, + }; +} diff --git a/packages/cli-server-api/src/indexPageMiddleware.ts b/packages/cli-server-api/src/indexPageMiddleware.ts new file mode 100644 index 000000000..403c8e922 --- /dev/null +++ b/packages/cli-server-api/src/indexPageMiddleware.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +import http from 'http'; +import fs from 'fs'; +import path from 'path'; + +export default function indexPageMiddleware( + req: http.IncomingMessage, + res: http.ServerResponse, + next: (err?: any) => void, +) { + if (req.url === '/') { + res.setHeader('Content-Type', 'text/html'); + res.end(fs.readFileSync(path.join(__dirname, 'index.html'))); + } else { + next(); + } +} diff --git a/packages/cli-server-api/src/openStackFrameInEditorMiddleware.ts b/packages/cli-server-api/src/openStackFrameInEditorMiddleware.ts new file mode 100644 index 000000000..dd42bbc65 --- /dev/null +++ b/packages/cli-server-api/src/openStackFrameInEditorMiddleware.ts @@ -0,0 +1,35 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +import http from 'http'; +import {launchEditor} from '@react-native-community/cli-tools'; +import connect from 'connect'; +import rawBodyMiddleware from './rawBodyMiddleware'; + +type Options = { + watchFolders: Array; +}; + +function getOpenStackFrameInEditorMiddleware({watchFolders}: Options) { + return ( + req: http.IncomingMessage & {rawBody?: string}, + res: http.ServerResponse, + next: (err?: any) => void, + ) => { + if (!req.rawBody) { + return next(new Error('missing request body')); + } + const frame = JSON.parse(req.rawBody); + launchEditor(frame.file, frame.lineNumber, watchFolders); + res.end('OK'); + }; +} + +export default (options: Options) => { + return connect() + .use(rawBodyMiddleware) + .use(getOpenStackFrameInEditorMiddleware(options)); +}; diff --git a/packages/cli-server-api/src/openURLMiddleware.ts b/packages/cli-server-api/src/openURLMiddleware.ts new file mode 100644 index 000000000..a267aeddc --- /dev/null +++ b/packages/cli-server-api/src/openURLMiddleware.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +import http from 'http'; +import {launchDefaultBrowser, logger} from '@react-native-community/cli-tools'; +import connect from 'connect'; +import rawBodyMiddleware from './rawBodyMiddleware'; + +/** + * Handle request from JS to open an arbitrary URL in Chrome + */ +function openURLMiddleware( + req: http.IncomingMessage & {rawBody?: string}, + res: http.ServerResponse, + next: (err?: any) => void, +) { + if (!req.rawBody) { + return next(new Error('missing request body')); + } + const {url} = JSON.parse(req.rawBody); + logger.info(`Opening ${url}...`); + launchDefaultBrowser(url); + res.end('OK'); +} + +export default connect() + .use(rawBodyMiddleware) + .use(openURLMiddleware); diff --git a/packages/cli-server-api/src/rawBodyMiddleware.ts b/packages/cli-server-api/src/rawBodyMiddleware.ts new file mode 100644 index 000000000..2a9746387 --- /dev/null +++ b/packages/cli-server-api/src/rawBodyMiddleware.ts @@ -0,0 +1,24 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +import http from 'http'; + +export default function rawBodyMiddleware( + req: http.IncomingMessage, + _res: http.ServerResponse, + next: (err?: any) => void, +) { + (req as http.IncomingMessage & {rawBody: string}).rawBody = ''; + req.setEncoding('utf8'); + + req.on('data', (chunk: string) => { + (req as http.IncomingMessage & {rawBody: string}).rawBody += chunk; + }); + + req.on('end', () => { + next(); + }); +} diff --git a/packages/cli-server-api/src/securityHeadersMiddleware.ts b/packages/cli-server-api/src/securityHeadersMiddleware.ts new file mode 100644 index 000000000..10eae9cac --- /dev/null +++ b/packages/cli-server-api/src/securityHeadersMiddleware.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +import http from 'http'; + +export default function securityHeadersMiddleware( + req: http.IncomingMessage, + res: http.ServerResponse, + next: (err?: any) => void, +) { + // Block any cross origin request. + if ( + typeof req.headers.origin === 'string' && + !req.headers.origin.match(/^https?:\/\/localhost:/) + ) { + next( + new Error( + 'Unauthorized request from ' + + req.headers.origin + + '. This may happen because of a conflicting browser extension. Please try to disable it and try again.', + ), + ); + return; + } + + // Block MIME-type sniffing. + res.setHeader('X-Content-Type-Options', 'nosniff'); + + next(); +} diff --git a/packages/cli-server-api/src/statusPageMiddleware.ts b/packages/cli-server-api/src/statusPageMiddleware.ts new file mode 100644 index 000000000..edd692ce2 --- /dev/null +++ b/packages/cli-server-api/src/statusPageMiddleware.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +import http from 'http'; + +/** + * Status page so that anyone who needs to can verify that the packager is + * running on 8081 and not another program / service. + */ +export default function statusPageMiddleware( + _req: http.IncomingMessage, + res: http.ServerResponse, +) { + res.end('packager-status:running'); +} diff --git a/packages/cli-server-api/src/systraceProfileMiddleware.ts b/packages/cli-server-api/src/systraceProfileMiddleware.ts new file mode 100644 index 000000000..6c4a56c07 --- /dev/null +++ b/packages/cli-server-api/src/systraceProfileMiddleware.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +import http from 'http'; +import fs from 'fs'; +import {logger} from '@react-native-community/cli-tools'; + +export default function systraceProfileMiddleware( + req: http.IncomingMessage & {rawBody: string}, + res: http.ServerResponse, +) { + logger.info('Dumping profile information...'); + const dumpName = `/tmp/dump_${Date.now()}.json`; + fs.writeFileSync(dumpName, req.rawBody); + const response = + `Your profile was saved at:\n${dumpName}\n\n` + + 'On Google Chrome navigate to chrome://tracing and then click on "load" ' + + 'to load and visualise your profile.\n\n' + + 'This message is also printed to your console by the packager so you can copy it :)'; + logger.info(response); + res.end(response); +} diff --git a/packages/cli-server-api/src/websocket/debuggerProxyServer.ts b/packages/cli-server-api/src/websocket/debuggerProxyServer.ts new file mode 100644 index 000000000..a182b3532 --- /dev/null +++ b/packages/cli-server-api/src/websocket/debuggerProxyServer.ts @@ -0,0 +1,95 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +import ws from 'ws'; +import {logger} from '@react-native-community/cli-tools'; +import {Server as HttpServer} from 'http'; +import {Server as HttpsServer} from 'https'; + +type Server = HttpServer | HttpsServer; +function attachToServer(server: Server, path: string) { + const WebSocketServer = ws.Server; + const wss = new WebSocketServer({ + server, + path, + }); + + let debuggerSocket: ws | null; + let clientSocket: ws | null; + + function send(dest: ws | null, message: ws.Data) { + if (!dest) { + return; + } + + try { + dest.send(message); + } catch (e) { + logger.warn(e); + // Sometimes this call throws 'not opened' + } + } + + const debuggerSocketCloseHandler = () => { + debuggerSocket = null; + if (clientSocket) { + clientSocket.close(1011, 'Debugger was disconnected'); + } + }; + + const clientSocketCloseHandler = () => { + clientSocket = null; + send(debuggerSocket, JSON.stringify({method: '$disconnected'})); + }; + + wss.on('connection', (connection: ws) => { + // @ts-ignore current definition of ws does not have upgradeReq type + const {url} = connection.upgradeReq; + + if (url.indexOf('role=debugger') > -1) { + if (debuggerSocket) { + connection.close(1011, 'Another debugger is already connected'); + return; + } + debuggerSocket = connection; + if (debuggerSocket) { + debuggerSocket.onerror = debuggerSocketCloseHandler; + debuggerSocket.onclose = debuggerSocketCloseHandler; + debuggerSocket.onmessage = ({data}) => send(clientSocket, data); + } + } else if (url.indexOf('role=client') > -1) { + if (clientSocket) { + // @ts-ignore not nullable with current type definition of ws + clientSocket.onerror = null; + // @ts-ignore not nullable with current type definition of ws + clientSocket.onclose = null; + // @ts-ignore not nullable with current type definition of ws + clientSocket.onmessage = null; + clientSocket.close(1011, 'Another client connected'); + } + clientSocket = connection; + clientSocket.onerror = clientSocketCloseHandler; + clientSocket.onclose = clientSocketCloseHandler; + clientSocket.onmessage = ({data}) => send(debuggerSocket, data); + } else { + connection.close(1011, 'Missing role param'); + } + }); + + return { + server: wss, + isDebuggerConnected() { + return !!debuggerSocket; + }, + }; +} + +export default { + attachToServer, +}; diff --git a/packages/cli-server-api/src/websocket/eventsSocketServer.ts b/packages/cli-server-api/src/websocket/eventsSocketServer.ts new file mode 100644 index 000000000..0af4e8c6d --- /dev/null +++ b/packages/cli-server-api/src/websocket/eventsSocketServer.ts @@ -0,0 +1,203 @@ +import {Server as WebSocketServer} from 'ws'; +import {logger} from '@react-native-community/cli-tools'; +import prettyFormat from 'pretty-format'; +import {Server as HttpServer} from 'http'; +import {Server as HttpsServer} from 'https'; +import messageSocketModule from './messageSocketServer'; + +/** + * The eventsSocket websocket listens at the 'events/` for websocket + * connections, on which all Metro reports will be emitted. + * + * This is mostly useful for developer tools (clients) that wants to monitor Metro, + * and the apps connected to Metro. + * + * The eventsSocket provides the following features: + * - it reports any Metro event (that is reported through a reporter) to all clients + * - it reports any console.log's (and friends) from the connected app to all clients + * (as client_log event) + * - it allows connected clients to send commands through Metro to the connected app. + * This reuses the generic command mechanism. + * Two useful commands are 'reload' and 'devmenu'. + */ + +type Server = HttpServer | HttpsServer; + +type Command = { + version: number; + type: 'command'; + command: string; + params?: any; +}; + +/** + * This number is used to version the communication protocol between + * Dev tooling like Flipper and Metro, so that in the future we can recognize + * messages coming from old clients, so that it will be simpler to implement + * backward compatibility. + * + * We start at 2 as the protocol is currently the same as used internally at FB, + * which happens to be at version 2 as well. + */ +const PROTOCOL_VERSION = 2; + +function parseMessage(data: string): T | undefined { + try { + const message = JSON.parse(data); + if (message.version === PROTOCOL_VERSION) { + return message; + } + logger.error( + 'Received message had wrong protocol version: ' + message.version, + ); + } catch { + logger.error('Failed to parse the message as JSON:\n' + data); + } + return undefined; +} + +/** + * Two types of messages will arrive in this function, + * 1) messages generated by Metro itself (through the reporter abstraction) + * those are yet to be serialized, and can contain any kind of data structure + * 2) a specific event generated by Metro is `client_log`, which describes + * console.* calls in the app. + * The arguments send to the console are pretty printed so that they can be + * displayed in a nicer way in dev tools + * + * @param message + */ +function serializeMessage(message: any) { + // We do want to send Metro report messages, but their contents is not guaranteed to be serializable. + // For some known types we will pretty print otherwise not serializable parts first: + let toSerialize = message; + if (message && message.error && message.error instanceof Error) { + toSerialize = { + ...message, + error: prettyFormat(message.error, { + escapeString: true, + highlight: true, + maxDepth: 3, + min: true, + }), + }; + } else if (message && message.type === 'client_log') { + toSerialize = { + ...message, + data: message.data.map((item: any) => + typeof item === 'string' + ? item + : prettyFormat(item, { + escapeString: true, + highlight: true, + maxDepth: 3, + min: true, + plugins: [prettyFormat.plugins.ReactElement], + }), + ), + }; + } + try { + return JSON.stringify(toSerialize); + } catch (e) { + logger.error('Failed to serialize: ' + e); + return null; + } +} + +type MessageSocket = ReturnType; + +/** + * Starts the eventsSocket at the given path + * + * @param server + * @param path typically: 'events/' + * @param messageSocket: webSocket to which all connected RN apps are listening + */ +function attachToServer( + server: Server, + path: string, + messageSocket: MessageSocket, +) { + const wss = new WebSocketServer({ + server: server, + path: path, + verifyClient({origin}: {origin: string}) { + // This exposes the full JS logs and enables issuing commands like reload + // so let's make sure only locally running stuff can connect to it + return ( + origin.startsWith('http://localhost:') || origin.startsWith('file:') + ); + }, + }); + + const clients = new Map(); + let nextClientId = 0; + + /** + * broadCastEvent is called by reportEvent (below), which is called by the + * default reporter of this server, to make sure that all Metro events are + * broadcasted to all connected clients + * (that is, all devtools such as Flipper, _not_: connected apps) + * + * @param message + */ + function broadCastEvent(message: any) { + if (!clients.size) { + return; + } + const serialized = serializeMessage(message); + if (!serialized) { + return; + } + for (const ws of clients.values()) { + try { + ws.send(serialized); + } catch (e) { + logger.error( + `Failed to send broadcast to client due to:\n ${e.toString()}`, + ); + } + } + } + + wss.on('connection', function(clientWs) { + const clientId = `client#${nextClientId++}`; + + clients.set(clientId, clientWs); + + clientWs.onclose = clientWs.onerror = () => { + clients.delete(clientId); + }; + + clientWs.onmessage = event => { + const message: Command | undefined = parseMessage(event.data.toString()); + if (message == null) { + return; + } + if (message.type === 'command') { + try { + /** + * messageSocket.broadcast (not to be confused with our own broadcast above) + * forwards a command to all connected React Native applications. + */ + messageSocket.broadcast(message.command, message.params); + } catch (e) { + logger.error('Failed to forward message to clients: ', e); + } + } else { + logger.error('Unknown message type: ', message.type); + } + }; + }); + + return { + reportEvent: (event: any) => { + broadCastEvent(event); + }, + }; +} + +export default { + attachToServer, +}; diff --git a/packages/cli-server-api/src/websocket/messageSocketServer.ts b/packages/cli-server-api/src/websocket/messageSocketServer.ts new file mode 100644 index 000000000..2b78c5689 --- /dev/null +++ b/packages/cli-server-api/src/websocket/messageSocketServer.ts @@ -0,0 +1,256 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import url from 'url'; +import {Server as WebSocketServer} from 'ws'; +import {logger} from '@react-native-community/cli-tools'; +import {Server as HttpServer} from 'http'; +import {Server as HttpsServer} from 'https'; + +const PROTOCOL_VERSION = 2; + +type IdObject = { + requestId: string; + clientId: string; +}; + +type Message = { + version?: string; + id?: IdObject; + method?: string; + target: string; + result?: any; + error?: Error; + params?: Record; +}; + +function parseMessage(data: string, binary: any) { + if (binary) { + logger.error('Expected text message, got binary!'); + return undefined; + } + try { + const message = JSON.parse(data); + if (message.version === PROTOCOL_VERSION) { + return message; + } + logger.error( + `Received message had wrong protocol version: ${message.version}`, + ); + } catch (e) { + logger.error(`Failed to parse the message as JSON:\n${data}`); + } + return undefined; +} + +function isBroadcast(message: Message) { + return ( + typeof message.method === 'string' && + message.id === undefined && + message.target === undefined + ); +} + +function isRequest(message: Message) { + return ( + typeof message.method === 'string' && typeof message.target === 'string' + ); +} + +function isResponse(message: Message) { + return ( + typeof message.id === 'object' && + typeof message.id.requestId !== 'undefined' && + typeof message.id.clientId === 'string' && + (message.result !== undefined || message.error !== undefined) + ); +} + +type Server = HttpServer | HttpsServer; +function attachToServer(server: Server, path: string) { + const wss = new WebSocketServer({ + server, + path, + }); + const clients = new Map(); + let nextClientId = 0; + + function getClientWs(clientId: string) { + const clientWs = clients.get(clientId); + if (clientWs === undefined) { + throw new Error( + `could not find id "${clientId}" while forwarding request`, + ); + } + return clientWs; + } + + function handleSendBroadcast( + broadcasterId: string | null, + message: Partial, + ) { + const forwarded = { + version: PROTOCOL_VERSION, + method: message.method, + params: message.params, + }; + if (clients.size === 0) { + logger.warn( + `No apps connected. Sending "${ + message.method + }" to all React Native apps failed. Make sure your app is running in the simulator or on a phone connected via USB.`, + ); + } + for (const [otherId, otherWs] of clients) { + if (otherId !== broadcasterId) { + try { + otherWs.send(JSON.stringify(forwarded)); + } catch (e) { + logger.error( + `Failed to send broadcast to client: '${otherId}' ` + + `due to:\n ${e.toString()}`, + ); + } + } + } + } + + wss.on('connection', clientWs => { + const clientId = `client#${nextClientId++}`; + + function handleCaughtError(message: Message, error: Error) { + const errorMessage = { + id: message.id, + method: message.method, + target: message.target, + error: message.error === undefined ? 'undefined' : 'defined', + params: message.params === undefined ? 'undefined' : 'defined', + result: message.result === undefined ? 'undefined' : 'defined', + }; + + if (message.id === undefined) { + logger.error( + `Handling message from ${clientId} failed with:\n${error}\n` + + `message:\n${JSON.stringify(errorMessage)}`, + ); + } else { + try { + clientWs.send( + JSON.stringify({ + version: PROTOCOL_VERSION, + error, + id: message.id, + }), + ); + } catch (e) { + logger.error( + `Failed to reply to ${clientId} with error:\n${error}` + + `\nmessage:\n${JSON.stringify(errorMessage)}` + + `\ndue to error: ${e.toString()}`, + ); + } + } + } + + function handleServerRequest(message: Message) { + let result = null; + switch (message.method) { + case 'getid': + result = clientId; + break; + case 'getpeers': + result = {}; + clients.forEach((otherWs, otherId) => { + if (clientId !== otherId) { + result[otherId] = url.parse(otherWs.upgradeReq.url, true).query; + } + }); + break; + default: + throw new Error(`unknown method: ${message.method}`); + } + + clientWs.send( + JSON.stringify({ + version: PROTOCOL_VERSION, + result, + id: message.id, + }), + ); + } + + function forwardRequest(message: Message) { + getClientWs(message.target).send( + JSON.stringify({ + version: PROTOCOL_VERSION, + method: message.method, + params: message.params, + id: + message.id === undefined + ? undefined + : {requestId: message.id, clientId}, + }), + ); + } + + function forwardResponse(message: Message) { + if (!message.id) { + return; + } + getClientWs(message.id.clientId).send( + JSON.stringify({ + version: PROTOCOL_VERSION, + result: message.result, + error: message.error, + id: message.id.requestId, + }), + ); + } + + clients.set(clientId, clientWs); + const onCloseHandler = () => { + // @ts-ignore + clientWs.onmessage = null; + clients.delete(clientId); + }; + clientWs.onclose = onCloseHandler; + clientWs.onerror = onCloseHandler; + clientWs.onmessage = (event: any) => { + const message = parseMessage(event.data, event.binary); + if (message === undefined) { + logger.error('Received message not matching protocol'); + return; + } + + try { + if (isBroadcast(message)) { + handleSendBroadcast(clientId, message); + } else if (isRequest(message)) { + if (message.target === 'server') { + handleServerRequest(message); + } else { + forwardRequest(message); + } + } else if (isResponse(message)) { + forwardResponse(message); + } else { + throw new Error('Invalid message, did not match the protocol'); + } + } catch (e) { + handleCaughtError(message, e.toString()); + } + }; + }); + + return { + broadcast: (method: string, params?: Record) => { + handleSendBroadcast(null, {method, params}); + }, + }; +} + +export default {attachToServer, parseMessage}; diff --git a/packages/cli-server-api/tsconfig.json b/packages/cli-server-api/tsconfig.json new file mode 100644 index 000000000..7bb06bce6 --- /dev/null +++ b/packages/cli-server-api/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "build" + } +} diff --git a/packages/tools/package.json b/packages/tools/package.json index 1c088c512..d03183612 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -10,7 +10,9 @@ "chalk": "^3.0.0", "lodash": "^4.17.15", "mime": "^2.4.1", - "node-fetch": "^2.6.0" + "node-fetch": "^2.6.0", + "open": "^6.2.0", + "shell-quote": "1.6.1" }, "devDependencies": { "@types/lodash": "^4.14.149", diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index 8c98c6e0c..7c5c9dfe6 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -3,5 +3,8 @@ export {default as groupFilesByType} from './groupFilesByType'; export {default as isPackagerRunning} from './isPackagerRunning'; export {default as getDefaultUserTerminal} from './getDefaultUserTerminal'; export {fetch, fetchToTemp} from './fetch'; +export {default as launchDefaultBrowser} from './launchDefaultBrowser'; +export {default as launchDebugger} from './launchDebugger'; +export {default as launchEditor} from './launchEditor'; export * from './errors'; diff --git a/packages/tools/src/launchDebugger.ts b/packages/tools/src/launchDebugger.ts new file mode 100644 index 000000000..b3274d0a2 --- /dev/null +++ b/packages/tools/src/launchDebugger.ts @@ -0,0 +1,66 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +import open from 'open'; +import {execSync} from 'child_process'; +import logger from './logger'; +import launchDefaultBrowser from './launchDefaultBrowser'; +import chalk from 'chalk'; + +function commandExistsUnixSync(commandName: string) { + try { + const stdout = execSync( + `command -v ${commandName} 2>/dev/null` + + ` && { echo >&1 '${commandName} found'; exit 0; }`, + ); + return !!stdout; + } catch (error) { + return false; + } +} + +function getChromeAppName(): string { + switch (process.platform) { + case 'darwin': + return 'google chrome'; + case 'win32': + return 'chrome'; + case 'linux': + if (commandExistsUnixSync('google-chrome')) { + return 'google-chrome'; + } + if (commandExistsUnixSync('chromium-browser')) { + return 'chromium-browser'; + } + return 'chromium'; + + default: + return 'google-chrome'; + } +} + +function launchChrome(url: string) { + return open(url, {app: [getChromeAppName()], wait: true}); +} + +async function launchDebugger(url: string) { + try { + await launchChrome(url); + } catch (error) { + logger.debug(error); + logger.info( + `For a better debugging experience please install Google Chrome from: ${chalk.underline.dim( + 'https://www.google.com/chrome/', + )}`, + ); + launchDefaultBrowser(url); + } +} + +export default launchDebugger; diff --git a/packages/tools/src/launchDefaultBrowser.ts b/packages/tools/src/launchDefaultBrowser.ts new file mode 100644 index 000000000..f693d53aa --- /dev/null +++ b/packages/tools/src/launchDefaultBrowser.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +import open from 'open'; +import logger from './logger'; + +async function launchDefaultBrowser(url: string) { + try { + await open(url); + } catch (err) { + if (err) { + logger.error('Browser exited with error:', err); + } + } +} + +export default launchDefaultBrowser; diff --git a/packages/tools/src/launchEditor.ts b/packages/tools/src/launchEditor.ts new file mode 100644 index 000000000..5c70b899d --- /dev/null +++ b/packages/tools/src/launchEditor.ts @@ -0,0 +1,248 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +import chalk from 'chalk'; +import fs from 'fs'; +import path from 'path'; +import {execSync, spawn, ChildProcess} from 'child_process'; +// @ts-ignore @types not installed +import shellQuote from 'shell-quote'; +import logger from './logger'; + +function isTerminalEditor(editor: string) { + switch (editor) { + case 'vim': + case 'emacs': + case 'nano': + return true; + default: + return false; + } +} + +// Map from full process name to binary that starts the process +// We can't just re-use full process name, because it will spawn a new instance +// of the app every time +const COMMON_EDITORS: Record = { + '/Applications/Atom.app/Contents/MacOS/Atom': 'atom', + '/Applications/Atom Beta.app/Contents/MacOS/Atom Beta': + '/Applications/Atom Beta.app/Contents/MacOS/Atom Beta', + '/Applications/IntelliJ IDEA.app/Contents/MacOS/idea': 'idea', + '/Applications/Sublime Text.app/Contents/MacOS/Sublime Text': + '/Applications/Sublime Text.app/Contents/SharedSupport/bin/subl', + '/Applications/Sublime Text 2.app/Contents/MacOS/Sublime Text 2': + '/Applications/Sublime Text 2.app/Contents/SharedSupport/bin/subl', + '/Applications/Visual Studio Code.app/Contents/MacOS/Electron': 'code', + '/Applications/WebStorm.app/Contents/MacOS/webstorm': 'webstorm', +}; + +// Transpiled version of: /^[\p{L}0-9/.\-_\\]+$/u +// Non-transpiled version requires support for Unicode property regex. Allows +// alphanumeric characters, periods, dashes, slashes, and underscores. +const WINDOWS_FILE_NAME_WHITELIST = /^(?:[\x2D-9A-Z\\_a-z\xAA\xB5\xBA\xC0-\xD6\xD8-\xF6\xF8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376\u0377\u037A-\u037D\u037F\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0481\u048A-\u052F\u0531-\u0556\u0559\u0560-\u0588\u05D0-\u05EA\u05EF-\u05F2\u0620-\u064A\u066E\u066F\u0671-\u06D3\u06D5\u06E5\u06E6\u06EE\u06EF\u06FA-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07CA-\u07EA\u07F4\u07F5\u07FA\u0800-\u0815\u081A\u0824\u0828\u0840-\u0858\u0860-\u086A\u08A0-\u08B4\u08B6-\u08BD\u0904-\u0939\u093D\u0950\u0958-\u0961\u0971-\u0980\u0985-\u098C\u098F\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC\u09DD\u09DF-\u09E1\u09F0\u09F1\u09FC\u0A05-\u0A0A\u0A0F\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32\u0A33\u0A35\u0A36\u0A38\u0A39\u0A59-\u0A5C\u0A5E\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0\u0AE1\u0AF9\u0B05-\u0B0C\u0B0F\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32\u0B33\u0B35-\u0B39\u0B3D\u0B5C\u0B5D\u0B5F-\u0B61\u0B71\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99\u0B9A\u0B9C\u0B9E\u0B9F\u0BA3\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C39\u0C3D\u0C58-\u0C5A\u0C60\u0C61\u0C80\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDE\u0CE0\u0CE1\u0CF1\u0CF2\u0D05-\u0D0C\u0D0E-\u0D10\u0D12-\u0D3A\u0D3D\u0D4E\u0D54-\u0D56\u0D5F-\u0D61\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0E01-\u0E30\u0E32\u0E33\u0E40-\u0E46\u0E81\u0E82\u0E84\u0E87\u0E88\u0E8A\u0E8D\u0E94-\u0E97\u0E99-\u0E9F\u0EA1-\u0EA3\u0EA5\u0EA7\u0EAA\u0EAB\u0EAD-\u0EB0\u0EB2\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0EDC-\u0EDF\u0F00\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8C\u1000-\u102A\u103F\u1050-\u1055\u105A-\u105D\u1061\u1065\u1066\u106E-\u1070\u1075-\u1081\u108E\u10A0-\u10C5\u10C7\u10CD\u10D0-\u10FA\u10FC-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1380-\u138F\u13A0-\u13F5\u13F8-\u13FD\u1401-\u166C\u166F-\u167F\u1681-\u169A\u16A0-\u16EA\u16F1-\u16F8\u1700-\u170C\u170E-\u1711\u1720-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7\u17DC\u1820-\u1878\u1880-\u1884\u1887-\u18A8\u18AA\u18B0-\u18F5\u1900-\u191E\u1950-\u196D\u1970-\u1974\u1980-\u19AB\u19B0-\u19C9\u1A00-\u1A16\u1A20-\u1A54\u1AA7\u1B05-\u1B33\u1B45-\u1B4B\u1B83-\u1BA0\u1BAE\u1BAF\u1BBA-\u1BE5\u1C00-\u1C23\u1C4D-\u1C4F\u1C5A-\u1C7D\u1C80-\u1C88\u1C90-\u1CBA\u1CBD-\u1CBF\u1CE9-\u1CEC\u1CEE-\u1CF1\u1CF5\u1CF6\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2071\u207F\u2090-\u209C\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2183\u2184\u2C00-\u2C2E\u2C30-\u2C5E\u2C60-\u2CE4\u2CEB-\u2CEE\u2CF2\u2CF3\u2D00-\u2D25\u2D27\u2D2D\u2D30-\u2D67\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005\u3006\u3031-\u3035\u303B\u303C\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312F\u3131-\u318E\u31A0-\u31BA\u31F0-\u31FF\u3400-\u4DB5\u4E00-\u9FEF\uA000-\uA48C\uA4D0-\uA4FD\uA500-\uA60C\uA610-\uA61F\uA62A\uA62B\uA640-\uA66E\uA67F-\uA69D\uA6A0-\uA6E5\uA717-\uA71F\uA722-\uA788\uA78B-\uA7B9\uA7F7-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA822\uA840-\uA873\uA882-\uA8B3\uA8F2-\uA8F7\uA8FB\uA8FD\uA8FE\uA90A-\uA925\uA930-\uA946\uA960-\uA97C\uA984-\uA9B2\uA9CF\uA9E0-\uA9E4\uA9E6-\uA9EF\uA9FA-\uA9FE\uAA00-\uAA28\uAA40-\uAA42\uAA44-\uAA4B\uAA60-\uAA76\uAA7A\uAA7E-\uAAAF\uAAB1\uAAB5\uAAB6\uAAB9-\uAABD\uAAC0\uAAC2\uAADB-\uAADD\uAAE0-\uAAEA\uAAF2-\uAAF4\uAB01-\uAB06\uAB09-\uAB0E\uAB11-\uAB16\uAB20-\uAB26\uAB28-\uAB2E\uAB30-\uAB5A\uAB5C-\uAB65\uAB70-\uABE2\uAC00-\uD7A3\uD7B0-\uD7C6\uD7CB-\uD7FB\uF900-\uFA6D\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC]|\uD800[\uDC00-\uDC0B\uDC0D-\uDC26\uDC28-\uDC3A\uDC3C\uDC3D\uDC3F-\uDC4D\uDC50-\uDC5D\uDC80-\uDCFA\uDE80-\uDE9C\uDEA0-\uDED0\uDF00-\uDF1F\uDF2D-\uDF40\uDF42-\uDF49\uDF50-\uDF75\uDF80-\uDF9D\uDFA0-\uDFC3\uDFC8-\uDFCF]|\uD801[\uDC00-\uDC9D\uDCB0-\uDCD3\uDCD8-\uDCFB\uDD00-\uDD27\uDD30-\uDD63\uDE00-\uDF36\uDF40-\uDF55\uDF60-\uDF67]|\uD802[\uDC00-\uDC05\uDC08\uDC0A-\uDC35\uDC37\uDC38\uDC3C\uDC3F-\uDC55\uDC60-\uDC76\uDC80-\uDC9E\uDCE0-\uDCF2\uDCF4\uDCF5\uDD00-\uDD15\uDD20-\uDD39\uDD80-\uDDB7\uDDBE\uDDBF\uDE00\uDE10-\uDE13\uDE15-\uDE17\uDE19-\uDE35\uDE60-\uDE7C\uDE80-\uDE9C\uDEC0-\uDEC7\uDEC9-\uDEE4\uDF00-\uDF35\uDF40-\uDF55\uDF60-\uDF72\uDF80-\uDF91]|\uD803[\uDC00-\uDC48\uDC80-\uDCB2\uDCC0-\uDCF2\uDD00-\uDD23\uDF00-\uDF1C\uDF27\uDF30-\uDF45]|\uD804[\uDC03-\uDC37\uDC83-\uDCAF\uDCD0-\uDCE8\uDD03-\uDD26\uDD44\uDD50-\uDD72\uDD76\uDD83-\uDDB2\uDDC1-\uDDC4\uDDDA\uDDDC\uDE00-\uDE11\uDE13-\uDE2B\uDE80-\uDE86\uDE88\uDE8A-\uDE8D\uDE8F-\uDE9D\uDE9F-\uDEA8\uDEB0-\uDEDE\uDF05-\uDF0C\uDF0F\uDF10\uDF13-\uDF28\uDF2A-\uDF30\uDF32\uDF33\uDF35-\uDF39\uDF3D\uDF50\uDF5D-\uDF61]|\uD805[\uDC00-\uDC34\uDC47-\uDC4A\uDC80-\uDCAF\uDCC4\uDCC5\uDCC7\uDD80-\uDDAE\uDDD8-\uDDDB\uDE00-\uDE2F\uDE44\uDE80-\uDEAA\uDF00-\uDF1A]|\uD806[\uDC00-\uDC2B\uDCA0-\uDCDF\uDCFF\uDE00\uDE0B-\uDE32\uDE3A\uDE50\uDE5C-\uDE83\uDE86-\uDE89\uDE9D\uDEC0-\uDEF8]|\uD807[\uDC00-\uDC08\uDC0A-\uDC2E\uDC40\uDC72-\uDC8F\uDD00-\uDD06\uDD08\uDD09\uDD0B-\uDD30\uDD46\uDD60-\uDD65\uDD67\uDD68\uDD6A-\uDD89\uDD98\uDEE0-\uDEF2]|\uD808[\uDC00-\uDF99]|\uD809[\uDC80-\uDD43]|[\uD80C\uD81C-\uD820\uD840-\uD868\uD86A-\uD86C\uD86F-\uD872\uD874-\uD879][\uDC00-\uDFFF]|\uD80D[\uDC00-\uDC2E]|\uD811[\uDC00-\uDE46]|\uD81A[\uDC00-\uDE38\uDE40-\uDE5E\uDED0-\uDEED\uDF00-\uDF2F\uDF40-\uDF43\uDF63-\uDF77\uDF7D-\uDF8F]|\uD81B[\uDE40-\uDE7F\uDF00-\uDF44\uDF50\uDF93-\uDF9F\uDFE0\uDFE1]|\uD821[\uDC00-\uDFF1]|\uD822[\uDC00-\uDEF2]|\uD82C[\uDC00-\uDD1E\uDD70-\uDEFB]|\uD82F[\uDC00-\uDC6A\uDC70-\uDC7C\uDC80-\uDC88\uDC90-\uDC99]|\uD835[\uDC00-\uDC54\uDC56-\uDC9C\uDC9E\uDC9F\uDCA2\uDCA5\uDCA6\uDCA9-\uDCAC\uDCAE-\uDCB9\uDCBB\uDCBD-\uDCC3\uDCC5-\uDD05\uDD07-\uDD0A\uDD0D-\uDD14\uDD16-\uDD1C\uDD1E-\uDD39\uDD3B-\uDD3E\uDD40-\uDD44\uDD46\uDD4A-\uDD50\uDD52-\uDEA5\uDEA8-\uDEC0\uDEC2-\uDEDA\uDEDC-\uDEFA\uDEFC-\uDF14\uDF16-\uDF34\uDF36-\uDF4E\uDF50-\uDF6E\uDF70-\uDF88\uDF8A-\uDFA8\uDFAA-\uDFC2\uDFC4-\uDFCB]|\uD83A[\uDC00-\uDCC4\uDD00-\uDD43]|\uD83B[\uDE00-\uDE03\uDE05-\uDE1F\uDE21\uDE22\uDE24\uDE27\uDE29-\uDE32\uDE34-\uDE37\uDE39\uDE3B\uDE42\uDE47\uDE49\uDE4B\uDE4D-\uDE4F\uDE51\uDE52\uDE54\uDE57\uDE59\uDE5B\uDE5D\uDE5F\uDE61\uDE62\uDE64\uDE67-\uDE6A\uDE6C-\uDE72\uDE74-\uDE77\uDE79-\uDE7C\uDE7E\uDE80-\uDE89\uDE8B-\uDE9B\uDEA1-\uDEA3\uDEA5-\uDEA9\uDEAB-\uDEBB]|\uD869[\uDC00-\uDED6\uDF00-\uDFFF]|\uD86D[\uDC00-\uDF34\uDF40-\uDFFF]|\uD86E[\uDC00-\uDC1D\uDC20-\uDFFF]|\uD873[\uDC00-\uDEA1\uDEB0-\uDFFF]|\uD87A[\uDC00-\uDFE0]|\uD87E[\uDC00-\uDE1D])+$/; + +function addWorkspaceToArgumentsIfExists(args: string[], workspace: string) { + if (workspace) { + args.unshift(workspace); + } + return args; +} + +function getArgumentsForLineNumber( + editor: string, + fileName: string, + lineNumber: number, + workspace: any, +) { + switch (path.basename(editor)) { + case 'vim': + case 'mvim': + return [fileName, `+${lineNumber}`]; + case 'atom': + case 'Atom': + case 'Atom Beta': + case 'subl': + case 'sublime': + case 'webstorm': + case 'wstorm': + case 'appcode': + case 'charm': + case 'idea': + return [`${fileName}:${lineNumber}`]; + case 'joe': + case 'emacs': + case 'emacsclient': + return [`+${lineNumber}`, fileName]; + case 'rmate': + case 'mate': + case 'mine': + return ['--line', lineNumber, fileName]; + case 'code': + return addWorkspaceToArgumentsIfExists( + ['-g', `${fileName}:${lineNumber}`], + workspace, + ); + // For all others, drop the lineNumber until we have + // a mapping above, since providing the lineNumber incorrectly + // can result in errors or confusing behavior. + default: + return [fileName]; + } +} + +function guessEditor() { + // Explicit config always wins + if (process.env.REACT_EDITOR) { + return shellQuote.parse(process.env.REACT_EDITOR); + } + + // Using `ps x` on OSX we can find out which editor is currently running. + // Potentially we could use similar technique for Windows and Linux + if (process.platform === 'darwin') { + try { + const output = execSync('ps x').toString(); + const processNames = Object.keys(COMMON_EDITORS); + for (let i = 0; i < processNames.length; i++) { + const processName = processNames[i]; + if (output.indexOf(processName) !== -1) { + return [COMMON_EDITORS[processName]]; + } + } + } catch (error) { + // Ignore... + } + } + + // Last resort, use old skool env vars + if (process.env.VISUAL) { + return [process.env.VISUAL]; + } + if (process.env.EDITOR) { + return [process.env.EDITOR]; + } + + return [null]; +} + +function printInstructions(title: string) { + logger.info( + [ + '', + chalk.bgBlue.white.bold(` ${title} `), + ' When you see Red Box with stack trace, you can click any ', + ' stack frame to jump to the source file. The packager will launch your ', + ' editor of choice. It will first look at REACT_EDITOR environment ', + ' variable, then at EDITOR. To set it up, you can add something like ', + ' export REACT_EDITOR=atom to your ~/.bashrc or ~/.zshrc depending on ', + ' which shell you use.', + '', + ].join('\n'), + ); +} + +function transformToAbsolutePathIfNeeded(pathName: string) { + if (!path.isAbsolute(pathName)) { + return path.resolve(process.cwd(), pathName); + } + return pathName; +} + +function findRootForFile(projectRoots: string[], fileName: string) { + const absoluteFileName = transformToAbsolutePathIfNeeded(fileName); + return projectRoots.find(root => { + const absoluteRoot = transformToAbsolutePathIfNeeded(root); + return absoluteFileName.startsWith(absoluteRoot + path.sep); + }); +} + +let _childProcess: ChildProcess | null = null; +function launchEditor( + fileName: string, + lineNumber: number, + projectRoots: string[], +) { + if (!fs.existsSync(fileName)) { + return; + } + + // Sanitize lineNumber to prevent malicious use on win32 + // via: https://github.com/nodejs/node/blob/c3bb4b1aa5e907d489619fb43d233c3336bfc03d/lib/child_process.js#L333 + if (lineNumber && isNaN(lineNumber)) { + return; + } + + let [editor, ...args] = guessEditor(); + if (!editor) { + printInstructions('PRO TIP'); + return; + } + + const workspace = findRootForFile(projectRoots, fileName); + if (lineNumber) { + args = args.concat( + getArgumentsForLineNumber(editor, fileName, lineNumber, workspace), + ); + } else { + args.push(fileName); + } + + // cmd.exe on Windows is vulnerable to RCE attacks given a file name of the + // form "C:\Users\myusername\Downloads\& curl 172.21.93.52". Use a whitelist + // to validate user-provided file names. This doesn't cover the entire range + // of valid file names but should cover almost all of them in practice. + if ( + process.platform === 'win32' && + !WINDOWS_FILE_NAME_WHITELIST.test(fileName.trim()) + ) { + logger.error(`Could not open ${path.basename(fileName)} in the editor.`); + logger.info( + 'When running on Windows, file names are checked against a whitelist ' + + 'to protect against remote code execution attacks. File names may ' + + 'consist only of alphanumeric characters (all languages), periods, ' + + 'dashes, slashes, and underscores.', + ); + return; + } + + logger.info( + `Opening ${chalk.underline(fileName)} with ${chalk.bold(editor)}`, + ); + + if (_childProcess && isTerminalEditor(editor)) { + // There's an existing editor process already and it's attached + // to the terminal, so go kill it. Otherwise two separate editor + // instances attach to the stdin/stdout which gets confusing. + _childProcess.kill('SIGKILL'); + } + + if (process.platform === 'win32') { + // On Windows, launch the editor in a shell because spawn can only + // launch .exe files. + _childProcess = spawn('cmd.exe', ['/C', editor].concat(args), { + stdio: 'inherit', + }); + } else { + _childProcess = spawn(editor, args, {stdio: 'inherit'}); + } + _childProcess.on('exit', errorCode => { + _childProcess = null; + + if (errorCode) { + logger.error('Your editor exited with an error!'); + printInstructions('Keep these instructions in mind:'); + } + }); + + _childProcess.on('error', error => { + logger.error(error.message); + printInstructions('How to fix:'); + }); +} + +export default launchEditor; diff --git a/yarn.lock b/yarn.lock index bb7a4bd16..11f48c191 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2176,7 +2176,7 @@ dependencies: "@types/express" "*" -"@types/connect@*": +"@types/connect@*", "@types/connect@^3.4.33": version "3.4.33" resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.33.tgz#31610c901eca573b8713c3330abc6e6b9f588546" integrity sha512-2+FrkXY4zllzTNfJth7jOqEHC+enpLeGslEhpnTAkg21GkRrWV4SsAtqchtT4YS9/nODBU2/ZfsBY2X4J/dX7A==