Skip to content

Commit

Permalink
feat: introduce /events endpoint for devtools like Flipper (react-nat…
Browse files Browse the repository at this point in the history
…ive-community#953)

* introduce /events endpoint for devtools like Flipper

* Small fix for the CONTRIBUTING instructions on running a local CLI

* Processed review comments

* Update packages/cli/src/commands/server/eventsSocket.ts

* Update packages/cli/src/commands/server/eventsSocket.ts

Co-authored-by: Michał Pierzchała <[email protected]>
  • Loading branch information
mweststrate and thymikee authored Feb 17, 2020
1 parent 167c474 commit 73aad4f
Show file tree
Hide file tree
Showing 6 changed files with 335 additions and 4 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ build/
!packages/cli/src/commands/init/__fixtures__/editTemplate/node_modules
*.tsbuildinfo
.cache
.watchmanconfig
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ npm config set registry https://registry.npmjs.org/
In order for symlinks to work correctly when running `start` locally, set REACT_NATIVE_APP_ROOT as the root folder of your cli project:

```
REACT_NATIVE_APP_ROOT=path/to/cli node path/to/cli/packages/cli/build/index.js start
REACT_NATIVE_APP_ROOT=path/to/cli node path/to/cli/packages/cli/build/bin.js start
```

## Running CLI with React Native from the source
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"open": "^6.2.0",
"ora": "^3.4.0",
"plist": "^3.0.0",
"pretty-format": "^25.1.0",
"semver": "^6.3.0",
"serve-static": "^1.13.1",
"shell-quote": "1.6.1",
Expand All @@ -80,6 +81,7 @@
"@types/mkdirp": "^0.5.2",
"@types/morgan": "^1.7.37",
"@types/node-notifier": "^5.4.0",
"@types/pretty-format": "^24.3.0",
"@types/semver": "^6.0.2",
"@types/wcwidth": "^1.0.0",
"@types/ws": "^6.0.3",
Expand Down
201 changes: 201 additions & 0 deletions packages/cli/src/commands/server/eventsSocket.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
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 './messageSocket';

/**
* 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<T extends Object>(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<typeof messageSocketModule.attachToServer>;

/**
* 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:');
},
});

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 = 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,
};
22 changes: 20 additions & 2 deletions packages/cli/src/commands/server/runServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import path from 'path';
import {logger} from '@react-native-community/cli-tools';
import {Config} from '@react-native-community/cli-types';
import messageSocket from './messageSocket';
import eventsSocketModule from './eventsSocket';
import webSocketProxy from './webSocketProxy';
import MiddlewareManager from './middleware/MiddlewareManager';
import loadMetroConfig from '../../tools/loadMetroConfig';
Expand Down Expand Up @@ -42,9 +43,20 @@ export type Args = {
};

async function runServer(_argv: Array<string>, ctx: Config, args: Args) {
let eventsSocket:
| ReturnType<typeof eventsSocketModule.attachToServer>
| undefined;
const terminal = new Terminal(process.stdout);
const ReporterImpl = getReporterImpl(args.customLogReporterPath);
const reporter = new ReporterImpl(terminal);
const terminalReporter = new ReporterImpl(terminal);
const reporter = {
update(event: any) {
terminalReporter.update(event);
if (eventsSocket) {
eventsSocket.reportEvent(event);
}
},
};

const metroConfig = await loadMetroConfig(ctx, {
config: args.config,
Expand Down Expand Up @@ -108,6 +120,12 @@ async function runServer(_argv: Array<string>, ctx: Config, args: Args) {
'/debugger-proxy',
);
const ms = messageSocket.attachToServer(serverInstance, '/message');
eventsSocket = eventsSocketModule.attachToServer(
serverInstance,
'/events',
ms,
);

middlewareManager.attachDevToolsSocket(wsProxy);
middlewareManager.attachDevToolsSocket(ms);

Expand Down Expand Up @@ -137,7 +155,7 @@ async function runServer(_argv: Array<string>, ctx: Config, args: Args) {
await releaseChecker(ctx.root);
}

function getReporterImpl(customLogReporterPath: string | void) {
function getReporterImpl(customLogReporterPath: string | undefined) {
if (customLogReporterPath === undefined) {
return require('metro/src/lib/TerminalReporter');
}
Expand Down
Loading

0 comments on commit 73aad4f

Please sign in to comment.