diff --git a/docs/API.md b/docs/API.md index f83f30d767..4c14fe1887 100644 --- a/docs/API.md +++ b/docs/API.md @@ -66,11 +66,11 @@ Bundles `entry` for the given `platform`, and saves it to location `out`. If `so ### `async runServer(config, )` -**Basic options:** `host`, `port`, `secureServerOptions`, `unstable_middleware`, `unstable_priorityMiddleware`, `unstable_onServerCreated`, `secure (DEPRECATED)`, `secureKey (DEPRECATED)`, `secureCert (DEPRECATED)` +**Basic options:** `host`, `port`, `secureServerOptions`, `unstable_middleware`, `unstable_priorityMiddleware`, `websocketEndpoints`, `unstable_onServerCreated`, `secure (DEPRECATED)`, `secureKey (DEPRECATED)`, `secureCert (DEPRECATED)` Starts a full Metro HTTP server. It will listen on the specified `host:port`, and can then be queried to retrieve bundles for various entry points. If the `secureServerOptions` family of options are present, the server will be exposed over HTTPS. -`unstable_middleware` and `unstable_priorityMiddleware` take the same shapes as [`server.unstable_middleware`](./Configuration.md#unstable_middleware-experimental) and [`server.unstable_priorityMiddleware`](./Configuration.md#unstable_prioritymiddleware-experimental), and wrap them: `unstable_middleware` passed here runs after the config's, and `unstable_priorityMiddleware` runs before the config's. +`unstable_middleware`, `unstable_priorityMiddleware` and `websocketEndpoints` take the same shapes as [`server.unstable_middleware`](./Configuration.md#unstable_middleware-experimental), [`server.unstable_priorityMiddleware`](./Configuration.md#unstable_prioritymiddleware-experimental) and [`server.unstable_websocketEndpoints`](./Configuration.md#unstable_websocketendpoints-experimental), and wrap them: `unstable_middleware` passed here runs after the config's, and `unstable_priorityMiddleware` runs before the config's. For the same websocket path, `websocketEndpoints` passed here win over those from the config. [`/hot`](./Configuration.md#unstable_websocketendpoints-experimental) is reserved for Metro. Priority middleware handlers must call `next()` for every request they don't fully handle, so that Metro can serve bundles, source maps, assets and its other endpoints. diff --git a/docs/Configuration.md b/docs/Configuration.md index a0797d3cd6..af397c7800 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -741,6 +741,16 @@ Called with Metro's server instance when [`runServer`](./API.md#async-runserverc `runServer` also takes an `unstable_onServerCreated` option, which is called after this one. +#### `unstable_websocketEndpoints`
Experimental
+ +Type: `{[path: string]: WebSocketServer}` + +[`ws`](https://www.npmjs.com/package/ws) servers, created with `noServer: true`, to handle WebSocket upgrades on the given paths. + +`/hot` is reserved for Metro's hot reloading. A supplied `/hot` endpoint is ignored, with a warning. + +[`runServer`](./API.md#async-runserverconfig-options) also takes a `websocketEndpoints` option, which wins over these for the same path. + #### `rewriteRequestUrl` Type: `string => string` @@ -868,7 +878,7 @@ This allows overriding and removing default config parameters such as `platforms ::: -`server.unstable_middleware` and `server.unstable_priorityMiddleware` are combined instead: later configs wrap earlier ones, so their `unstable_middleware` runs last and their `unstable_priorityMiddleware` runs first. An entry already in an earlier config, e.g. copied with a spread, is kept once. +`server.unstable_middleware` and `server.unstable_priorityMiddleware` are combined instead: later configs wrap earlier ones, so their `unstable_middleware` runs last and their `unstable_priorityMiddleware` runs first. An entry already in an earlier config, e.g. copied with a spread, is kept once. `server.unstable_websocketEndpoints` are combined too; for the same path, a later config's server wins. #### Merging Example diff --git a/packages/metro-config/API.md b/packages/metro-config/API.md index 3df6adb4e7..d04f74b925 100644 --- a/packages/metro-config/API.md +++ b/packages/metro-config/API.md @@ -8,6 +8,7 @@ import EventEmitter from 'node:events'; import type {HandleFunction} from 'connect'; import type {IncomingMessage} from 'connect'; import type {Server as Server_2} from 'connect'; +import type {Server as Server_3} from 'ws'; import type {ServerResponse} from 'node:http'; export type CacheStoresConfigT = ReadonlyArray>; @@ -191,6 +192,7 @@ export type ServerConfigT = { unstable_onServerCreated: (metroServer: Server) => void; unstable_priorityMiddleware: ReadonlyArray; unstable_serverRoot: null | undefined | string; + unstable_websocketEndpoints: Readonly<{[path: string]: Server_3}>; useGlobalHotkey: boolean; verifyConnections: boolean; tls: diff --git a/packages/metro-config/src/__tests__/mergeConfig-test.js b/packages/metro-config/src/__tests__/mergeConfig-test.js index 89a7121ed5..490b51d6a6 100644 --- a/packages/metro-config/src/__tests__/mergeConfig-test.js +++ b/packages/metro-config/src/__tests__/mergeConfig-test.js @@ -11,6 +11,7 @@ import type {InputConfigT, ServerMiddleware} from '../types'; import type {CustomResolver} from 'metro-resolver'; +import type {Server as WebSocketServer} from 'ws'; import {mergeConfig} from '../loadConfig'; import path from 'node:path'; @@ -235,6 +236,70 @@ describe('mergeConfig', () => { }); }); + describe('server websocket endpoint merging', () => { + const a: WebSocketServer = {} as $FlowFixMe; + const b: WebSocketServer = {} as $FlowFixMe; + + test('combines endpoints from both configs', () => { + const result = mergeConfig( + {server: {unstable_websocketEndpoints: {'/a': a}}}, + {server: {unstable_websocketEndpoints: {'/b': b}}}, + ); + expect(result.server?.unstable_websocketEndpoints).toEqual({ + '/a': a, + '/b': b, + }); + }); + + test('keeps endpoints defined by only one config', () => { + const withA: InputConfigT = { + server: {unstable_websocketEndpoints: {'/a': a}}, + }; + const withB: InputConfigT = { + server: {unstable_websocketEndpoints: {'/b': b}}, + }; + const empty: InputConfigT = {server: {}}; + expect( + mergeConfig(withA, empty).server?.unstable_websocketEndpoints, + ).toEqual({'/a': a}); + expect( + mergeConfig(empty, withB).server?.unstable_websocketEndpoints, + ).toEqual({'/b': b}); + }); + + test('keeps base endpoints when the override sets them to undefined', () => { + const withA: InputConfigT = { + server: {unstable_websocketEndpoints: {'/a': a}}, + }; + const explicitUndefined: InputConfigT = { + server: {unstable_websocketEndpoints: undefined}, + }; + expect( + mergeConfig(withA, explicitUndefined).server + ?.unstable_websocketEndpoints, + ).toEqual({'/a': a}); + }); + + test('keeps an endpoint copied from the base config', () => { + const result = mergeConfig( + {server: {unstable_websocketEndpoints: {'/a': a}}}, + {server: {unstable_websocketEndpoints: {'/a': a, '/b': b}}}, + ); + expect(result.server?.unstable_websocketEndpoints).toEqual({ + '/a': a, + '/b': b, + }); + }); + + test('a later config wins for the same path', () => { + const result = mergeConfig( + {server: {unstable_websocketEndpoints: {'/a': a}}}, + {server: {unstable_websocketEndpoints: {'/a': b}}}, + ); + expect(result.server?.unstable_websocketEndpoints).toEqual({'/a': b}); + }); + }); + describe('resolver path resolution', () => { // `resolve()` maps a module specifier to an absolute path, relative to // metro-config. Without it, these paths are later `require`d from diff --git a/packages/metro-config/src/defaults/index.js b/packages/metro-config/src/defaults/index.js index 8c4a51a945..b79e0f9247 100644 --- a/packages/metro-config/src/defaults/index.js +++ b/packages/metro-config/src/defaults/index.js @@ -87,6 +87,7 @@ const getDefaultValues = (projectRoot: ?string): ConfigT => ({ // merged with user middleware as [...user, ...defaults] unstable_priorityMiddleware: [], unstable_serverRoot: null, + unstable_websocketEndpoints: {}, useGlobalHotkey: true, verifyConnections: false, tls: false, diff --git a/packages/metro-config/src/loadConfig.js b/packages/metro-config/src/loadConfig.js index 59c4b0e298..69819740f2 100644 --- a/packages/metro-config/src/loadConfig.js +++ b/packages/metro-config/src/loadConfig.js @@ -15,6 +15,7 @@ import type { ServerMiddleware, YargArguments, } from './types'; +import type {Server as WebSocketServer} from 'ws'; import getDefaultConfig from './defaults'; import validConfig from './defaults/validConfig'; @@ -201,18 +202,21 @@ function mergeConfigObjects( }; } -// Later configs wrap earlier ones: their `unstable_middleware` runs last and -// their `unstable_priorityMiddleware` runs first. +// Later configs wrap earlier ones: their `unstable_middleware` runs last, their +// `unstable_priorityMiddleware` runs first, and their websocket endpoints take +// precedence. function mergeServerHandlers( base: InputConfigT['server'], overrides: InputConfigT['server'], ): { unstable_middleware?: ReadonlyArray, unstable_priorityMiddleware?: ReadonlyArray, + unstable_websocketEndpoints?: Readonly<{[path: string]: WebSocketServer}>, } { const merged: { unstable_middleware?: ReadonlyArray, unstable_priorityMiddleware?: ReadonlyArray, + unstable_websocketEndpoints?: Readonly<{[path: string]: WebSocketServer}>, } = {}; const middleware = concatUnique( base?.unstable_middleware, @@ -228,6 +232,12 @@ function mergeServerHandlers( if (priorityMiddleware != null) { merged.unstable_priorityMiddleware = priorityMiddleware; } + const baseEndpoints = base?.unstable_websocketEndpoints; + const overrideEndpoints = overrides?.unstable_websocketEndpoints; + if (baseEndpoints != null || overrideEndpoints != null) { + const fromBase: {[path: string]: WebSocketServer} = {...baseEndpoints}; + merged.unstable_websocketEndpoints = {...fromBase, ...overrideEndpoints}; + } return merged; } @@ -425,7 +435,12 @@ async function loadConfig( validate(configuration, { exampleConfig: await validConfig(), - recursiveDenylist: ['reporter', 'resolver', 'transformer'], + recursiveDenylist: [ + 'reporter', + 'resolver', + 'transformer', + 'server.unstable_websocketEndpoints', + ], deprecatedConfig: {}, }); diff --git a/packages/metro-config/src/types.js b/packages/metro-config/src/types.js index dbc936bdf2..bff947e3e5 100644 --- a/packages/metro-config/src/types.js +++ b/packages/metro-config/src/types.js @@ -24,6 +24,7 @@ import type { import type {Reporter} from 'metro/private/lib/reporting'; import type MetroServer from 'metro/private/Server'; import type {IntermediateStackFrame} from 'metro/private/Server/symbolicate'; +import type {Server as WebSocketServer} from 'ws'; export type ExtraTransformOptions = Readonly<{ preloadedModules?: Readonly<{[path: string]: true, ...}> | false, @@ -209,6 +210,9 @@ type ServerConfigT = { // handlers. unstable_priorityMiddleware: ReadonlyArray, unstable_serverRoot: ?string, + // `runServer`'s `websocketEndpoints` option wins over these for the same + // path. + unstable_websocketEndpoints: Readonly<{[path: string]: WebSocketServer}>, useGlobalHotkey: boolean, verifyConnections: boolean, tls: diff --git a/packages/metro/API.md b/packages/metro/API.md index b4afc4df23..570388f9a3 100644 --- a/packages/metro/API.md +++ b/packages/metro/API.md @@ -9,9 +9,9 @@ import EventEmitter from 'node:events'; import type {HandleFunction} from 'connect'; import type {IncomingMessage} from 'connect'; import type {Server} from 'connect'; -import type {Server as Server_2} from 'node:http'; -import type {Server as Server_3} from 'node:https'; -import type {Server as Server_4} from 'ws'; +import type {Server as Server_2} from 'ws'; +import type {Server as Server_3} from 'node:http'; +import type {Server as Server_4} from 'node:https'; import type {ServerOptions as ServerOptions_2} from 'node:https'; import type {ServerResponse} from 'node:http'; import type {Socket} from 'node:net'; @@ -273,7 +273,7 @@ export type RunServerOptions = Readonly<{ hasReducedPerformance?: boolean | undefined; host?: string | undefined; onError?: ((err: Error & {code?: string | undefined}) => void) | undefined; - onReady?: ((server: Server_2 | Server_3) => void) | undefined; + onReady?: ((server: Server_3 | Server_4) => void) | undefined; onClose?: (() => void) | undefined; secureServerOptions?: ServerOptions_2 | undefined; secure?: boolean | undefined; @@ -285,10 +285,10 @@ export type RunServerOptions = Readonly<{ unstable_priorityMiddleware?: ReadonlyArray | undefined; waitForBundler?: boolean | undefined; watch?: boolean | undefined; - websocketEndpoints?: Readonly<{[path: string]: Server_4}> | undefined; + websocketEndpoints?: Readonly<{[path: string]: Server_2}> | undefined; }>; -export type RunServerResult = {httpServer: Server_2 | Server_3}; +export type RunServerResult = {httpServer: Server_3 | Server_4}; export type SerializerOptions = Readonly<{ asyncRequireModulePath: string; diff --git a/packages/metro/src/index.flow.js b/packages/metro/src/index.flow.js index a580ef8dc0..0aa437edcc 100644 --- a/packages/metro/src/index.flow.js +++ b/packages/metro/src/index.flow.js @@ -91,6 +91,7 @@ export type RunServerOptions = Readonly<{ unstable_priorityMiddleware?: ReadonlyArray, waitForBundler?: boolean, watch?: boolean, + // Wins over `config.server.unstable_websocketEndpoints` for the same path. websocketEndpoints?: Readonly<{ [path: string]: WebSocketServer, }>, @@ -265,23 +266,26 @@ export const createConnectMiddleware = async function ( return { attachHmrServer(httpServer: HttpServer | HttpsServer): void { - const wss = createWebsocketServer({ - websocketServer: new MetroHmrServer( - metroServer.getBundler(), - metroServer.getCreateModuleId(), - config, - ), - }); - httpServer.on('upgrade', (request, socket, head) => { - const {pathname} = new URL(request.url, 'resolve://'); - if (pathname === '/hot') { - wss.handleUpgrade(request, socket, head, ws => { - wss.emit('connection', ws, request); - }); - } else { - socket.destroy(); - } - }); + if (config.server.unstable_websocketEndpoints['/hot'] != null) { + logWarning( + 'WARNING', + 'The supplied `/hot` websocket endpoint is ignored: `/hot` is ' + + "reserved for Metro's hot reloading.", + ); + } + attachWebsocketEndpoints( + httpServer, + { + '/hot': createWebsocketServer({ + websocketServer: new MetroHmrServer( + metroServer.getBundler(), + metroServer.getCreateModuleId(), + config, + ), + }), + }, + config.server.unstable_websocketEndpoints, + ); }, metroServer, middleware: composeMiddleware( @@ -314,7 +318,7 @@ export const runServer = async ( unstable_onServerCreated, unstable_priorityMiddleware = [], waitForBundler = false, - websocketEndpoints: userWebsocketEndpoints = {}, + websocketEndpoints = {}, watch, } = opts; await earlyPortCheck(host, config.server.port); @@ -327,14 +331,28 @@ export const runServer = async ( ); } const { + attachHmrServer, middleware, end: endMiddleware, metroServer, - } = await createConnectMiddleware(config, { - hasReducedPerformance, - waitForBundler, - watch, - }); + } = await createConnectMiddleware( + { + ...config, + server: { + ...config.server, + // `runServer`'s endpoints win over the config's for the same path. + unstable_websocketEndpoints: { + ...config.server.unstable_websocketEndpoints, + ...websocketEndpoints, + }, + }, + }, + { + hasReducedPerformance, + waitForBundler, + watch, + }, + ); unstable_onServerCreated?.(metroServer); @@ -375,6 +393,7 @@ export const runServer = async ( } else { httpServer = http.createServer(serverApp); } + return new Promise((resolve, reject) => { httpServer.on('error', error => { endMiddleware().finally(() => { @@ -392,32 +411,7 @@ export const runServer = async ( family, }); - const websocketEndpoints = { - ...userWebsocketEndpoints, - '/hot': createWebsocketServer({ - websocketServer: new MetroHmrServer( - metroServer.getBundler(), - metroServer.getCreateModuleId(), - config, - ), - }), - }; - - httpServer.on('upgrade', (request, socket, head) => { - const {pathname} = new URL(request.url, 'resolve://'); - if (pathname != null && websocketEndpoints[pathname]) { - websocketEndpoints[pathname].handleUpgrade( - request, - socket, - head, - ws => { - websocketEndpoints[pathname].emit('connection', ws, request); - }, - ); - } else { - socket.destroy(); - } - }); + attachHmrServer(httpServer); if (onReady) { onReady(httpServer); @@ -610,9 +604,13 @@ async function earlyPortCheck(host: void | string, port: number) { } function logDeprecationWarning(message: string): void { + logWarning('DEPRECATED', message); +} + +function logWarning(label: string, message: string): void { // eslint-disable-next-line no-console console.warn( - util.styleText(['inverse', 'yellow', 'bold'], ' DEPRECATED '), + util.styleText(['inverse', 'yellow', 'bold'], ` ${label} `), message, ); } @@ -635,3 +633,29 @@ function composeMiddleware( } return app; } + +// Earlier maps take precedence for the same path. +function attachWebsocketEndpoints( + httpServer: HttpServer | HttpsServer, + ...endpointMaps: ReadonlyArray> +): void { + const websocketEndpoints: {[path: string]: WebSocketServer} = {}; + for (const endpointMap of endpointMaps) { + for (const [path, server] of Object.entries(endpointMap)) { + if (websocketEndpoints[path] == null) { + websocketEndpoints[path] = server; + } + } + } + httpServer.on('upgrade', (request, socket, head) => { + const {pathname} = new URL(request.url, 'resolve://'); + const endpoint = pathname != null ? websocketEndpoints[pathname] : null; + if (endpoint == null) { + socket.destroy(); + return; + } + endpoint.handleUpgrade(request, socket, head, ws => { + endpoint.emit('connection', ws, request); + }); + }); +} diff --git a/packages/metro/src/integration_tests/__tests__/server-extraMiddleware-test.js b/packages/metro/src/integration_tests/__tests__/server-extraMiddleware-test.js index b8ff935dd8..1fa1c6cfb8 100644 --- a/packages/metro/src/integration_tests/__tests__/server-extraMiddleware-test.js +++ b/packages/metro/src/integration_tests/__tests__/server-extraMiddleware-test.js @@ -12,6 +12,7 @@ const Metro = require('../../..'); const MetroServer = require('../../Server').default; +const WebSocket = require('ws'); jest.useRealTimers(); jest.setTimeout(60 * 1000); @@ -134,6 +135,80 @@ describe('runServer custom middleware', () => { expect((await request(BUNDLE_PATH)).status).toBe(200); }); + const greetingServer = (greeting: string) => { + const wss = new WebSocket.Server({noServer: true}); + wss.on('connection', socket => socket.send(greeting)); + return wss; + }; + + const receiveGreeting = (pathname: string): Promise => + new Promise((resolve, reject) => { + const ws = new WebSocket( + `ws://localhost:${httpServer.address().port}${pathname}`, + ); + ws.on('message', data => { + ws.close(); + resolve(String(data)); + }); + ws.on('error', reject); + }); + + test('serves config websocket endpoints', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const wss = greetingServer('hello'); + await startServer( + await loadConfig({unstable_websocketEndpoints: {'/custom-ws': wss}}), + ); + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + + expect(await receiveGreeting('/custom-ws')).toBe('hello'); + wss.close(); + }); + + test('runServer websocket endpoints win over config ones', async () => { + const configWss = greetingServer('config'); + const frameworkWss = greetingServer('framework'); + await startServer( + await loadConfig({ + unstable_websocketEndpoints: {'/custom-ws': configWss}, + }), + {websocketEndpoints: {'/custom-ws': frameworkWss}}, + ); + + expect(await receiveGreeting('/custom-ws')).toBe('framework'); + configWss.close(); + frameworkWss.close(); + }); + + test('a supplied /hot is ignored with a warning', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const wss = new WebSocket.Server({noServer: true}); + const onConnection = jest.fn(); + wss.on('connection', onConnection); + await startServer( + await loadConfig({unstable_websocketEndpoints: {'/hot': wss}}), + ); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('WARNING'), + expect.stringContaining('`/hot`'), + ); + warn.mockRestore(); + await new Promise((resolve, reject) => { + const ws = new WebSocket( + `ws://localhost:${httpServer.address().port}/hot`, + ); + ws.on('open', () => { + ws.close(); + resolve(); + }); + ws.on('error', reject); + }); + expect(onConnection).not.toHaveBeenCalled(); + wss.close(); + }); + test('calls config, then runServer unstable_onServerCreated, before listening', async () => { const events = []; await startServer(