From c2ff5cf6d88b5060c05db32d9e5376dc61531576 Mon Sep 17 00:00:00 2001 From: Vitali Zaidman Date: Thu, 15 Oct 2026 07:45:15 -0700 Subject: [PATCH] Serve server.unstable_websocketEndpoints from config Summary: Part of a stack that replaces `server.enhanceMiddleware` with declarative dev server extension points. The previous change adds `server.unstable_middleware` and `server.unstable_priorityMiddleware`; this one adds their websocket counterpart, `server.unstable_websocketEndpoints`, so apps and config wrappers can register websocket endpoints in `metro.config.js`. ### Why **Deprecation** - The [2023 deprecation of `server.enhanceMiddleware`](https://github.com/facebook/metro/commit/22e85fde85ec454792a1b70eba4253747a2587a9) argued that dev server extensions belong with the tools that start Metro, configured through `runServer`, rather than in Metro's config. As the previous change in this stack explains, that assumed whoever adds a handler controls the `runServer` call, which apps and config wrappers don't. The same holds for websocket endpoints, which so far could only be passed to `runServer`. **Current State** Two different parties add websocket endpoints to Metro's server, and each controls a different entry point: - Frameworks (RN Community CLI, Expo CLI) start the server, and pass the endpoints every app needs, such as React Native's debugger sockets, to `runServer`'s `websocketEndpoints` option. - Apps, and libraries that ship config wrappers, never call `runServer`. `metro.config.js` is all they control. An app composes the wrappers of the libraries it uses: ``` // my-lib/metro.js exports.withMyLib = config => ({ ...config, server: {...config.server /* , my-lib's handlers */}, }); // metro.config.js const {getDefaultConfig} = require('expo/metro-config'); const {withNativeWind} = require('nativewind/metro'); const {withMyLib} = require('my-lib/metro'); module.exports = withMyLib( withNativeWind(getDefaultConfig(__dirname), {input: './global.css'}), ); ``` - So Metro needs the option in both places, combined by Metro, so each party can add endpoints without knowing about the other. - `metro.config.js` has no websocket option, so today apps and wrappers have to patch the framework. For example, internally we patch Expo CLI's `instantiateMetro.js` (details under Internal below) so it serves endpoints from a config field: ``` if (metroConfig.server.unstable_websocketEndpoints) { Object.assign(websocketEndpoints, metroConfig.server.unstable_websocketEndpoints); } ``` - HTTP middleware and websocket endpoints are naturally added together (`react-native/dev-middleware`'s `createDevMiddleware` returns both). With the previous change, the HTTP half can come from config; this change adds the websocket half. **Why a map keyed by path** Endpoints are matched by exact path, so unlike middleware there is no run order to preserve: combining two sets of endpoints is a plain object merge, and `mergeConfig` does it for you. For the same path, the outer layer wins, as with middleware: `runServer`'s `websocketEndpoints` win over the config's, and a later config wins over an earlier one in `mergeConfig`. `/hot` is reserved for Metro. ``` config.server.unstable_websocketEndpoints = {...config.server.unstable_websocketEndpoints, '/my-ws': wss}; // same result: mergeConfig(config, {server: {unstable_websocketEndpoints: {'/my-ws': wss}}}); ``` **API** - `server.unstable_websocketEndpoints: {[path]: WebSocketServer}` (servers created with `noServer: true`), defaulting to `{}`. - `createConnectMiddleware`'s `attachHmrServer` serves the config's endpoints alongside Metro's `/hot`, for frameworks that build their own HTTP server on `createConnectMiddleware`. Expo CLI's `runServer` fork attaches `/hot` itself instead of calling `attachHmrServer`, so it still needs a change to pick them up. - `mergeConfig` combines `server.unstable_websocketEndpoints` from both configs instead of replacing them. For the same path, the later config's server wins. A config that leaves the field out, or sets it to `undefined`, keeps the other config's endpoints. - `runServer` merges its `websocketEndpoints` option into the config it passes to `createConnectMiddleware`, so `attachHmrServer` serves both. For the same path, `runServer`'s win over the config's. `createConnectMiddleware`'s API is unchanged. - `/hot` stays reserved for Metro's hot reloading. A supplied `/hot` is still ignored, as before, but Metro now warns about it instead of dropping it silently. - The field is excluded from config validation's recursive key check, so endpoint paths aren't reported as unknown options. - `runServer` calls `attachHmrServer` instead of attaching its own upgrade handler. The upgrade handling is factored into `attachWebsocketEndpoints`, which merges the endpoint maps by precedence: earlier maps win, with Metro's `/hot` first. Changelog: * **[Feature]:** Add `server.unstable_websocketEndpoints` config option to register websocket endpoints, served by `runServer` and by `createConnectMiddleware`'s `attachHmrServer`. `mergeConfig` combines it from both configs. * **[Feature]:** Metro warns when a supplied `/hot` websocket endpoint is ignored. Previously it was dropped silently. Differential Revision: D123479780 --- docs/API.md | 4 +- docs/Configuration.md | 12 +- packages/metro-config/API.md | 2 + .../src/__tests__/mergeConfig-test.js | 65 +++++++++ packages/metro-config/src/defaults/index.js | 1 + packages/metro-config/src/loadConfig.js | 21 ++- packages/metro-config/src/types.js | 4 + packages/metro/API.md | 12 +- packages/metro/src/index.flow.js | 124 +++++++++++------- .../__tests__/server-extraMiddleware-test.js | 75 +++++++++++ 10 files changed, 258 insertions(+), 62 deletions(-) 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(