Skip to content

Serve server.unstable_websocketEndpoints from config - #2072

Open
vzaidman wants to merge 1 commit into
mainfrom
export-D123479780
Open

vzaidman wants to merge 1 commit into
mainfrom
export-D123479780

Conversation

@vzaidman

@vzaidman vzaidman commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Following #2031 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 for completion, so apps and config wrappers can register websocket endpoints in metro.config.js.

Why

Deprecation

  • The 2023 deprecation of server.enhanceMiddleware 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

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`](22e85fd) 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
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 15, 2026
@meta-codesync

meta-codesync Bot commented Oct 15, 2026

Copy link
Copy Markdown
Contributor

@vzaidman has exported this pull request. If you are a Meta employee, you can view the originating Diff in D123479780.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. meta-exported

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant