Repository navigation
Conversation
Contributor
|
@vzaidman has exported this pull request. If you are a Meta employee, you can view the originating Diff in D123474267. |
huntie
reviewed
Oct 8, 2026
meta-codesync Bot
pushed a commit
that referenced
this pull request
Oct 8, 2026
Summary: Revisits the [2023 deprecation of `server.enhanceMiddleware`](22e85fd). Replaces `server.enhanceMiddleware` with two declarative middleware lists, one before Metro and one after it, accepted both in `metro.config.js` and by `runServer`, and `unstable_onServerCreated`, which receives Metro's server instance before the HTTP server starts listening (after the bundler is ready when `waitForBundler` is set). ### Why **Deprecation** - The deprecation argued that middleware belongs with the tools that start Metro, configured through `runServer`, rather than in Metro's config. That assumed whoever adds a handler controls the `runServer` call. As robhogan pointed out at the time, apps and config wrappers don't; only frameworks do. The deprecation removed their only hook without giving them a replacement, so three years later they still use `enhanceMiddleware`, and so does Expo CLI itself (see below). **Current State** Two different parties add handlers to Metro's server, and each controls a different entry point: - Frameworks (RN Community CLI, Expo CLI) start the server by calling `runServer` or `createConnectMiddleware`, and pass in the handlers every app needs, such as React Native's dev middleware. - 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, applied in a fixed order (framework handlers wrap config handlers), so each party can add handlers without knowing about the other. - The replacement, `runServer`'s `unstable_extraMiddleware`, only reaches frameworks and only works for them. So `enhanceMiddleware` stayed the only hook for most users, and Expo CLI itself still relies on it, with a [TODO](https://github.com/expo/expo/blob/d69fb576152d9e01afe013ae0d3e6962651ac871/packages/%40expo/cli/src/start/server/metro/instantiateMetro.ts#L425-L437) acknowledging it. Expo builds its own middleware stack (CORS, debugger, JS inspector), and uses the deprecated hook to mount Metro's middleware at the end of it. It overwrites the app's `enhanceMiddleware` with a wrapper that runs the app's hook first: ``` // TODO(cedric): `enhanceMiddleware` is deprecated, but is currently used to unify the middleware stacks const customEnhanceMiddleware = metroConfig.server.enhanceMiddleware; metroConfig.server.enhanceMiddleware = (metroMiddleware, server) => { if (customEnhanceMiddleware) { metroMiddleware = customEnhanceMiddleware(metroMiddleware, server); } return middleware.use(metroMiddleware); // Expo's stack, with Metro mounted last }; ``` So the deprecated hook couldn't be removed without breaking Expo, and an app's handlers could only run between Expo's stack and Metro. - Function-shaped hooks don't compose well. Every contributor has to capture and call the previous `enhanceMiddleware`. Of 15 internal apps we scanned, 6 overwrote it by accident, removing some endpoints (internal details below). Also, 4 libraries required an undeclared `connect` just to mount a path. e.g: ``` const prev = config.server.enhanceMiddleware; config.server.enhanceMiddleware = (metroMiddleware, metroServer) => require('connect')() .use('/my-endpoint', myHandler) .use(prev ? prev(metroMiddleware, metroServer) : metroMiddleware); ``` Passing `metroMiddleware` instead of the `prev(...)` call still works locally, but drops everything `prev` installed. - Neither `metro.config.js` nor `runServer`'s options allowed access to Metro's server instance besides `enhanceMiddleware`, which was abused as a lifecycle callback in a very dirty way. For example, internally, `fb-metro-cli` was using this hack: ``` let observerInstalled = false; config.server.enhanceMiddleware = (metroMiddleware, metroServer) => { if (!observerInstalled) { installBundlerObserver(metroServer); observerInstalled = true; } return metroMiddleware; }; ``` `unstable_onServerCreated` is intended to fix it. **Why two flat lists** Extending is a plain array operation, array order is run order, and each list has one obvious merge order: a later contributor wraps the earlier ones, so it prepends its pre-middlewares and appends its post-middlewares. `mergeConfig` combines the lists in that order, so these are equivalent: ``` config.server.unstable_preMiddlewares = [auth, ...config.server.unstable_preMiddlewares]; config.server.unstable_postMiddlewares = [...config.server.unstable_postMiddlewares, fallback]; mergeConfig(config, { server: {unstable_preMiddlewares: [auth], unstable_postMiddlewares: [fallback]}, }); ``` Entries copied from the earlier config, e.g. by spreading it in `mergeConfig`'s function form, are kept once. **Shapes considered** - `{pre: [], post: []}`: every contributor clones and merges a nested object, and a missed spread drops the other list: ``` config.server.middlewares = { ...config.server.middlewares, pre: [auth, ...config.server.middlewares.pre], post: [...config.server.middlewares.post, fallback], }; ``` - One list of `{path, handler, pre: true}` entries: entries can be passed in any order, with pre and post interleaved, so composing contributions from several wrappers to run in a clear order is inconvenient: ``` // wrapper config.server.middlewares = [{handler: auth, pre: true}, ...config.server.middlewares, {handler: fallback}]; // app config.server.middlewares = [...config.server.middlewares, {handler: appPre, pre: true}, {path: '/x', handler: appPost}]; // result: [auth (pre), fallback, appPre (pre), /x appPost] // runs as: auth -> appPre -> Metro -> fallback -> /x appPost ``` **API** ``` type ServerMiddleware = Middleware | Readonly<[path: string, handler: Middleware]>; ``` - `server.unstable_preMiddlewares` / `server.unstable_postMiddlewares` (config, for apps and wrappers) and the `runServer` options of the same names (for frameworks) are `ReadonlyArray<ServerMiddleware>`, defaulting to empty. - Entries mirror `connect`'s `app.use`: a handler, or a `[path, handler]` tuple mounted with connect semantics. - Pre-middlewares run before Metro. Post-middlewares run only for requests Metro passed on with `next()`. - Request order: framework pre -> config pre -> Metro -> config post -> framework post. `createConnectMiddleware` applies the config layer, so frameworks calling it directly (Expo CLI) get config middleware without reimplementing the ordering. - `runServer`'s `unstable_onServerCreated(metroServer)` runs after Metro's server is created and before the HTTP server listens. - `mergeConfig` combines `unstable_preMiddlewares` and `unstable_postMiddlewares` as above, instead of replacing them like other arrays, so a later config can't silently drop handlers added by an earlier one. - `server.enhanceMiddleware` keeps its identity default, so code that calls the previous value without a check keeps working. Metro logs a deprecation warning only when it is set to anything other than that default. It is still applied around Metro's middleware. Expo CLI overwrites `enhanceMiddleware` itself, so Expo users see this warning until Expo CLI moves to `unstable_preMiddlewares`. - `runServer`'s `unstable_extraMiddleware` is kept as a deprecated alias, mounted before `unstable_preMiddlewares`, so React Native keeps working against this version. `unstable_preMiddlewares` covers it: its handlers run in the same place, before Metro, and it also accepts `[path, handler]` entries. Changelog: * **[Feature]:** Add `server.unstable_preMiddlewares` and `server.unstable_postMiddlewares` config options, and `runServer` options of the same names, to add middleware before and after Metro's own. * **[Feature]:** `mergeConfig` combines `server.unstable_preMiddlewares` and `server.unstable_postMiddlewares` from both configs: a later config's pre-middlewares run first and its post-middlewares run last. * **[Feature]:** Add `runServer` option `unstable_onServerCreated`, called with Metro's server instance before the HTTP server listens. * **[Deprecated]:** `server.enhanceMiddleware` logs a deprecation warning when set to anything other than its default. * **[Deprecated]:** `runServer`'s `unstable_extraMiddleware` option. Use `unstable_preMiddlewares`, which runs the same middlewares in the same place. Differential Revision: D123474267
meta-codesync
Bot
force-pushed
the
export-D123474267
branch
from
October 8, 2026 10:13
fda5f04 to
db15fcc
Compare
meta-codesync Bot
pushed a commit
that referenced
this pull request
Oct 8, 2026
Summary: Revisits the [2023 deprecation of `server.enhanceMiddleware`](22e85fd). Replaces `server.enhanceMiddleware` with two declarative middleware lists, one before Metro and one after it, accepted both in `metro.config.js` and by `runServer`, and `unstable_onServerCreated`, which receives Metro's server instance before the HTTP server starts listening (after the bundler is ready when `waitForBundler` is set). ### Why **Deprecation** - The deprecation argued that middleware belongs with the tools that start Metro, configured through `runServer`, rather than in Metro's config. That assumed whoever adds a handler controls the `runServer` call. As robhogan pointed out at the time, apps and config wrappers don't; only frameworks do. The deprecation removed their only hook without giving them a replacement, so three years later they still use `enhanceMiddleware`, and so does Expo CLI itself (see below). **Current State** Two different parties add handlers to Metro's server, and each controls a different entry point: - Frameworks (RN Community CLI, Expo CLI) start the server by calling `runServer` or `createConnectMiddleware`, and pass in the handlers every app needs, such as React Native's dev middleware. - 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, applied in a fixed order (framework handlers wrap config handlers), so each party can add handlers without knowing about the other. - The replacement, `runServer`'s `unstable_extraMiddleware`, only reaches frameworks and only works for them. So `enhanceMiddleware` stayed the only hook for most users, and Expo CLI itself still relies on it, with a [TODO](https://github.com/expo/expo/blob/d69fb576152d9e01afe013ae0d3e6962651ac871/packages/%40expo/cli/src/start/server/metro/instantiateMetro.ts#L425-L437) acknowledging it. Expo builds its own middleware stack (CORS, debugger, JS inspector), and uses the deprecated hook to mount Metro's middleware at the end of it. It overwrites the app's `enhanceMiddleware` with a wrapper that runs the app's hook first: ``` // TODO(cedric): `enhanceMiddleware` is deprecated, but is currently used to unify the middleware stacks const customEnhanceMiddleware = metroConfig.server.enhanceMiddleware; metroConfig.server.enhanceMiddleware = (metroMiddleware, server) => { if (customEnhanceMiddleware) { metroMiddleware = customEnhanceMiddleware(metroMiddleware, server); } return middleware.use(metroMiddleware); // Expo's stack, with Metro mounted last }; ``` So the deprecated hook couldn't be removed without breaking Expo, and an app's handlers could only run between Expo's stack and Metro. - Function-shaped hooks don't compose well. Every contributor has to capture and call the previous `enhanceMiddleware`. Of 15 internal apps we scanned, 6 overwrote it by accident, removing some endpoints (internal details below). Also, 4 libraries required an undeclared `connect` just to mount a path. e.g: ``` const prev = config.server.enhanceMiddleware; config.server.enhanceMiddleware = (metroMiddleware, metroServer) => require('connect')() .use('/my-endpoint', myHandler) .use(prev ? prev(metroMiddleware, metroServer) : metroMiddleware); ``` Passing `metroMiddleware` instead of the `prev(...)` call still works locally, but drops everything `prev` installed. - Neither `metro.config.js` nor `runServer`'s options allowed access to Metro's server instance besides `enhanceMiddleware`, which was abused as a lifecycle callback in a very dirty way. For example, internally, `fb-metro-cli` was using this hack: ``` let observerInstalled = false; config.server.enhanceMiddleware = (metroMiddleware, metroServer) => { if (!observerInstalled) { installBundlerObserver(metroServer); observerInstalled = true; } return metroMiddleware; }; ``` `unstable_onServerCreated` is intended to fix it. **Why two flat lists** Extending is a plain array operation, array order is run order, and each list has one obvious merge order: a later contributor wraps the earlier ones, so it prepends its pre-middlewares and appends its post-middlewares. `mergeConfig` combines the lists in that order, so these are equivalent: ``` config.server.unstable_preMiddlewares = [auth, ...config.server.unstable_preMiddlewares]; config.server.unstable_postMiddlewares = [...config.server.unstable_postMiddlewares, fallback]; mergeConfig(config, { server: {unstable_preMiddlewares: [auth], unstable_postMiddlewares: [fallback]}, }); ``` Entries copied from the earlier config, e.g. by spreading it in `mergeConfig`'s function form, are kept once. **Shapes considered** - `{pre: [], post: []}`: every contributor clones and merges a nested object, and a missed spread drops the other list: ``` config.server.middlewares = { ...config.server.middlewares, pre: [auth, ...config.server.middlewares.pre], post: [...config.server.middlewares.post, fallback], }; ``` - One list of `{path, handler, pre: true}` entries: entries can be passed in any order, with pre and post interleaved, so composing contributions from several wrappers to run in a clear order is inconvenient: ``` // wrapper config.server.middlewares = [{handler: auth, pre: true}, ...config.server.middlewares, {handler: fallback}]; // app config.server.middlewares = [...config.server.middlewares, {handler: appPre, pre: true}, {path: '/x', handler: appPost}]; // result: [auth (pre), fallback, appPre (pre), /x appPost] // runs as: auth -> appPre -> Metro -> fallback -> /x appPost ``` **API** ``` type ServerMiddleware = Middleware | Readonly<[path: string, handler: Middleware]>; ``` - `server.unstable_preMiddlewares` / `server.unstable_postMiddlewares` (config, for apps and wrappers) and the `runServer` options of the same names (for frameworks) are `ReadonlyArray<ServerMiddleware>`, defaulting to empty. - Entries mirror `connect`'s `app.use`: a handler, or a `[path, handler]` tuple mounted with connect semantics. - Pre-middlewares run before Metro. Post-middlewares run only for requests Metro passed on with `next()`. - Request order: framework pre -> config pre -> Metro -> config post -> framework post. `createConnectMiddleware` applies the config layer, so frameworks calling it directly (Expo CLI) get config middleware without reimplementing the ordering. - `runServer`'s `unstable_onServerCreated(metroServer)` runs after Metro's server is created and before the HTTP server listens. - `mergeConfig` combines `unstable_preMiddlewares` and `unstable_postMiddlewares` as above, instead of replacing them like other arrays, so a later config can't silently drop handlers added by an earlier one. - `server.enhanceMiddleware` keeps its identity default, so code that calls the previous value without a check keeps working. Metro logs a deprecation warning only when it is set to anything other than that default. It is still applied around Metro's middleware. Expo CLI overwrites `enhanceMiddleware` itself, so Expo users see this warning until Expo CLI moves to `unstable_preMiddlewares`. - `runServer`'s `unstable_extraMiddleware` is kept as a deprecated alias, mounted before `unstable_preMiddlewares`, so React Native keeps working against this version. `unstable_preMiddlewares` covers it: its handlers run in the same place, before Metro, and it also accepts `[path, handler]` entries. Changelog: * **[Feature]:** Add `server.unstable_preMiddlewares` and `server.unstable_postMiddlewares` config options, and `runServer` options of the same names, to add middleware before and after Metro's own. * **[Feature]:** `mergeConfig` combines `server.unstable_preMiddlewares` and `server.unstable_postMiddlewares` from both configs: a later config's pre-middlewares run first and its post-middlewares run last. * **[Feature]:** Add `runServer` option `unstable_onServerCreated`, called with Metro's server instance before the HTTP server listens. * **[Deprecated]:** `server.enhanceMiddleware` logs a deprecation warning when set to anything other than its default. * **[Deprecated]:** `runServer`'s `unstable_extraMiddleware` option. Use `unstable_preMiddlewares`, which runs the same middlewares in the same place. Differential Revision: D123474267
meta-codesync
Bot
force-pushed
the
export-D123474267
branch
from
October 8, 2026 10:36
db15fcc to
78bfb22
Compare
meta-codesync Bot
pushed a commit
that referenced
this pull request
Oct 8, 2026
Summary: Pull Request resolved: #2031 Revisits the [2023 deprecation of `server.enhanceMiddleware`](22e85fd). Replaces `server.enhanceMiddleware` with two declarative middleware lists, one before Metro and one after it, accepted both in `metro.config.js` and by `runServer`, and `unstable_onServerCreated`, which receives Metro's server instance before the HTTP server starts listening (after the bundler is ready when `waitForBundler` is set). ### Why **Deprecation** - The deprecation argued that middleware belongs with the tools that start Metro, configured through `runServer`, rather than in Metro's config. That assumed whoever adds a handler controls the `runServer` call. As robhogan pointed out at the time, apps and config wrappers don't; only frameworks do. The deprecation removed their only hook without giving them a replacement, so three years later they still use `enhanceMiddleware`, and so does Expo CLI itself (see below). **Current State** Two different parties add handlers to Metro's server, and each controls a different entry point: - Frameworks (RN Community CLI, Expo CLI) start the server by calling `runServer` or `createConnectMiddleware`, and pass in the handlers every app needs, such as React Native's dev middleware. - 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, applied in a fixed order (framework handlers wrap config handlers), so each party can add handlers without knowing about the other. - The replacement, `runServer`'s `unstable_extraMiddleware`, only reaches frameworks and only works for them. So `enhanceMiddleware` stayed the only hook for most users, and Expo CLI itself still relies on it, with a [TODO](https://github.com/expo/expo/blob/d69fb576152d9e01afe013ae0d3e6962651ac871/packages/%40expo/cli/src/start/server/metro/instantiateMetro.ts#L425-L437) acknowledging it. Expo builds its own middleware stack (CORS, debugger, JS inspector), and uses the deprecated hook to mount Metro's middleware at the end of it. It overwrites the app's `enhanceMiddleware` with a wrapper that runs the app's hook first: ``` // TODO(cedric): `enhanceMiddleware` is deprecated, but is currently used to unify the middleware stacks const customEnhanceMiddleware = metroConfig.server.enhanceMiddleware; metroConfig.server.enhanceMiddleware = (metroMiddleware, server) => { if (customEnhanceMiddleware) { metroMiddleware = customEnhanceMiddleware(metroMiddleware, server); } return middleware.use(metroMiddleware); // Expo's stack, with Metro mounted last }; ``` So the deprecated hook couldn't be removed without breaking Expo, and an app's handlers could only run between Expo's stack and Metro. - Function-shaped hooks don't compose well. Every contributor has to capture and call the previous `enhanceMiddleware`. Of 15 internal apps we scanned, 6 overwrote it by accident, removing some endpoints (internal details below). Also, 4 libraries required an undeclared `connect` just to mount a path. e.g: ``` const prev = config.server.enhanceMiddleware; config.server.enhanceMiddleware = (metroMiddleware, metroServer) => require('connect')() .use('/my-endpoint', myHandler) .use(prev ? prev(metroMiddleware, metroServer) : metroMiddleware); ``` Passing `metroMiddleware` instead of the `prev(...)` call still works locally, but drops everything `prev` installed. - Neither `metro.config.js` nor `runServer`'s options allowed access to Metro's server instance besides `enhanceMiddleware`, which was abused as a lifecycle callback in a very dirty way. For example, internally, `fb-metro-cli` was using this hack: ``` let observerInstalled = false; config.server.enhanceMiddleware = (metroMiddleware, metroServer) => { if (!observerInstalled) { installBundlerObserver(metroServer); observerInstalled = true; } return metroMiddleware; }; ``` `unstable_onServerCreated` is intended to fix it. **Why two flat lists** Extending is a plain array operation, array order is run order, and each list has one obvious merge order: a later contributor wraps the earlier ones, so it prepends its pre-middlewares and appends its post-middlewares. `mergeConfig` combines the lists in that order, so these are equivalent: ``` config.server.unstable_preMiddlewares = [auth, ...config.server.unstable_preMiddlewares]; config.server.unstable_postMiddlewares = [...config.server.unstable_postMiddlewares, fallback]; mergeConfig(config, { server: {unstable_preMiddlewares: [auth], unstable_postMiddlewares: [fallback]}, }); ``` Entries copied from the earlier config, e.g. by spreading it in `mergeConfig`'s function form, are kept once. **Shapes considered** - `{pre: [], post: []}`: every contributor clones and merges a nested object, and a missed spread drops the other list: ``` config.server.middlewares = { ...config.server.middlewares, pre: [auth, ...config.server.middlewares.pre], post: [...config.server.middlewares.post, fallback], }; ``` - One list of `{path, handler, pre: true}` entries: entries can be passed in any order, with pre and post interleaved, so composing contributions from several wrappers to run in a clear order is inconvenient: ``` // wrapper config.server.middlewares = [{handler: auth, pre: true}, ...config.server.middlewares, {handler: fallback}]; // app config.server.middlewares = [...config.server.middlewares, {handler: appPre, pre: true}, {path: '/x', handler: appPost}]; // result: [auth (pre), fallback, appPre (pre), /x appPost] // runs as: auth -> appPre -> Metro -> fallback -> /x appPost ``` **API** ``` type ServerMiddleware = Middleware | Readonly<[path: string, handler: Middleware]>; ``` - `server.unstable_preMiddlewares` / `server.unstable_postMiddlewares` (config, for apps and wrappers) and the `runServer` options of the same names (for frameworks) are `ReadonlyArray<ServerMiddleware>`, defaulting to empty. - Entries mirror `connect`'s `app.use`: a handler, or a `[path, handler]` tuple mounted with connect semantics. - Pre-middlewares run before Metro. Post-middlewares run only for requests Metro passed on with `next()`. - Request order: framework pre -> config pre -> Metro -> config post -> framework post. `createConnectMiddleware` applies the config layer, so frameworks calling it directly (Expo CLI) get config middleware without reimplementing the ordering. - `runServer`'s `unstable_onServerCreated(metroServer)` runs after Metro's server is created and before the HTTP server listens. - `mergeConfig` combines `unstable_preMiddlewares` and `unstable_postMiddlewares` as above, instead of replacing them like other arrays, so a later config can't silently drop handlers added by an earlier one. - `server.enhanceMiddleware` keeps its identity default, so code that calls the previous value without a check keeps working. Metro logs a deprecation warning only when it is set to anything other than that default. It is still applied around Metro's middleware. Expo CLI overwrites `enhanceMiddleware` itself, so Expo users see this warning until Expo CLI moves to `unstable_preMiddlewares`. - `runServer`'s `unstable_extraMiddleware` is kept as a deprecated alias, mounted before `unstable_preMiddlewares`, so React Native keeps working against this version. `unstable_preMiddlewares` covers it: its handlers run in the same place, before Metro, and it also accepts `[path, handler]` entries. Changelog: * **[Feature]:** Add `server.unstable_preMiddlewares` and `server.unstable_postMiddlewares` config options, and `runServer` options of the same names, to add middleware before and after Metro's own. * **[Feature]:** `mergeConfig` combines `server.unstable_preMiddlewares` and `server.unstable_postMiddlewares` from both configs: a later config's pre-middlewares run first and its post-middlewares run last. * **[Feature]:** Add `runServer` option `unstable_onServerCreated`, called with Metro's server instance before the HTTP server listens. * **[Deprecated]:** `server.enhanceMiddleware` logs a deprecation warning when set to anything other than its default. * **[Deprecated]:** `runServer`'s `unstable_extraMiddleware` option. Use `unstable_preMiddlewares`, which runs the same middlewares in the same place. Differential Revision: D123474267
meta-codesync
Bot
force-pushed
the
export-D123474267
branch
from
October 8, 2026 10:38
78bfb22 to
c9778db
Compare
meta-codesync Bot
pushed a commit
that referenced
this pull request
Oct 8, 2026
…hanceMiddleware (#2031) Summary: Revisits the [2023 deprecation of `server.enhanceMiddleware`](22e85fd). Replaces `server.enhanceMiddleware` with two declarative middleware lists, `unstable_middleware` after Metro and `unstable_priorityMiddleware` before it, accepted both in `metro.config.js` and by `runServer`, and `unstable_onServerCreated`, which receives Metro's server instance before the HTTP server starts listening (after the bundler is ready when `waitForBundler` is set). ### Why **Deprecation** - The deprecation argued that middleware belongs with the tools that start Metro, configured through `runServer`, rather than in Metro's config. That assumed whoever adds a handler controls the `runServer` call. As robhogan pointed out at the time, apps and config wrappers don't; only frameworks do. The deprecation removed their only hook without giving them a replacement, so three years later they still use `enhanceMiddleware`, and so does Expo CLI itself (see below). **Current State** Two different parties add handlers to Metro's server, and each controls a different entry point: - Frameworks (RN Community CLI, Expo CLI) start the server by calling `runServer` or `createConnectMiddleware`, and pass in the handlers every app needs, such as React Native's dev middleware. - 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, applied in a fixed order (framework handlers wrap config handlers), so each party can add handlers without knowing about the other. - The replacement, `runServer`'s `unstable_extraMiddleware`, only reaches frameworks and only works for them. So `enhanceMiddleware` stayed the only hook for most users, and Expo CLI itself still relies on it, with a [TODO](https://github.com/expo/expo/blob/d69fb576152d9e01afe013ae0d3e6962651ac871/packages/%40expo/cli/src/start/server/metro/instantiateMetro.ts#L425-L437) acknowledging it. Expo builds its own middleware stack (CORS, debugger, JS inspector), and uses the deprecated hook to mount Metro's middleware at the end of it. It overwrites the app's `enhanceMiddleware` with a wrapper that runs the app's hook first: ``` // TODO(cedric): `enhanceMiddleware` is deprecated, but is currently used to unify the middleware stacks const customEnhanceMiddleware = metroConfig.server.enhanceMiddleware; metroConfig.server.enhanceMiddleware = (metroMiddleware, server) => { if (customEnhanceMiddleware) { metroMiddleware = customEnhanceMiddleware(metroMiddleware, server); } return middleware.use(metroMiddleware); // Expo's stack, with Metro mounted last }; ``` So the deprecated hook couldn't be removed without breaking Expo, and an app's handlers could only run between Expo's stack and Metro. - Function-shaped hooks don't compose well. Every contributor has to capture and call the previous `enhanceMiddleware`. Of 15 internal apps we scanned, 6 overwrote it by accident, removing some endpoints (internal details below). Also, 4 libraries required an undeclared `connect` just to mount a path. e.g: ``` const prev = config.server.enhanceMiddleware; config.server.enhanceMiddleware = (metroMiddleware, metroServer) => require('connect')() .use('/my-endpoint', myHandler) .use(prev ? prev(metroMiddleware, metroServer) : metroMiddleware); ``` Passing `metroMiddleware` instead of the `prev(...)` call still works locally, but drops everything `prev` installed. - Neither `metro.config.js` nor `runServer`'s options allowed access to Metro's server instance besides `enhanceMiddleware`, which was abused as a lifecycle callback in a very dirty way. For example, internally, `fb-metro-cli` was using this hack: ``` let observerInstalled = false; config.server.enhanceMiddleware = (metroMiddleware, metroServer) => { if (!observerInstalled) { installBundlerObserver(metroServer); observerInstalled = true; } return metroMiddleware; }; ``` `unstable_onServerCreated` is intended to fix it. **Why two flat lists** Extending is a plain array operation, array order is run order, and each list has one obvious merge order: a later contributor wraps the earlier ones, so it appends to `unstable_middleware` and prepends to `unstable_priorityMiddleware`. `mergeConfig` combines the lists in that order, so these are equivalent: ``` config.server.unstable_middleware = [...config.server.unstable_middleware, fallback]; config.server.unstable_priorityMiddleware = [auth, ...config.server.unstable_priorityMiddleware]; mergeConfig(config, { server: {unstable_middleware: [fallback], unstable_priorityMiddleware: [auth]}, }); ``` Entries copied from the earlier config, e.g. by spreading it in `mergeConfig`'s function form, are kept once. **Naming** `unstable_middleware` is the default choice: it runs after Metro, so it can't shadow Metro's endpoints. Running ahead of Metro's endpoints is a deliberate choice, so it gets the explicit name `unstable_priorityMiddleware`. Both are singular, like `enhanceMiddleware`: each list composes into one middleware on the server. **Shapes considered** - `{pre: [], post: []}`: every contributor clones and merges a nested object, and a missed spread drops the other list: ``` config.server.middlewares = { ...config.server.middlewares, pre: [auth, ...config.server.middlewares.pre], post: [...config.server.middlewares.post, fallback], }; ``` - One list of `{path, handler, pre: true}` entries: entries can be passed in any order, with pre and post interleaved, so composing contributions from several wrappers to run in a clear order is inconvenient: ``` // wrapper config.server.middlewares = [{handler: auth, pre: true}, ...config.server.middlewares, {handler: fallback}]; // app config.server.middlewares = [...config.server.middlewares, {handler: appPre, pre: true}, {path: '/x', handler: appPost}]; // result: [auth (pre), fallback, appPre (pre), /x appPost] // runs as: auth -> appPre -> Metro -> fallback -> /x appPost ``` **API** ``` type ServerMiddleware = Middleware | Readonly<[path: string, handler: Middleware]>; ``` - `server.unstable_middleware` / `server.unstable_priorityMiddleware` (config, for apps and wrappers) and the `runServer` options of the same names (for frameworks) are `ReadonlyArray<ServerMiddleware>`, defaulting to empty. - Entries mirror `connect`'s `app.use`: a handler, or a `[path, handler]` tuple mounted with connect semantics. - `unstable_middleware` runs only for requests Metro passed on with `next()`. `unstable_priorityMiddleware` runs before Metro. - Request order: framework priority middleware -> config priority middleware -> Metro -> config middleware -> framework middleware. `createConnectMiddleware` applies the config layer, so frameworks calling it directly (Expo CLI) get config middleware without reimplementing the ordering. - `runServer`'s `unstable_onServerCreated(metroServer)` runs after Metro's server is created and before the HTTP server listens. - `mergeConfig` combines `unstable_middleware` and `unstable_priorityMiddleware` as above, instead of replacing them like other arrays, so a later config can't silently drop handlers added by an earlier one. - `server.enhanceMiddleware` keeps its identity default, so code that calls the previous value without a check keeps working. Metro logs a deprecation warning only when it is set to anything other than that default. It is still applied around Metro's middleware. Expo CLI overwrites `enhanceMiddleware` itself, so Expo users see this warning until Expo CLI moves to `unstable_priorityMiddleware`. - `runServer`'s `unstable_extraMiddleware` is kept as a deprecated alias, mounted before `unstable_priorityMiddleware`, so React Native keeps working against this version. `unstable_priorityMiddleware` covers it: its handlers run in the same place, before Metro, and it also accepts `[path, handler]` entries. Changelog: * **[Feature]:** Add `server.unstable_middleware` and `server.unstable_priorityMiddleware` config options, and `runServer` options of the same names, to add middleware after and before Metro's own. * **[Feature]:** `mergeConfig` combines `server.unstable_middleware` and `server.unstable_priorityMiddleware` from both configs: a later config's `unstable_middleware` runs last and its `unstable_priorityMiddleware` runs first. * **[Feature]:** Add `runServer` option `unstable_onServerCreated`, called with Metro's server instance before the HTTP server listens. * **[Deprecated]:** `server.enhanceMiddleware` logs a deprecation warning when set to anything other than its default. * **[Deprecated]:** `runServer`'s `unstable_extraMiddleware` option. Use `unstable_priorityMiddleware`, which runs the same middleware in the same place. Differential Revision: D123474267
meta-codesync
Bot
force-pushed
the
export-D123474267
branch
from
October 8, 2026 15:59
c9778db to
8cfad73
Compare
meta-codesync Bot
pushed a commit
that referenced
this pull request
Oct 8, 2026
…hanceMiddleware (#2031) Summary: Pull Request resolved: #2031 Revisits the [2023 deprecation of `server.enhanceMiddleware`](22e85fd). Replaces `server.enhanceMiddleware` with two declarative middleware lists, `unstable_middleware` after Metro and `unstable_priorityMiddleware` before it, accepted both in `metro.config.js` and by `runServer`, and `unstable_onServerCreated`, which receives Metro's server instance before the HTTP server starts listening (after the bundler is ready when `waitForBundler` is set). ### Why **Deprecation** - The deprecation argued that middleware belongs with the tools that start Metro, configured through `runServer`, rather than in Metro's config. That assumed whoever adds a handler controls the `runServer` call. As robhogan pointed out at the time, apps and config wrappers don't; only frameworks do. The deprecation removed their only hook without giving them a replacement, so three years later they still use `enhanceMiddleware`, and so does Expo CLI itself (see below). **Current State** Two different parties add handlers to Metro's server, and each controls a different entry point: - Frameworks (RN Community CLI, Expo CLI) start the server by calling `runServer` or `createConnectMiddleware`, and pass in the handlers every app needs, such as React Native's dev middleware. - 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, applied in a fixed order (framework handlers wrap config handlers), so each party can add handlers without knowing about the other. - The replacement, `runServer`'s `unstable_extraMiddleware`, only reaches frameworks and only works for them. So `enhanceMiddleware` stayed the only hook for most users, and Expo CLI itself still relies on it, with a [TODO](https://github.com/expo/expo/blob/d69fb576152d9e01afe013ae0d3e6962651ac871/packages/%40expo/cli/src/start/server/metro/instantiateMetro.ts#L425-L437) acknowledging it. Expo builds its own middleware stack (CORS, debugger, JS inspector), and uses the deprecated hook to mount Metro's middleware at the end of it. It overwrites the app's `enhanceMiddleware` with a wrapper that runs the app's hook first: ``` // TODO(cedric): `enhanceMiddleware` is deprecated, but is currently used to unify the middleware stacks const customEnhanceMiddleware = metroConfig.server.enhanceMiddleware; metroConfig.server.enhanceMiddleware = (metroMiddleware, server) => { if (customEnhanceMiddleware) { metroMiddleware = customEnhanceMiddleware(metroMiddleware, server); } return middleware.use(metroMiddleware); // Expo's stack, with Metro mounted last }; ``` So the deprecated hook couldn't be removed without breaking Expo, and an app's handlers could only run between Expo's stack and Metro. - Function-shaped hooks don't compose well. Every contributor has to capture and call the previous `enhanceMiddleware`. Of 15 internal apps we scanned, 6 overwrote it by accident, removing some endpoints (internal details below). Also, 4 libraries required an undeclared `connect` just to mount a path. e.g: ``` const prev = config.server.enhanceMiddleware; config.server.enhanceMiddleware = (metroMiddleware, metroServer) => require('connect')() .use('/my-endpoint', myHandler) .use(prev ? prev(metroMiddleware, metroServer) : metroMiddleware); ``` Passing `metroMiddleware` instead of the `prev(...)` call still works locally, but drops everything `prev` installed. - Neither `metro.config.js` nor `runServer`'s options allowed access to Metro's server instance besides `enhanceMiddleware`, which was abused as a lifecycle callback in a very dirty way. For example, internally, `fb-metro-cli` was using this hack: ``` let observerInstalled = false; config.server.enhanceMiddleware = (metroMiddleware, metroServer) => { if (!observerInstalled) { installBundlerObserver(metroServer); observerInstalled = true; } return metroMiddleware; }; ``` `unstable_onServerCreated` is intended to fix it. **Why two flat lists** Extending is a plain array operation, array order is run order, and each list has one obvious merge order: a later contributor wraps the earlier ones, so it appends to `unstable_middleware` and prepends to `unstable_priorityMiddleware`. `mergeConfig` combines the lists in that order, so these are equivalent: ``` config.server.unstable_middleware = [...config.server.unstable_middleware, fallback]; config.server.unstable_priorityMiddleware = [auth, ...config.server.unstable_priorityMiddleware]; mergeConfig(config, { server: {unstable_middleware: [fallback], unstable_priorityMiddleware: [auth]}, }); ``` Entries copied from the earlier config, e.g. by spreading it in `mergeConfig`'s function form, are kept once. **Naming** `unstable_middleware` is the default choice: it runs after Metro, so it can't shadow Metro's endpoints. Running ahead of Metro's endpoints is a deliberate choice, so it gets the explicit name `unstable_priorityMiddleware`. Both are singular, like `enhanceMiddleware`: each list composes into one middleware on the server. **Shapes considered** - `{pre: [], post: []}`: every contributor clones and merges a nested object, and a missed spread drops the other list: ``` config.server.middlewares = { ...config.server.middlewares, pre: [auth, ...config.server.middlewares.pre], post: [...config.server.middlewares.post, fallback], }; ``` - One list of `{path, handler, pre: true}` entries: entries can be passed in any order, with pre and post interleaved, so composing contributions from several wrappers to run in a clear order is inconvenient: ``` // wrapper config.server.middlewares = [{handler: auth, pre: true}, ...config.server.middlewares, {handler: fallback}]; // app config.server.middlewares = [...config.server.middlewares, {handler: appPre, pre: true}, {path: '/x', handler: appPost}]; // result: [auth (pre), fallback, appPre (pre), /x appPost] // runs as: auth -> appPre -> Metro -> fallback -> /x appPost ``` **API** ``` type ServerMiddleware = Middleware | Readonly<[path: string, handler: Middleware]>; ``` - `server.unstable_middleware` / `server.unstable_priorityMiddleware` (config, for apps and wrappers) and the `runServer` options of the same names (for frameworks) are `ReadonlyArray<ServerMiddleware>`, defaulting to empty. - Entries mirror `connect`'s `app.use`: a handler, or a `[path, handler]` tuple mounted with connect semantics. - `unstable_middleware` runs only for requests Metro passed on with `next()`. `unstable_priorityMiddleware` runs before Metro. - Request order: framework priority middleware -> config priority middleware -> Metro -> config middleware -> framework middleware. `createConnectMiddleware` applies the config layer, so frameworks calling it directly (Expo CLI) get config middleware without reimplementing the ordering. - `runServer`'s `unstable_onServerCreated(metroServer)` runs after Metro's server is created and before the HTTP server listens. - `mergeConfig` combines `unstable_middleware` and `unstable_priorityMiddleware` as above, instead of replacing them like other arrays, so a later config can't silently drop handlers added by an earlier one. - `server.enhanceMiddleware` keeps its identity default, so code that calls the previous value without a check keeps working. Metro logs a deprecation warning only when it is set to anything other than that default. It is still applied around Metro's middleware. Expo CLI overwrites `enhanceMiddleware` itself, so Expo users see this warning until Expo CLI moves to `unstable_priorityMiddleware`. - `runServer`'s `unstable_extraMiddleware` is kept as a deprecated alias, mounted before `unstable_priorityMiddleware`, so React Native keeps working against this version. `unstable_priorityMiddleware` covers it: its handlers run in the same place, before Metro, and it also accepts `[path, handler]` entries. Changelog: * **[Feature]:** Add `server.unstable_middleware` and `server.unstable_priorityMiddleware` config options, and `runServer` options of the same names, to add middleware after and before Metro's own. * **[Feature]:** `mergeConfig` combines `server.unstable_middleware` and `server.unstable_priorityMiddleware` from both configs: a later config's `unstable_middleware` runs last and its `unstable_priorityMiddleware` runs first. * **[Feature]:** Add `runServer` option `unstable_onServerCreated`, called with Metro's server instance before the HTTP server listens. * **[Deprecated]:** `server.enhanceMiddleware` logs a deprecation warning when set to anything other than its default. * **[Deprecated]:** `runServer`'s `unstable_extraMiddleware` option. Use `unstable_priorityMiddleware`, which runs the same middleware in the same place. Differential Revision: D123474267
meta-codesync
Bot
force-pushed
the
export-D123474267
branch
from
October 8, 2026 16:05
8cfad73 to
f106b9d
Compare
meta-codesync Bot
pushed a commit
that referenced
this pull request
Oct 8, 2026
…hanceMiddleware (#2031) Summary: Revisits the [2023 deprecation of `server.enhanceMiddleware`](22e85fd). Replaces `server.enhanceMiddleware` with two declarative middleware lists, `unstable_middleware` after Metro and `unstable_priorityMiddleware` before it, accepted both in `metro.config.js` and by `runServer`, and `unstable_onServerCreated`, which receives Metro's server instance before the HTTP server starts listening (after the bundler is ready when `waitForBundler` is set). ### Why **Deprecation** - The deprecation argued that middleware belongs with the tools that start Metro, configured through `runServer`, rather than in Metro's config. That assumed whoever adds a handler controls the `runServer` call. As robhogan pointed out at the time, apps and config wrappers don't; only frameworks do. The deprecation removed their only hook without giving them a replacement, so three years later they still use `enhanceMiddleware`, and so does Expo CLI itself (see below). **Current State** Two different parties add handlers to Metro's server, and each controls a different entry point: - Frameworks (RN Community CLI, Expo CLI) start the server by calling `runServer` or `createConnectMiddleware`, and pass in the handlers every app needs, such as React Native's dev middleware. - 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, applied in a fixed order (framework handlers wrap config handlers), so each party can add handlers without knowing about the other. - The replacement, `runServer`'s `unstable_extraMiddleware`, only reaches frameworks and only works for them. So `enhanceMiddleware` stayed the only hook for most users, and Expo CLI itself still relies on it, with a [TODO](https://github.com/expo/expo/blob/d69fb576152d9e01afe013ae0d3e6962651ac871/packages/%40expo/cli/src/start/server/metro/instantiateMetro.ts#L425-L437) acknowledging it. Expo builds its own middleware stack (CORS, debugger, JS inspector), and uses the deprecated hook to mount Metro's middleware at the end of it. It overwrites the app's `enhanceMiddleware` with a wrapper that runs the app's hook first: ``` // TODO(cedric): `enhanceMiddleware` is deprecated, but is currently used to unify the middleware stacks const customEnhanceMiddleware = metroConfig.server.enhanceMiddleware; metroConfig.server.enhanceMiddleware = (metroMiddleware, server) => { if (customEnhanceMiddleware) { metroMiddleware = customEnhanceMiddleware(metroMiddleware, server); } return middleware.use(metroMiddleware); // Expo's stack, with Metro mounted last }; ``` So the deprecated hook couldn't be removed without breaking Expo, and an app's handlers could only run between Expo's stack and Metro. - Function-shaped hooks don't compose well. Every contributor has to capture and call the previous `enhanceMiddleware`. Of 15 internal apps we scanned, 6 overwrote it by accident, removing some endpoints (internal details below). Also, 4 libraries required an undeclared `connect` just to mount a path. e.g: ``` const prev = config.server.enhanceMiddleware; config.server.enhanceMiddleware = (metroMiddleware, metroServer) => require('connect')() .use('/my-endpoint', myHandler) .use(prev ? prev(metroMiddleware, metroServer) : metroMiddleware); ``` Passing `metroMiddleware` instead of the `prev(...)` call still works locally, but drops everything `prev` installed. - Neither `metro.config.js` nor `runServer`'s options allowed access to Metro's server instance besides `enhanceMiddleware`, which was abused as a lifecycle callback in a very dirty way. For example, internally, `fb-metro-cli` was using this hack: ``` let observerInstalled = false; config.server.enhanceMiddleware = (metroMiddleware, metroServer) => { if (!observerInstalled) { installBundlerObserver(metroServer); observerInstalled = true; } return metroMiddleware; }; ``` `unstable_onServerCreated` is intended to fix it. **Why two flat lists** Extending is a plain array operation, array order is run order, and each list has one obvious merge order: a later contributor wraps the earlier ones, so it appends to `unstable_middleware` and prepends to `unstable_priorityMiddleware`. `mergeConfig` combines the lists in that order, so these are equivalent: ``` config.server.unstable_middleware = [...config.server.unstable_middleware, fallback]; config.server.unstable_priorityMiddleware = [auth, ...config.server.unstable_priorityMiddleware]; mergeConfig(config, { server: {unstable_middleware: [fallback], unstable_priorityMiddleware: [auth]}, }); ``` Entries copied from the earlier config, e.g. by spreading it in `mergeConfig`'s function form, are kept once. **Naming** `unstable_middleware` is the default choice: it runs after Metro, so it can't shadow Metro's endpoints. Running ahead of Metro's endpoints is a deliberate choice, so it gets the explicit name `unstable_priorityMiddleware`. Both are singular, like `enhanceMiddleware`: each list composes into one middleware on the server. **Shapes considered** - `{pre: [], post: []}`: every contributor clones and merges a nested object, and a missed spread drops the other list: ``` config.server.middlewares = { ...config.server.middlewares, pre: [auth, ...config.server.middlewares.pre], post: [...config.server.middlewares.post, fallback], }; ``` - One list of `{path, handler, pre: true}` entries: entries can be passed in any order, with pre and post interleaved, so composing contributions from several wrappers to run in a clear order is inconvenient: ``` // wrapper config.server.middlewares = [{handler: auth, pre: true}, ...config.server.middlewares, {handler: fallback}]; // app config.server.middlewares = [...config.server.middlewares, {handler: appPre, pre: true}, {path: '/x', handler: appPost}]; // result: [auth (pre), fallback, appPre (pre), /x appPost] // runs as: auth -> appPre -> Metro -> fallback -> /x appPost ``` **API** ``` type ServerMiddleware = Middleware | Readonly<[path: string, handler: Middleware]>; ``` - `server.unstable_middleware` / `server.unstable_priorityMiddleware` (config, for apps and wrappers) and the `runServer` options of the same names (for frameworks) are `ReadonlyArray<ServerMiddleware>`, defaulting to empty. - Entries mirror `connect`'s `app.use`: a handler, or a `[path, handler]` tuple mounted with connect semantics. - `unstable_middleware` runs only for requests Metro passed on with `next()`. `unstable_priorityMiddleware` runs before Metro. - Request order: framework priority middleware -> config priority middleware -> Metro -> config middleware -> framework middleware. `createConnectMiddleware` applies the config layer, so frameworks calling it directly (Expo CLI) get config middleware without reimplementing the ordering. - `runServer`'s `unstable_onServerCreated(metroServer)` runs after Metro's server is created and before the HTTP server listens. - `mergeConfig` combines `unstable_middleware` and `unstable_priorityMiddleware` as above, instead of replacing them like other arrays, so a later config can't silently drop handlers added by an earlier one. - `server.enhanceMiddleware` keeps its identity default, so code that calls the previous value without a check keeps working. Metro logs a deprecation warning only when it is set to anything other than that default. It is still applied around Metro's middleware. Expo CLI overwrites `enhanceMiddleware` itself, so Expo users see this warning until Expo CLI moves to `unstable_priorityMiddleware`. - `runServer`'s `unstable_extraMiddleware` is kept as a deprecated alias, mounted before `unstable_priorityMiddleware`, so React Native keeps working against this version. `unstable_priorityMiddleware` covers it: its handlers run in the same place, before Metro, and it also accepts `[path, handler]` entries. Changelog: * **[Feature]:** Add `server.unstable_middleware` and `server.unstable_priorityMiddleware` config options, and `runServer` options of the same names, to add middleware after and before Metro's own. * **[Feature]:** `mergeConfig` combines `server.unstable_middleware` and `server.unstable_priorityMiddleware` from both configs: a later config's `unstable_middleware` runs last and its `unstable_priorityMiddleware` runs first. * **[Feature]:** Add `runServer` option `unstable_onServerCreated`, called with Metro's server instance before the HTTP server listens. * **[Deprecated]:** `server.enhanceMiddleware` logs a deprecation warning when set to anything other than its default. * **[Deprecated]:** `runServer`'s `unstable_extraMiddleware` option. Use `unstable_priorityMiddleware`, which runs the same middleware in the same place. Differential Revision: D123474267
meta-codesync
Bot
force-pushed
the
export-D123474267
branch
from
October 8, 2026 17:41
f106b9d to
163e8bb
Compare
…hanceMiddleware (#2031) Summary: Pull Request resolved: #2031 Revisits the [2023 deprecation of `server.enhanceMiddleware`](22e85fd). Replaces `server.enhanceMiddleware` with two declarative middleware lists, `unstable_middleware` after Metro and `unstable_priorityMiddleware` before it, accepted both in `metro.config.js` and by `runServer`, and `unstable_onServerCreated`, which receives Metro's server instance before the HTTP server starts listening (after the bundler is ready when `waitForBundler` is set). ### Why **Deprecation** - The deprecation argued that middleware belongs with the tools that start Metro, configured through `runServer`, rather than in Metro's config. That assumed whoever adds a handler controls the `runServer` call. As robhogan pointed out at the time, apps and config wrappers don't; only frameworks do. The deprecation removed their only hook without giving them a replacement, so three years later they still use `enhanceMiddleware`, and so does Expo CLI itself (see below). **Current State** Two different parties add handlers to Metro's server, and each controls a different entry point: - Frameworks (RN Community CLI, Expo CLI) start the server by calling `runServer` or `createConnectMiddleware`, and pass in the handlers every app needs, such as React Native's dev middleware. - 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, applied in a fixed order (framework handlers wrap config handlers), so each party can add handlers without knowing about the other. - The replacement, `runServer`'s `unstable_extraMiddleware`, only reaches frameworks and only works for them. So `enhanceMiddleware` stayed the only hook for most users, and Expo CLI itself still relies on it, with a [TODO](https://github.com/expo/expo/blob/d69fb576152d9e01afe013ae0d3e6962651ac871/packages/%40expo/cli/src/start/server/metro/instantiateMetro.ts#L425-L437) acknowledging it. Expo builds its own middleware stack (CORS, debugger, JS inspector), and uses the deprecated hook to mount Metro's middleware at the end of it. It overwrites the app's `enhanceMiddleware` with a wrapper that runs the app's hook first: ``` // TODO(cedric): `enhanceMiddleware` is deprecated, but is currently used to unify the middleware stacks const customEnhanceMiddleware = metroConfig.server.enhanceMiddleware; metroConfig.server.enhanceMiddleware = (metroMiddleware, server) => { if (customEnhanceMiddleware) { metroMiddleware = customEnhanceMiddleware(metroMiddleware, server); } return middleware.use(metroMiddleware); // Expo's stack, with Metro mounted last }; ``` So the deprecated hook couldn't be removed without breaking Expo, and an app's handlers could only run between Expo's stack and Metro. - Function-shaped hooks don't compose well. Every contributor has to capture and call the previous `enhanceMiddleware`. Of 15 internal apps we scanned, 6 overwrote it by accident, removing some endpoints (internal details below). Also, 4 libraries required an undeclared `connect` just to mount a path. e.g: ``` const prev = config.server.enhanceMiddleware; config.server.enhanceMiddleware = (metroMiddleware, metroServer) => require('connect')() .use('/my-endpoint', myHandler) .use(prev ? prev(metroMiddleware, metroServer) : metroMiddleware); ``` Passing `metroMiddleware` instead of the `prev(...)` call still works locally, but drops everything `prev` installed. - Neither `metro.config.js` nor `runServer`'s options allowed access to Metro's server instance besides `enhanceMiddleware`, which was abused as a lifecycle callback in a very dirty way. For example, internally, `fb-metro-cli` was using this hack: ``` let observerInstalled = false; config.server.enhanceMiddleware = (metroMiddleware, metroServer) => { if (!observerInstalled) { installBundlerObserver(metroServer); observerInstalled = true; } return metroMiddleware; }; ``` `unstable_onServerCreated` is intended to fix it. **Why two flat lists** Extending is a plain array operation, array order is run order, and each list has one obvious merge order: a later contributor wraps the earlier ones, so it appends to `unstable_middleware` and prepends to `unstable_priorityMiddleware`. `mergeConfig` combines the lists in that order, so these are equivalent: ``` config.server.unstable_middleware = [...config.server.unstable_middleware, fallback]; config.server.unstable_priorityMiddleware = [auth, ...config.server.unstable_priorityMiddleware]; mergeConfig(config, { server: {unstable_middleware: [fallback], unstable_priorityMiddleware: [auth]}, }); ``` Entries copied from the earlier config, e.g. by spreading it in `mergeConfig`'s function form, are kept once. **Naming** `unstable_middleware` is the default choice: it runs after Metro, so it can't shadow Metro's endpoints. Running ahead of Metro's endpoints is a deliberate choice, so it gets the explicit name `unstable_priorityMiddleware`. Both are singular, like `enhanceMiddleware`: each list composes into one middleware on the server. **Shapes considered** - `{pre: [], post: []}`: every contributor clones and merges a nested object, and a missed spread drops the other list: ``` config.server.middlewares = { ...config.server.middlewares, pre: [auth, ...config.server.middlewares.pre], post: [...config.server.middlewares.post, fallback], }; ``` - One list of `{path, handler, pre: true}` entries: entries can be passed in any order, with pre and post interleaved, so composing contributions from several wrappers to run in a clear order is inconvenient: ``` // wrapper config.server.middlewares = [{handler: auth, pre: true}, ...config.server.middlewares, {handler: fallback}]; // app config.server.middlewares = [...config.server.middlewares, {handler: appPre, pre: true}, {path: '/x', handler: appPost}]; // result: [auth (pre), fallback, appPre (pre), /x appPost] // runs as: auth -> appPre -> Metro -> fallback -> /x appPost ``` **API** ``` type ServerMiddleware = Middleware | Readonly<[path: string, handler: Middleware]>; ``` - `server.unstable_middleware` / `server.unstable_priorityMiddleware` (config, for apps and wrappers) and the `runServer` options of the same names (for frameworks) are `ReadonlyArray<ServerMiddleware>`, defaulting to empty. - Entries mirror `connect`'s `app.use`: a handler, or a `[path, handler]` tuple mounted with connect semantics. - `unstable_middleware` runs only for requests Metro passed on with `next()`. `unstable_priorityMiddleware` runs before Metro. - Request order: framework priority middleware -> config priority middleware -> Metro -> config middleware -> framework middleware. `createConnectMiddleware` applies the config layer, so frameworks calling it directly (Expo CLI) get config middleware without reimplementing the ordering. - `runServer`'s `unstable_onServerCreated(metroServer)` runs after Metro's server is created and before the HTTP server listens. - `mergeConfig` combines `unstable_middleware` and `unstable_priorityMiddleware` as above, instead of replacing them like other arrays, so a later config can't silently drop handlers added by an earlier one. - `server.enhanceMiddleware` keeps its identity default, so code that calls the previous value without a check keeps working. Metro logs a deprecation warning only when it is set to anything other than that default. It is still applied around Metro's middleware. Expo CLI overwrites `enhanceMiddleware` itself, so Expo users see this warning until Expo CLI moves to `unstable_priorityMiddleware`. - `runServer`'s `unstable_extraMiddleware` is kept as a deprecated alias, mounted before `unstable_priorityMiddleware`, so React Native keeps working against this version. `unstable_priorityMiddleware` covers it: its handlers run in the same place, before Metro, and it also accepts `[path, handler]` entries. Changelog: * **[Feature]:** Add `server.unstable_middleware` and `server.unstable_priorityMiddleware` config options, and `runServer` options of the same names, to add middleware after and before Metro's own. * **[Feature]:** `mergeConfig` combines `server.unstable_middleware` and `server.unstable_priorityMiddleware` from both configs: a later config's `unstable_middleware` runs last and its `unstable_priorityMiddleware` runs first. * **[Feature]:** Add `runServer` option `unstable_onServerCreated`, called with Metro's server instance before the HTTP server listens. * **[Deprecated]:** `server.enhanceMiddleware` logs a deprecation warning when set to anything other than its default. * **[Deprecated]:** `runServer`'s `unstable_extraMiddleware` option. Use `unstable_priorityMiddleware`, which runs the same middleware in the same place. Differential Revision: D123474267
meta-codesync
Bot
force-pushed
the
export-D123474267
branch
from
October 8, 2026 17:44
163e8bb to
afb3b67
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary:
Revisits the 2023 deprecation of
server.enhanceMiddleware.Replaces
server.enhanceMiddlewarewith two declarative middleware lists,unstable_middlewareafter Metro andunstable_priorityMiddlewarebefore it, accepted both inmetro.config.jsand byrunServer, andunstable_onServerCreated, which receives Metro's server instance before the HTTP server starts listening (after the bundler is ready whenwaitForBundleris set).Why
Deprecation
runServer, rather than in Metro's config. That assumed whoever adds a handler controls therunServercall. As robhogan pointed out at the time, apps and config wrappers don't; only frameworks do. The deprecation removed their only hook without giving them a replacement, so three years later they still useenhanceMiddleware, and so does Expo CLI itself (see below).Current State
Two different parties add handlers to Metro's server, and each controls a different entry point:
runServerorcreateConnectMiddleware, and pass in the handlers every app needs, such as React Native's dev middleware.runServer.metro.config.jsis all they control. An app composes the wrappers of the libraries it uses:runServer'sunstable_extraMiddleware, only reaches frameworks and only works for them. SoenhanceMiddlewarestayed the only hook for most users, and Expo CLI itself still relies on it, with a TODO acknowledging it. Expo builds its own middleware stack (CORS, debugger, JS inspector), and uses the deprecated hook to mount Metro's middleware at the end of it. It overwrites the app'senhanceMiddlewarewith a wrapper that runs the app's hook first:So the deprecated hook couldn't be removed without breaking Expo, and an app's handlers could only run between Expo's stack and Metro.
enhanceMiddleware. Of 15 internal apps we scanned, 6 overwrote it by accident, removing some endpoints (internal details below). Also, 4 libraries required an undeclaredconnectjust to mount a path. e.g:Passing
metroMiddlewareinstead of theprev(...)call still works locally, but drops everythingprevinstalled.metro.config.jsnorrunServer's options allowed access to Metro's server instance besidesenhanceMiddleware, which was abused as a lifecycle callback in a very dirty way. For example, internally,fb-metro-cliwas using this hack:unstable_onServerCreatedis intended to fix it.Why two flat lists
Extending is a plain array operation, array order is run order, and each list has one obvious merge order: a later contributor wraps the earlier ones, so it appends to
unstable_middlewareand prepends tounstable_priorityMiddleware.mergeConfigcombines the lists in that order, so these are equivalent:Entries copied from the earlier config, e.g. by spreading it in
mergeConfig's function form, are kept once.Naming
unstable_middlewareis the default choice: it runs after Metro, so it can't shadow Metro's endpoints. Running ahead of Metro's endpoints is a deliberate choice, so it gets the explicit nameunstable_priorityMiddleware. Both are singular, likeenhanceMiddleware: each list composes into one middleware on the server.Shapes considered
{pre: [], post: []}: every contributor clones and merges a nested object, and a missed spread drops the other list:{path, handler, pre: true}entries: entries can be passed in any order, with pre and post interleaved, so composing contributions from several wrappers to run in a clear order is inconvenient:API
server.unstable_middleware/server.unstable_priorityMiddleware(config, for apps and wrappers) and therunServeroptions of the same names (for frameworks) areReadonlyArray<ServerMiddleware>, defaulting to empty.connect'sapp.use: a handler, or a[path, handler]tuple mounted with connect semantics.unstable_middlewareruns only for requests Metro passed on withnext().unstable_priorityMiddlewareruns before Metro.createConnectMiddlewareapplies the config layer, so frameworks calling it directly (Expo CLI) get config middleware without reimplementing the ordering.runServer'sunstable_onServerCreated(metroServer)runs after Metro's server is created and before the HTTP server listens.mergeConfigcombinesunstable_middlewareandunstable_priorityMiddlewareas above, instead of replacing them like other arrays, so a later config can't silently drop handlers added by an earlier one.server.enhanceMiddlewarekeeps its identity default, so code that calls the previous value without a check keeps working. Metro logs a deprecation warning only when it is set to anything other than that default. It is still applied around Metro's middleware. Expo CLI overwritesenhanceMiddlewareitself, so Expo users see this warning until Expo CLI moves tounstable_priorityMiddleware.runServer'sunstable_extraMiddlewareis kept as a deprecated alias, mounted beforeunstable_priorityMiddleware, so React Native keeps working against this version.unstable_priorityMiddlewarecovers it: its handlers run in the same place, before Metro, and it also accepts[path, handler]entries.Changelog:
server.unstable_middlewareandserver.unstable_priorityMiddlewareconfig options, andrunServeroptions of the same names, to add middleware after and before Metro's own.mergeConfigcombinesserver.unstable_middlewareandserver.unstable_priorityMiddlewarefrom both configs: a later config'sunstable_middlewareruns last and itsunstable_priorityMiddlewareruns first.runServeroptionunstable_onServerCreated, called with Metro's server instance before the HTTP server listens.server.enhanceMiddlewarelogs a deprecation warning when set to anything other than its default.runServer'sunstable_extraMiddlewareoption. Useunstable_priorityMiddleware, which runs the same middleware in the same place.Differential Revision: D123474267