From 3470a2737f41c2fbdd1081cfea4bfe5e332c9d50 Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Fri, 30 Oct 2026 10:49:17 +0000 Subject: [PATCH] Move /message, /events and /reload into community-cli-plugin --- flow-typed/npm/ws_v7.x.x.js | 8 +- packages/community-cli-plugin/package.json | 4 +- .../dev-server/__tests__/SocketTestUtils.js | 165 +++++++++++ .../createDevServerMiddleware-test.js | 34 ++- .../__tests__/createEventsSocket-test.js | 131 +++++++++ .../__tests__/createMessageSocket-test.js | 161 +++++++++++ .../dev-server/createDevServerMiddleware.js | 47 ++- .../src/dev-server/createEventsSocket.js | 163 +++++++++++ .../src/dev-server/createMessageSocket.js | 267 ++++++++++++++++++ .../src/dev-server/isLocalSocket.js | 34 +++ .../src/dev-server/loadCommunityMiddleware.js | 29 -- .../src/dev-server/runDevServer.js | 25 +- 12 files changed, 1018 insertions(+), 50 deletions(-) create mode 100644 packages/community-cli-plugin/src/dev-server/__tests__/SocketTestUtils.js create mode 100644 packages/community-cli-plugin/src/dev-server/__tests__/createEventsSocket-test.js create mode 100644 packages/community-cli-plugin/src/dev-server/__tests__/createMessageSocket-test.js create mode 100644 packages/community-cli-plugin/src/dev-server/createEventsSocket.js create mode 100644 packages/community-cli-plugin/src/dev-server/createMessageSocket.js create mode 100644 packages/community-cli-plugin/src/dev-server/isLocalSocket.js diff --git a/flow-typed/npm/ws_v7.x.x.js b/flow-typed/npm/ws_v7.x.x.js index 9a9639d4e580..520a293363b8 100644 --- a/flow-typed/npm/ws_v7.x.x.js +++ b/flow-typed/npm/ws_v7.x.x.js @@ -39,7 +39,13 @@ declare class ws$WebSocketServer extends events$EventEmitter { perMessageDeflate?: boolean | ws$PerMessageDeflateOptions, port?: number, server?: http$Server | https$Server, - verifyClient?: () => unknown, + verifyClient?: ( + info: Readonly<{ + origin: string, + secure: boolean, + req: http$IncomingMessage<>, + }>, + ) => unknown, }, callback?: () => unknown, ): this; diff --git a/packages/community-cli-plugin/package.json b/packages/community-cli-plugin/package.json index 4dea1b8c9116..e3ae8e918626 100644 --- a/packages/community-cli-plugin/package.json +++ b/packages/community-cli-plugin/package.json @@ -43,7 +43,9 @@ "metro": "^0.87.2", "nocache": "^3.0.1", "open": "^8.4.2", - "semver": "^7.1.3" + "pretty-format": "^29.7.0", + "semver": "^7.1.3", + "ws": "^7.5.10" }, "devDependencies": { "metro-resolver": "^0.87.2" diff --git a/packages/community-cli-plugin/src/dev-server/__tests__/SocketTestUtils.js b/packages/community-cli-plugin/src/dev-server/__tests__/SocketTestUtils.js new file mode 100644 index 000000000000..98f3ae5b8635 --- /dev/null +++ b/packages/community-cli-plugin/src/dev-server/__tests__/SocketTestUtils.js @@ -0,0 +1,165 @@ +/** + * 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 + */ + +import http from 'node:http'; +import WebSocket from 'ws'; + +export type TestServer = Readonly<{ + baseUrl: URL, + mount: (path: string, wss: ws$WebSocketServer) => void, + server: http$Server, + connect: ( + path: string, + options?: Readonly<{origin?: string}>, + ) => Promise, + close: () => Promise, +}>; + +export type TestClient = Readonly<{ + send: (message: unknown) => void, + sendRaw: (data: string | Buffer) => void, + next: () => Promise, + /** + * Resolves with the messages received before the server answers a `getid` + * request, which it handles after everything this client was sent earlier. + */ + drain: () => Promise>, + getId: () => Promise, + close: () => void, +}>; + +/** + * Retry an assertion until it passes, for an effect the server applies after + * the client has already sent its message. + */ +export async function waitUntil(assertion: () => void): Promise { + const deadline = Date.now() + 2000; + for (;;) { + try { + assertion(); + return; + } catch (e) { + if (Date.now() > deadline) { + throw e; + } + await new Promise(resolve => setTimeout(resolve, 10)); + } + } +} + +export async function startTestServer(): Promise { + const endpoints = new Map(); + const sockets = new Set(); + const server = http.createServer(); + server.on('upgrade', (req, socket, head) => { + const wss = endpoints.get(new URL(req.url, 'http://localhost').pathname); + if (wss == null) { + socket.destroy(); + return; + } + wss.handleUpgrade(req, socket, head, ws => wss.emit('connection', ws, req)); + }); + await new Promise(resolve => server.listen(0, 'localhost', resolve)); + const address = server.address(); + if (address == null || typeof address === 'string') { + throw new Error('Expected the server to listen on a TCP port'); + } + const baseUrl = new URL(`http://localhost:${address.port}`); + + return { + baseUrl, + mount: (path, wss) => { + endpoints.set(path, wss); + }, + server, + connect: async (path, options) => { + const socket = new WebSocket(`ws://localhost:${address.port}${path}`, { + headers: options?.origin == null ? {} : {Origin: options.origin}, + }); + sockets.add(socket); + const received: Array = []; + const waiting: Array<(message: unknown) => void> = []; + socket.on('message', data => { + const message = JSON.parse(String(data)); + const resolve = waiting.shift(); + if (resolve != null) { + resolve(message); + } else { + received.push(message); + } + }); + await new Promise((resolve, reject) => { + socket.once('open', resolve); + socket.once('error', reject); + socket.once('unexpected-response', (req, res) => + reject(new Error(`HTTP ${res.statusCode}`)), + ); + }); + + const next = (): Promise => + received.length > 0 + ? Promise.resolve(received.shift()) + : new Promise(resolve => waiting.push(resolve)); + + return { + send: message => socket.send(JSON.stringify(message)), + sendRaw: data => socket.send(data), + next, + drain: async () => { + socket.send( + JSON.stringify({ + version: 2, + method: 'getid', + target: 'server', + id: 'drain', + }), + ); + const before = []; + let message = await next(); + while (!isReplyTo(message, 'drain')) { + before.push(message); + message = await next(); + } + return before; + }, + getId: async () => { + socket.send( + JSON.stringify({ + version: 2, + method: 'getid', + target: 'server', + id: 'getid', + }), + ); + const message = await next(); + if ( + message == null || + typeof message !== 'object' || + message.id !== 'getid' || + typeof message.result !== 'string' + ) { + throw new Error('Expected a getid reply'); + } + return message.result; + }, + close: () => socket.close(), + }; + }, + close: async () => { + sockets.forEach(socket => socket.terminate()); + server.closeAllConnections(); + await new Promise(resolve => server.close(() => resolve())); + }, + }; +} + +function isReplyTo(message: unknown, id: string): boolean { + return message != null && typeof message === 'object' && message.id === id; +} diff --git a/packages/community-cli-plugin/src/dev-server/__tests__/createDevServerMiddleware-test.js b/packages/community-cli-plugin/src/dev-server/__tests__/createDevServerMiddleware-test.js index 3d10e350356a..ceb59fbdd7f0 100644 --- a/packages/community-cli-plugin/src/dev-server/__tests__/createDevServerMiddleware-test.js +++ b/packages/community-cli-plugin/src/dev-server/__tests__/createDevServerMiddleware-test.js @@ -33,7 +33,8 @@ beforeEach(async () => { serverBaseUrl: new URL(baseUrl), projectRoot: '/project', watchFolders: ['/project'], - }), + logger: {warn: () => {}, error: () => {}}, + }).middleware, ) .use((req, res) => { res.writeHead(404); @@ -62,3 +63,34 @@ describe('createDevServerMiddleware', () => { expect(resp.status).toBe(403); }); }); + +describe('createDevServerMiddleware sockets', () => { + const {startTestServer} = require('./SocketTestUtils'); + let testServer; + + beforeEach(async () => { + const connect = require('connect'); + const createDevServerMiddleware = + require('../createDevServerMiddleware').default; + testServer = await startTestServer(); + const {middleware, websocketEndpoints} = createDevServerMiddleware({ + serverBaseUrl: testServer.baseUrl, + projectRoot: '/project', + watchFolders: ['/project'], + logger: {warn: () => {}, error: () => {}}, + }); + testServer.server.on('request', connect().use(middleware)); + testServer.mount('/message', websocketEndpoints['/message']); + }); + + afterEach(() => testServer.close()); + + test('reloads the connected apps on /reload', async () => { + const app = await testServer.connect('/message'); + + const resp = await fetch(testServer.baseUrl.origin + '/reload'); + + expect(await resp.text()).toBe('OK'); + expect(await app.next()).toEqual({version: 2, method: 'reload'}); + }); +}); diff --git a/packages/community-cli-plugin/src/dev-server/__tests__/createEventsSocket-test.js b/packages/community-cli-plugin/src/dev-server/__tests__/createEventsSocket-test.js new file mode 100644 index 000000000000..1ede4dcb2a1c --- /dev/null +++ b/packages/community-cli-plugin/src/dev-server/__tests__/createEventsSocket-test.js @@ -0,0 +1,131 @@ +/** + * 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 + */ + +import type {TestServer} from './SocketTestUtils'; + +import createEventsSocket from '../createEventsSocket'; +import {startTestServer, waitUntil} from './SocketTestUtils'; + +jest.useRealTimers(); + +const logger = {warn: jest.fn(), error: jest.fn()}; +const broadcast = jest.fn<[string, ?{[string]: unknown}], void>(); + +let testServer: TestServer; +let eventsSocket: ReturnType; + +beforeEach(async () => { + logger.warn.mockReset(); + logger.error.mockReset(); + broadcast.mockReset(); + testServer = await startTestServer(); + eventsSocket = createEventsSocket({ + logger, + serverBaseUrl: testServer.baseUrl, + broadcast, + }); + testServer.mount('/events', eventsSocket.server); +}); + +afterEach(() => testServer.close()); + +describe('/events', () => { + test('sends a reported event to every client', async () => { + const first = await testServer.connect('/events'); + const second = await testServer.connect('/events'); + + eventsSocket.reportEvent({type: 'bundle_build_started', id: 'a'}); + + const expected = {type: 'bundle_build_started', id: 'a'}; + expect(await first.next()).toEqual(expected); + expect(await second.next()).toEqual(expected); + }); + + test('formats the error of an event', async () => { + const client = await testServer.connect('/events'); + + eventsSocket.reportEvent({ + type: 'bundling_error', + error: new Error('boom'), + }); + + expect(await client.next()).toEqual({ + type: 'bundling_error', + error: expect.stringContaining('boom'), + }); + }); + + test('logs an event that cannot be serialized', async () => { + const client = await testServer.connect('/events'); + const circular: {[string]: unknown} = {}; + circular.self = circular; + + eventsSocket.reportEvent(circular); + eventsSocket.reportEvent({type: 'after'}); + + expect(await client.next()).toEqual({type: 'after'}); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to serialize'), + ); + }); + + test('forwards a command to the connected apps', async () => { + const client = await testServer.connect('/events'); + + client.send({ + version: 2, + type: 'command', + command: 'reload', + params: {a: 1}, + }); + + await waitUntil(() => + expect(broadcast).toHaveBeenCalledWith('reload', {a: 1}), + ); + }); + + test.each([ + ['a message that is not JSON', 'not json', 'Failed to parse the message'], + [ + 'a message with another protocol version', + '{"version":1,"type":"command","command":"reload"}', + 'wrong protocol version: 1', + ], + ])('logs and ignores %s', async (_, raw, logged) => { + const client = await testServer.connect('/events'); + + client.sendRaw(raw); + client.send({version: 2, type: 'command', command: 'devMenu'}); + await waitUntil(() => expect(broadcast).toHaveBeenCalledTimes(1)); + + expect(broadcast).toHaveBeenCalledWith('devMenu', undefined); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining(logged)); + }); + + test.each([ + ['no origin', undefined], + ['the dev server origin', 'SERVER'], + ['a file origin', 'file://'], + ])('accepts a client with %s', async (_, origin) => { + const client = await testServer.connect('/events', { + origin: origin === 'SERVER' ? testServer.baseUrl.origin : origin, + }); + + eventsSocket.reportEvent({type: 'x'}); + + expect(await client.next()).toEqual({type: 'x'}); + }); + + test('refuses a client with a foreign origin', async () => { + await expect( + testServer.connect('/events', {origin: 'https://example.com'}), + ).rejects.toThrow('HTTP 401'); + }); +}); diff --git a/packages/community-cli-plugin/src/dev-server/__tests__/createMessageSocket-test.js b/packages/community-cli-plugin/src/dev-server/__tests__/createMessageSocket-test.js new file mode 100644 index 000000000000..d8fc2cfdf009 --- /dev/null +++ b/packages/community-cli-plugin/src/dev-server/__tests__/createMessageSocket-test.js @@ -0,0 +1,161 @@ +/** + * 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 + */ + +import type {TestServer} from './SocketTestUtils'; + +import createMessageSocket from '../createMessageSocket'; +import {startTestServer} from './SocketTestUtils'; + +jest.useRealTimers(); + +const logger = {warn: jest.fn(), error: jest.fn()}; + +let testServer: TestServer; +let messageSocket: ReturnType; + +beforeEach(async () => { + logger.warn.mockReset(); + logger.error.mockReset(); + testServer = await startTestServer(); + messageSocket = createMessageSocket({ + logger, + serverBaseUrl: testServer.baseUrl, + }); + testServer.mount('/message', messageSocket.server); +}); + +afterEach(() => testServer.close()); + +describe('/message', () => { + test('answers getpeers with the other clients and their query', async () => { + const first = await testServer.connect('/message?name=first&platform=ios'); + const firstId = await first.getId(); + const second = await testServer.connect('/message'); + + second.send({version: 2, method: 'getpeers', target: 'server', id: 2}); + + expect(await second.next()).toEqual({ + version: 2, + result: {[firstId]: {name: 'first', platform: 'ios'}}, + id: 2, + }); + }); + + test('forwards a request to its target and the response back', async () => { + const requester = await testServer.connect('/message'); + const target = await testServer.connect('/message'); + const requesterId = await requester.getId(); + const targetId = await target.getId(); + + requester.send({ + version: 2, + method: 'ping', + target: targetId, + id: 7, + params: {n: 1}, + }); + expect(await target.next()).toEqual({ + version: 2, + method: 'ping', + params: {n: 1}, + id: {requestId: 7, clientId: requesterId}, + }); + + target.send({ + version: 2, + id: {requestId: 7, clientId: requesterId}, + result: 'pong', + }); + expect(await requester.next()).toEqual({ + version: 2, + id: 7, + result: 'pong', + }); + }); + + test('replies with an error to a request for an unknown target', async () => { + const client = await testServer.connect('/message'); + + client.send({version: 2, method: 'ping', target: 'client#99', id: 3}); + + expect(await client.next()).toEqual({ + version: 2, + error: expect.stringContaining('could not find id "client#99"'), + id: 3, + }); + }); + + test('relays a reload broadcast from a trusted client to the others', async () => { + const sender = await testServer.connect('/message'); + const other = await testServer.connect('/message'); + + sender.send({version: 2, method: 'reload', params: {a: 1}}); + + expect(await other.next()).toEqual({ + version: 2, + method: 'reload', + params: {a: 1}, + }); + expect(await sender.drain()).toEqual([]); + }); + + test('drops a broadcast of any other method', async () => { + const sender = await testServer.connect('/message'); + const other = await testServer.connect('/message'); + + sender.send({version: 2, method: 'openFile', params: {}}); + + expect(await sender.drain()).toEqual([]); + expect(await other.drain()).toEqual([]); + }); + + test('drops a broadcast from a client with a foreign origin', async () => { + const sender = await testServer.connect('/message', { + origin: 'https://example.com', + }); + const other = await testServer.connect('/message'); + + sender.send({version: 2, method: 'reload'}); + + expect(await sender.drain()).toEqual([]); + expect(await other.drain()).toEqual([]); + }); + + test('broadcasts a server command to every client', async () => { + const first = await testServer.connect('/message'); + const second = await testServer.connect('/message'); + await first.drain(); + await second.drain(); + + messageSocket.broadcast('devMenu', {a: 1}); + + const expected = {version: 2, method: 'devMenu', params: {a: 1}}; + expect(await first.next()).toEqual(expected); + expect(await second.next()).toEqual(expected); + }); + + test.each([ + ['a message that is not JSON', 'not json', 'Failed to parse the message'], + [ + 'a message with another protocol version', + '{"version":1,"method":"reload"}', + 'wrong protocol version: 1', + ], + ])('logs and ignores %s', async (_, raw, logged) => { + const client = await testServer.connect('/message'); + const other = await testServer.connect('/message'); + + client.sendRaw(raw); + + expect(await client.drain()).toEqual([]); + expect(await other.drain()).toEqual([]); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining(logged)); + }); +}); diff --git a/packages/community-cli-plugin/src/dev-server/createDevServerMiddleware.js b/packages/community-cli-plugin/src/dev-server/createDevServerMiddleware.js index 672eb4b2d820..c02512d3c781 100644 --- a/packages/community-cli-plugin/src/dev-server/createDevServerMiddleware.js +++ b/packages/community-cli-plugin/src/dev-server/createDevServerMiddleware.js @@ -8,8 +8,11 @@ * @format */ +import type {Logger} from './createMessageSocket'; import type {Server} from 'connect'; +import createEventsSocket from './createEventsSocket'; +import createMessageSocket from './createMessageSocket'; import openStackFrameMiddleware from './openStackFrameMiddleware'; import openURLMiddleware from './openURLMiddleware'; import securityHeadersMiddleware from './securityHeadersMiddleware'; @@ -22,22 +25,56 @@ type Options = Readonly<{ serverBaseUrl: URL, projectRoot: string, watchFolders: ReadonlyArray, + logger: Logger, +}>; + +type DevServerMiddleware = Readonly<{ + middleware: Server, + websocketEndpoints: {[path: string]: ws$WebSocketServer}, + messageSocket: Readonly<{ + broadcast: (method: string, params?: ?{[string]: unknown}) => void, + }>, + eventsSocket: Readonly<{ + reportEvent: (event: unknown) => void, + }>, }>; /** - * The dev server routes that React Native tooling and apps call outside - * Metro and `@react-native/dev-middleware`. + * The dev server routes and WebSockets that React Native tooling and apps + * call outside Metro and `@react-native/dev-middleware`. */ export default function createDevServerMiddleware({ serverBaseUrl, projectRoot, watchFolders, -}: Options): Server { - return connect() + logger, +}: Options): DevServerMiddleware { + const messageSocket = createMessageSocket({logger, serverBaseUrl}); + const eventsSocket = createEventsSocket({ + logger, + serverBaseUrl, + broadcast: messageSocket.broadcast, + }); + + const middleware = connect() .use(securityHeadersMiddleware({serverBaseUrl})) .use(compression()) .use(nocache()) .use('/open-stack-frame', openStackFrameMiddleware({watchFolders})) .use('/open-url', openURLMiddleware()) - .use('/status', statusMiddleware({projectRoot})); + .use('/status', statusMiddleware({projectRoot})) + .use('/reload', (req, res) => { + messageSocket.broadcast('reload'); + res.end('OK'); + }); + + return { + middleware, + websocketEndpoints: { + '/message': messageSocket.server, + '/events': eventsSocket.server, + }, + messageSocket: {broadcast: messageSocket.broadcast}, + eventsSocket: {reportEvent: eventsSocket.reportEvent}, + }; } diff --git a/packages/community-cli-plugin/src/dev-server/createEventsSocket.js b/packages/community-cli-plugin/src/dev-server/createEventsSocket.js new file mode 100644 index 000000000000..ff5905293d3e --- /dev/null +++ b/packages/community-cli-plugin/src/dev-server/createEventsSocket.js @@ -0,0 +1,163 @@ +/** + * 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 + */ + +import type {Logger} from './createMessageSocket'; + +import isAllowedOrigin from './isAllowedOrigin'; +import {format as prettyFormat, plugins} from 'pretty-format'; +import WS from 'ws'; + +const PROTOCOL_VERSION = 2; + +type Options = Readonly<{ + logger: Logger, + serverBaseUrl: URL, + broadcast: (method: string, params?: ?{[string]: unknown}) => void, +}>; + +/** + * The `/events` WebSocket, for developer tools that monitor a dev server. It + * sends every Metro reporter event, including the console output of connected + * apps as `client_log`, to each client, and forwards a client's `command` + * message (such as `reload` or `devMenu`) to the connected apps. + * + * Commands reach the apps, so only a client with no `Origin` or a local one + * may connect. + */ +export default function createEventsSocket({ + logger, + serverBaseUrl, + broadcast, +}: Options): { + server: ws$WebSocketServer, + reportEvent: (event: unknown) => void, +} { + const server = new WS.Server({ + noServer: true, + verifyClient: ({origin}) => + origin == null || + origin === '' || + isAllowedOrigin(origin, serverBaseUrl) || + origin.startsWith('file:'), + }); + const clients = new Set(); + + server.on('connection', socket => { + clients.add(socket); + + function removeClient() { + clients.delete(socket); + } + socket.on('close', removeClient); + socket.on('error', removeClient); + + socket.on('message', data => { + const message = parseCommand(data, logger); + if (message == null) { + return; + } + try { + broadcast(message.command, message.params); + } catch (e) { + logger.error('Failed to forward message to clients: ', String(e)); + } + }); + }); + + return { + server, + reportEvent: event => { + if (clients.size === 0) { + return; + } + const serialized = serializeEvent(event, logger); + if (serialized == null) { + return; + } + for (const socket of clients) { + try { + socket.send(serialized); + } catch (e) { + logger.error( + `Failed to send broadcast to client due to:\n ${String(e)}`, + ); + } + } + }, + }; +} + +function parseCommand( + data: unknown, + logger: Logger, +): ?Readonly<{command: string, params: ?{[string]: unknown}}> { + let message; + try { + message = JSON.parse(String(data)); + } catch { + logger.error('Failed to parse the message as JSON:\n' + String(data)); + return null; + } + if (message == null || typeof message !== 'object') { + logger.error('Unknown message type: ', String(message)); + return null; + } + if (message.version !== PROTOCOL_VERSION) { + logger.error( + 'Received message had wrong protocol version: ' + String(message.version), + ); + return null; + } + if (message.type !== 'command' || typeof message.command !== 'string') { + logger.error('Unknown message type: ', String(message.type)); + return null; + } + return {command: message.command, params: message.params}; +} + +// Metro events are not guaranteed to be serializable. Errors and the +// arguments of a `client_log` event are formatted first. +function serializeEvent(event: unknown, logger: Logger): ?string { + let toSerialize = event; + if (event != null && typeof event === 'object') { + if (event.error instanceof Error) { + toSerialize = { + ...event, + error: prettyFormat(event.error, { + escapeString: true, + highlight: true, + maxDepth: 3, + min: true, + }), + }; + } else if (event.type === 'client_log' && Array.isArray(event.data)) { + toSerialize = { + ...event, + data: event.data.map(item => + typeof item === 'string' + ? item + : prettyFormat(item, { + escapeString: true, + highlight: true, + maxDepth: 3, + min: true, + plugins: [plugins.ReactElement], + }), + ), + }; + } + } + try { + return JSON.stringify(toSerialize); + } catch (e) { + logger.error('Failed to serialize: ' + String(e)); + return null; + } +} diff --git a/packages/community-cli-plugin/src/dev-server/createMessageSocket.js b/packages/community-cli-plugin/src/dev-server/createMessageSocket.js new file mode 100644 index 000000000000..9fe8558c36c8 --- /dev/null +++ b/packages/community-cli-plugin/src/dev-server/createMessageSocket.js @@ -0,0 +1,267 @@ +/** + * 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 + */ + +import isAllowedOrigin from './isAllowedOrigin'; +import isLocalSocket from './isLocalSocket'; +import WS from 'ws'; + +const PROTOCOL_VERSION = 2; +const CLIENT_BROADCAST_ALLOWED_METHODS: ReadonlySet = new Set([ + 'reload', + 'devMenu', +]); + +export type Logger = Readonly<{ + warn: (...message: ReadonlyArray) => void, + error: (...message: ReadonlyArray) => void, + ... +}>; + +type Params = Readonly<{[string]: unknown}>; + +type ClientMessage = + | Readonly<{kind: 'broadcast', method: string, params: unknown}> + | Readonly<{ + kind: 'request', + method: string, + target: string, + id: unknown, + params: unknown, + }> + | Readonly<{ + kind: 'response', + requestId: unknown, + clientId: string, + result: unknown, + error: unknown, + }>; + +type Client = Readonly<{ + socket: ws$WebSocket, + query: Readonly<{[string]: string}>, +}>; + +type Options = Readonly<{ + logger: Logger, + serverBaseUrl: URL, +}>; + +/** + * The `/message` WebSocket, which connects the React Native apps and tools of + * a dev server. It relays requests and responses between clients and + * broadcasts server commands such as `reload` and `devMenu` to every app. + * + * A client may broadcast `reload` or `devMenu` itself only when it connects + * from this machine with the dev server's own origin or none. + */ +export default function createMessageSocket({logger, serverBaseUrl}: Options): { + server: ws$WebSocketServer, + broadcast: (method: string, params?: Params | null) => void, +} { + const server = new WS.Server({noServer: true}); + const clients = new Map(); + let nextClientId = 0; + + function send(socket: ws$WebSocket, message: Params) { + socket.send(JSON.stringify({...message, version: PROTOCOL_VERSION})); + } + + function getClient(clientId: string): Client { + const client = clients.get(clientId); + if (client == null) { + throw new Error( + `could not find id "${clientId}" while forwarding request`, + ); + } + return client; + } + + function broadcast( + broadcasterId: string | null, + method: string, + params: unknown, + ) { + if (clients.size === 0) { + logger.warn( + `No apps connected. Sending "${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 [clientId, {socket}] of clients) { + if (clientId !== broadcasterId) { + try { + send(socket, {method, params}); + } catch (e) { + logger.error( + `Failed to send broadcast to client: '${clientId}' due to:\n ${String(e)}`, + ); + } + } + } + } + + server.on('connection', (socket, request) => { + const clientId = `client#${nextClientId++}`; + const {origin} = request.headers; + const isTrustedClient = + isLocalSocket(request.socket) && + (origin == null || isAllowedOrigin(origin, serverBaseUrl)); + + clients.set(clientId, { + socket, + query: Object.fromEntries( + new URL(request.url ?? '/', 'http://localhost').searchParams, + ), + }); + + function handleServerRequest(message: ClientMessage) { + if (message.kind !== 'request') { + return; + } + let result: unknown; + switch (message.method) { + case 'getid': + result = clientId; + break; + case 'getpeers': { + const peers: {[string]: Readonly<{[string]: string}>} = {}; + for (const [otherId, {query}] of clients) { + if (otherId !== clientId) { + peers[otherId] = query; + } + } + result = peers; + break; + } + default: + throw new Error(`unknown method: ${message.method}`); + } + send(socket, {result, id: message.id}); + } + + function handleMessage(message: ClientMessage) { + switch (message.kind) { + case 'broadcast': + if ( + isTrustedClient && + CLIENT_BROADCAST_ALLOWED_METHODS.has(message.method) + ) { + broadcast(clientId, message.method, message.params); + } + break; + case 'request': + if (message.target === 'server') { + handleServerRequest(message); + } else { + send(getClient(message.target).socket, { + method: message.method, + params: message.params, + id: + message.id === undefined + ? undefined + : {requestId: message.id, clientId}, + }); + } + break; + case 'response': + send(getClient(message.clientId).socket, { + result: message.result, + error: message.error, + id: message.requestId, + }); + break; + } + } + + function removeClient() { + socket.removeAllListeners('message'); + clients.delete(clientId); + } + socket.on('close', removeClient); + socket.on('error', removeClient); + + socket.on('message', data => { + const message = parseClientMessage(data, logger); + if (message == null) { + return; + } + try { + handleMessage(message); + } catch (e) { + if (message.kind === 'request' && message.id !== undefined) { + try { + send(socket, {error: String(e), id: message.id}); + } catch (replyError) { + logger.error( + `Failed to reply to ${clientId} with error:\n${String(e)}\ndue to error: ${String(replyError)}`, + ); + } + } else { + logger.error( + `Handling message from ${clientId} failed with:\n${String(e)}`, + ); + } + } + }); + }); + + return { + server, + broadcast: (method, params) => broadcast(null, method, params), + }; +} + +function parseClientMessage(data: unknown, logger: Logger): ?ClientMessage { + if (typeof data !== 'string') { + logger.error('Expected text message, got binary!'); + return null; + } + let message; + try { + message = JSON.parse(data); + } catch { + logger.error(`Failed to parse the message as JSON:\n${data}`); + return null; + } + if (message == null || typeof message !== 'object') { + logger.error('Received message not matching protocol'); + return null; + } + if (message.version !== PROTOCOL_VERSION) { + logger.error( + `Received message had wrong protocol version: ${String(message.version)}`, + ); + return null; + } + + const {method, target, id, params, result, error} = message; + if (typeof method === 'string' && id === undefined && target === undefined) { + return {kind: 'broadcast', method, params}; + } + if (typeof method === 'string' && typeof target === 'string') { + return {kind: 'request', method, target, id, params}; + } + if ( + id != null && + typeof id === 'object' && + id.requestId !== undefined && + typeof id.clientId === 'string' && + (result !== undefined || error !== undefined) + ) { + return { + kind: 'response', + requestId: id.requestId, + clientId: id.clientId, + result, + error, + }; + } + logger.error('Received message not matching protocol'); + return null; +} diff --git a/packages/community-cli-plugin/src/dev-server/isLocalSocket.js b/packages/community-cli-plugin/src/dev-server/isLocalSocket.js new file mode 100644 index 000000000000..db84d277cf72 --- /dev/null +++ b/packages/community-cli-plugin/src/dev-server/isLocalSocket.js @@ -0,0 +1,34 @@ +/** + * 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 + */ + +const IPV4_MAPPED_IPV6_PREFIX = '::ffff:'; + +/** + * Whether a connection comes from the machine the dev server runs on. + */ +export default function isLocalSocket(socket: net$Socket): boolean { + let {localAddress, remoteAddress, remoteFamily} = socket; + + if (localAddress != null && localAddress === remoteAddress) { + return true; + } + if (remoteAddress == null || remoteFamily == null) { + return false; + } + + if ( + remoteFamily === 'IPv6' && + remoteAddress.startsWith(IPV4_MAPPED_IPV6_PREFIX) + ) { + remoteAddress = remoteAddress.slice(IPV4_MAPPED_IPV6_PREFIX.length); + } + + return remoteAddress === '::1' || remoteAddress.startsWith('127.'); +} diff --git a/packages/community-cli-plugin/src/dev-server/loadCommunityMiddleware.js b/packages/community-cli-plugin/src/dev-server/loadCommunityMiddleware.js index 8e39424aeb58..1406ed7d1eec 100644 --- a/packages/community-cli-plugin/src/dev-server/loadCommunityMiddleware.js +++ b/packages/community-cli-plugin/src/dev-server/loadCommunityMiddleware.js @@ -9,7 +9,6 @@ */ import type {Server} from 'connect'; -import type {TerminalReportableEvent} from 'metro'; type DevServerMiddlewareFactory = (params: { host?: string, @@ -18,43 +17,15 @@ type DevServerMiddlewareFactory = (params: { }) => { middleware: Server, websocketEndpoints: {[path: string]: ws$WebSocketServer}, - messageSocketEndpoint: { - server: ws$WebSocketServer, - broadcast: ( - method: string, - params?: Record | null, - ) => void, - }, - eventsSocketEndpoint: { - server: ws$WebSocketServer, - reportEvent: (event: TerminalReportableEvent) => void, - }, ... }; -// $FlowFixMe[incompatible-type] -const unusedStubWSServer: ws$WebSocketServer = {}; // $FlowFixMe[incompatible-type] const unusedMiddlewareStub: Server = {}; -// FIXME: Several features will break without community middleware -// (@react-native-community/cli-server-api) and should be migrated into core. -// e.g. used by packages/react-native/Libraries/Core/Devtools/: -// - /symbolicate const communityMiddlewareFallback: DevServerMiddlewareFactory = () => ({ middleware: unusedMiddlewareStub, websocketEndpoints: {}, - messageSocketEndpoint: { - server: unusedStubWSServer, - broadcast: ( - method: string, - _params?: Record | null, - ): void => {}, - }, - eventsSocketEndpoint: { - server: unusedStubWSServer, - reportEvent: (event: TerminalReportableEvent) => {}, - }, }); /** diff --git a/packages/community-cli-plugin/src/dev-server/runDevServer.js b/packages/community-cli-plugin/src/dev-server/runDevServer.js index b19e6079eb8e..c0552eb6b22b 100644 --- a/packages/community-cli-plugin/src/dev-server/runDevServer.js +++ b/packages/community-cli-plugin/src/dev-server/runDevServer.js @@ -103,7 +103,6 @@ async function runDevServer( metroConfig.server.forwardClientLogs = false; } - let reportEvent: (event: TerminalReportableEvent) => void; const terminal = new Metro.Terminal(process.stdout); const ReporterImpl = getReporterImpl(args.customLogReporterPath); const terminalReporter = new ReporterImpl(terminal); @@ -112,31 +111,32 @@ async function runDevServer( const { middleware: communityMiddleware, websocketEndpoints: communityWebsocketEndpoints, - messageSocketEndpoint, - eventsSocketEndpoint, } = createCommunityMiddleware({ host: hostname, port, watchFolders, }); + const logger = createDevMiddlewareLogger(terminalReporter); const {middleware, websocketEndpoints} = createDevMiddleware({ serverBaseUrl: devServerUrl, - logger: createDevMiddlewareLogger(terminalReporter), + logger, }); - const pluginMiddleware = createDevServerMiddleware({ + const { + middleware: pluginMiddleware, + websocketEndpoints: pluginWebsocketEndpoints, + messageSocket, + eventsSocket, + } = createDevServerMiddleware({ serverBaseUrl: new URL(devServerUrl), projectRoot, watchFolders: [unstable_serverRoot ?? projectRoot, ...watchFolders], + logger, }); const reporter: Reporter = { update(event: TerminalReportableEvent) { terminalReporter.update(event); - /* $FlowFixMe[constant-condition] Error discovered during Constant - * Condition roll out. See https://fburl.com/workplace/1v97vimq. */ - if (reportEvent) { - reportEvent(event); - } + eventsSocket.reportEvent(event); if (args.interactive && event.type === 'initialize_done') { terminalReporter.update({ type: 'unstable_server_log', @@ -145,7 +145,7 @@ async function runDevServer( }); attachKeyHandlers({ devServerUrl, - messageSocket: messageSocketEndpoint, + messageSocket, reporter: terminalReporter, }); } @@ -166,12 +166,11 @@ async function runDevServer( ], websocketEndpoints: { ...communityWebsocketEndpoints, + ...pluginWebsocketEndpoints, ...websocketEndpoints, }, }); - reportEvent = eventsSocketEndpoint.reportEvent; - await version.logIfUpdateAvailable(cliConfig, terminalReporter); }