From 757f2543a9b5bf90b30146df3836b140535265cb Mon Sep 17 00:00:00 2001 From: Vitali Zaidman Date: Thu, 15 Oct 2026 03:56:11 -0700 Subject: [PATCH] Add unstable_middleware, unstable_priorityMiddleware and unstable_onServerCreated, deprecate enhanceMiddleware (#2031) Summary: Revisits the [2023 deprecation of `server.enhanceMiddleware`](https://github.com/facebook/metro/commit/22e85fde85ec454792a1b70eba4253747a2587a9). Replaces `server.enhanceMiddleware` with two declarative middleware lists, `unstable_middleware` after Metro and `unstable_priorityMiddleware` before it, and `unstable_onServerCreated`, which receives Metro's server instance before the HTTP server starts listening (after the bundler is ready when `waitForBundler` is set). All three are accepted both in `metro.config.js` and by `runServer`. ### 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`, in both places, 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.middleware = { ...config.server.middleware, pre: [auth, ...config.server.middleware.pre], post: [...config.server.middleware.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.middleware = [{handler: auth, pre: true}, ...config.server.middleware, {handler: fallback}]; // app config.server.middleware = [...config.server.middleware, {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`, 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. - `server.unstable_onServerCreated(metroServer)` (config) and the `runServer` option of the same name run after Metro's server is created and before the HTTP server listens, the config's first. `createConnectMiddleware` calls the config's, so frameworks calling it directly (Expo CLI) get it too. `mergeConfig` replaces it, like other functions. - The types and docs of each config option and its `runServer` counterpart reference each other. - `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 `server.unstable_onServerCreated` config option and `runServer` option of the same name, called with Metro's server instance before the HTTP server listens. The config's runs first. * **[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. Reviewed By: GijsWeterings Differential Revision: D123474267 --- docs/API.md | 8 +- docs/Configuration.md | 42 +++- packages/metro-config/API.md | 5 + .../src/__tests__/mergeConfig-test.js | 32 +++- .../metro-config/src/defaults/defaults.js | 6 +- packages/metro-config/src/defaults/index.js | 9 +- packages/metro-config/src/loadConfig.js | 50 ++++- packages/metro-config/src/types.js | 18 +- packages/metro/API.md | 3 + packages/metro/src/index.flow.js | 79 ++++++-- .../__tests__/server-extraMiddleware-test.js | 179 ++++++++++++++++++ 11 files changed, 407 insertions(+), 24 deletions(-) create mode 100644 packages/metro/src/integration_tests/__tests__/server-extraMiddleware-test.js diff --git a/docs/API.md b/docs/API.md index bbf24ceba1..f83f30d767 100644 --- a/docs/API.md +++ b/docs/API.md @@ -66,10 +66,16 @@ Bundles `entry` for the given `platform`, and saves it to location `out`. If `so ### `async runServer(config, )` -**Basic options:** `host`, `port`, `secureServerOptions`, `secure (DEPRECATED)`, `secureKey (DEPRECATED)`, `secureCert (DEPRECATED)` +**Basic options:** `host`, `port`, `secureServerOptions`, `unstable_middleware`, `unstable_priorityMiddleware`, `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. + +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. + +`unstable_onServerCreated` is called with Metro's server instance before the HTTP server starts listening, after [`server.unstable_onServerCreated`](./Configuration.md#unstable_onservercreated-experimental). + `secure`, `secureKey`, `secureCert` are now deprecated and will be removed in a later release. The presence of `secureServerOptions`, along with its options will make Metro run over https. ### `createConnectMiddleware(config, )` diff --git a/docs/Configuration.md b/docs/Configuration.md index 8f057477f8..a0797d3cd6 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -687,7 +687,9 @@ Whether we should enable CMD+R hotkey for refreshing the bundle. Type: `(Middleware, MetroServer) => Middleware` -A function that allows attaching custom [`connect`](https://www.npmjs.com/package/connect) middleware to Metro. For example: +A function that allows attaching custom [`connect`](https://www.npmjs.com/package/connect) middleware to Metro. Metro logs a deprecation warning when this option is set to anything other than its default. Use [`unstable_middleware`](#unstable_middleware-experimental) or [`unstable_priorityMiddleware`](#unstable_prioritymiddleware-experimental) instead, and [`unstable_onServerCreated`](#unstable_onservercreated-experimental) to access the Metro server instance. + +For example: :::tip You can use [`connect()`](https://www.npmjs.com/package/connect#mount-middleware) as a utility to extend the base `metroMiddleware` and to mount additional middleware handlers. @@ -703,6 +705,42 @@ enhanceMiddleware: (metroMiddleware: Middleware, metroServer: MetroServer) => { The `Middleware` type is an alias for [`connect.HandleFunction`](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/876b9ec96ba02d0c84b1e49af5890c8f5aa2dfe3/types/connect/index.d.ts#L29). +#### `unstable_middleware`
Experimental
+ +Type: `Array` + +Custom [`connect`](https://www.npmjs.com/package/connect) middleware list allowing integrators to extend Metro's dev server endpoints, mounted in array order. A `[path, handler]` entry mounts the handler with `connect` semantics: it matches the path as a prefix and strips it from `req.url`, keeping the full URL in `req.originalUrl`. + +[`runServer`](./API.md#async-runserverconfig-options) also takes an `unstable_middleware` option, which runs after these handlers. + +```js +server: { + unstable_middleware: [ + ['/custom-endpoint', customEndpointMiddleware()], + notFoundPage, + ], + unstable_priorityMiddleware: [corsMiddleware], +}, +``` + +#### `unstable_priorityMiddleware`
Experimental
+ +Type: `Array` + +Like [`unstable_middleware`](#unstable_middleware-experimental), but runs before Metro. These handlers see the URL before [`rewriteRequestUrl`](#rewriterequesturl) is applied. + +Each priority middleware handler must call `next()` for every request it doesn't fully handle itself. Otherwise the request never reaches Metro, which builds and serves bundles, source maps and assets, symbolicates stack traces, and serves its other dev server endpoints. + +[`runServer`](./API.md#async-runserverconfig-options) also takes an `unstable_priorityMiddleware` option, which runs before these handlers. + +#### `unstable_onServerCreated`
Experimental
+ +Type: `(MetroServer) => void` + +Called with Metro's server instance when [`runServer`](./API.md#async-runserverconfig-options) or [`createConnectMiddleware`](./API.md#createconnectmiddlewareconfig-options) creates it, after the bundler is ready when `waitForBundler` is set, and before `runServer` starts the HTTP server. + +`runServer` also takes an `unstable_onServerCreated` option, which is called after this one. + #### `rewriteRequestUrl` Type: `string => string` @@ -830,6 +868,8 @@ 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. + #### Merging Example ```typescript diff --git a/packages/metro-config/API.md b/packages/metro-config/API.md index 58a8d84074..3df6adb4e7 100644 --- a/packages/metro-config/API.md +++ b/packages/metro-config/API.md @@ -187,6 +187,9 @@ export type ServerConfigT = { forwardClientLogs: boolean; port: number; rewriteRequestUrl: (url: string) => string; + unstable_middleware: ReadonlyArray; + unstable_onServerCreated: (metroServer: Server) => void; + unstable_priorityMiddleware: ReadonlyArray; unstable_serverRoot: null | undefined | string; useGlobalHotkey: boolean; verifyConnections: boolean; @@ -200,6 +203,8 @@ export type ServerConfigT = { }; }; +export type ServerMiddleware = Middleware | Readonly<[path: string, handler: Middleware]>; + export type SymbolicatorConfigT = { customizeFrame: (frame: { readonly file: null | undefined | string; diff --git a/packages/metro-config/src/__tests__/mergeConfig-test.js b/packages/metro-config/src/__tests__/mergeConfig-test.js index 502c678417..89a7121ed5 100644 --- a/packages/metro-config/src/__tests__/mergeConfig-test.js +++ b/packages/metro-config/src/__tests__/mergeConfig-test.js @@ -9,7 +9,7 @@ * @oncall react_native */ -import type {InputConfigT} from '../types'; +import type {InputConfigT, ServerMiddleware} from '../types'; import type {CustomResolver} from 'metro-resolver'; import {mergeConfig} from '../loadConfig'; @@ -205,6 +205,36 @@ describe('mergeConfig', () => { }); }); + describe('server middleware merging', () => { + const a: ServerMiddleware = (req, res, next) => next(); + const b: ServerMiddleware = (req, res, next) => next(); + const c: ServerMiddleware = ['/c', (req, res, next) => next()]; + + test('later configs wrap earlier ones', () => { + const result = mergeConfig( + {server: {unstable_priorityMiddleware: [a], unstable_middleware: [a]}}, + {server: {unstable_priorityMiddleware: [b], unstable_middleware: [b]}}, + ); + expect(result.server?.unstable_priorityMiddleware).toEqual([b, a]); + expect(result.server?.unstable_middleware).toEqual([a, b]); + }); + + test('keeps entries copied from the base config once', () => { + const result = mergeConfig( + {server: {unstable_priorityMiddleware: [a, c]}}, + (config: InputConfigT) => ({ + server: { + unstable_priorityMiddleware: [ + b, + ...(config.server?.unstable_priorityMiddleware ?? []), + ], + }, + }), + ); + expect(result.server?.unstable_priorityMiddleware).toEqual([b, a, c]); + }); + }); + 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/defaults.js b/packages/metro-config/src/defaults/defaults.js index f0e7588bbf..96d04763ed 100644 --- a/packages/metro-config/src/defaults/defaults.js +++ b/packages/metro-config/src/defaults/defaults.js @@ -9,7 +9,7 @@ * @oncall react_native */ -import type {PerfLogger, RootPerfLogger} from '../types'; +import type {ConfigT, PerfLogger, RootPerfLogger} from '../types'; export {default as defaultCreateModuleIdFactory} from './createModuleIdFactory'; @@ -63,6 +63,10 @@ export const platforms: Array = ['ios', 'android', 'windows', 'web']; export const DEFAULT_METRO_MINIFIER_PATH = 'metro-minify-terser'; +// Metro warns about `server.enhanceMiddleware` unless it is this function. +export const defaultEnhanceMiddleware: ConfigT['server']['enhanceMiddleware'] = + (middleware, _server) => middleware; + export const noopPerfLoggerFactory = (): RootPerfLogger => { class Logger { start() {} diff --git a/packages/metro-config/src/defaults/index.js b/packages/metro-config/src/defaults/index.js index 3da8b3b439..8c4a51a945 100644 --- a/packages/metro-config/src/defaults/index.js +++ b/packages/metro-config/src/defaults/index.js @@ -17,6 +17,7 @@ import { assetExts, assetResolutions, defaultCreateModuleIdFactory, + defaultEnhanceMiddleware, noopPerfLoggerFactory, platforms, sourceExts, @@ -75,10 +76,16 @@ const getDefaultValues = (projectRoot: ?string): ConfigT => ({ }, server: { - enhanceMiddleware: (middleware, _) => middleware, + enhanceMiddleware: defaultEnhanceMiddleware, forwardClientLogs: true, port: 8081, rewriteRequestUrl: url => url, + // Keep empty to not shadows a Metro builtin or user entry paths + // merged with user middleware as [...defaults, ...user] + unstable_middleware: [], + unstable_onServerCreated: () => {}, + // merged with user middleware as [...user, ...defaults] + unstable_priorityMiddleware: [], unstable_serverRoot: null, useGlobalHotkey: true, verifyConnections: false, diff --git a/packages/metro-config/src/loadConfig.js b/packages/metro-config/src/loadConfig.js index 551b38216a..59c4b0e298 100644 --- a/packages/metro-config/src/loadConfig.js +++ b/packages/metro-config/src/loadConfig.js @@ -9,7 +9,12 @@ * @oncall react_native */ -import type {ConfigT, InputConfigT, YargArguments} from './types'; +import type { + ConfigT, + InputConfigT, + ServerMiddleware, + YargArguments, +} from './types'; import getDefaultConfig from './defaults'; import validConfig from './defaults/validConfig'; @@ -152,6 +157,7 @@ function mergeConfigObjects( server: { ...base.server, ...overrides.server, + ...mergeServerHandlers(base.server, overrides.server), // $FlowFixMe[exponential-spread] ...(base.server?.tls != null ? {tls: base.server?.tls} : null), // only override base tls config with false or an object @@ -195,6 +201,48 @@ function mergeConfigObjects( }; } +// Later configs wrap earlier ones: their `unstable_middleware` runs last and +// their `unstable_priorityMiddleware` runs first. +function mergeServerHandlers( + base: InputConfigT['server'], + overrides: InputConfigT['server'], +): { + unstable_middleware?: ReadonlyArray, + unstable_priorityMiddleware?: ReadonlyArray, +} { + const merged: { + unstable_middleware?: ReadonlyArray, + unstable_priorityMiddleware?: ReadonlyArray, + } = {}; + const middleware = concatUnique( + base?.unstable_middleware, + overrides?.unstable_middleware, + ); + if (middleware != null) { + merged.unstable_middleware = middleware; + } + const priorityMiddleware = concatUnique( + overrides?.unstable_priorityMiddleware, + base?.unstable_priorityMiddleware, + ); + if (priorityMiddleware != null) { + merged.unstable_priorityMiddleware = priorityMiddleware; + } + return merged; +} + +// An entry in both lists (e.g. copied by spreading the base config) is kept +// once, at its first position. +function concatUnique( + first: ?ReadonlyArray, + second: ?ReadonlyArray, +): ?ReadonlyArray { + if (first == null || second == null) { + return first ?? second; + } + return [...new Set([...first, ...second])]; +} + async function mergeConfigAsync( baseConfig: Promise, ...reversedConfigs: ReadonlyArray< diff --git a/packages/metro-config/src/types.js b/packages/metro-config/src/types.js index 2d359280a3..dbc936bdf2 100644 --- a/packages/metro-config/src/types.js +++ b/packages/metro-config/src/types.js @@ -60,6 +60,11 @@ export type GetTransformOptions = ( export type Middleware = HandleFunction; +export type ServerMiddleware = + | Middleware + // Mounted with connect semantics: prefix match, stripped from `req.url`. + | Readonly<[path: string, handler: Middleware]>; + type PerfAnnotations = Partial<{ string: Readonly<{[key: string]: string}>, int: Readonly<{[key: string]: number}>, @@ -184,7 +189,7 @@ type MetalConfigT = { type CacheStoresConfigT = ReadonlyArray>>; type ServerConfigT = { - /** @deprecated */ + /** @deprecated Use `unstable_middleware`, `unstable_priorityMiddleware`, or `unstable_onServerCreated` */ enhanceMiddleware: ( middleware: Middleware, server: MetroServer, @@ -192,6 +197,17 @@ type ServerConfigT = { forwardClientLogs: boolean, port: number, rewriteRequestUrl: (url: string) => string, + // Only receives requests that Metro endpoints did not handle. + // `runServer`'s `unstable_middleware` option runs after these handlers. + unstable_middleware: ReadonlyArray, + // Called with Metro's server instance before the HTTP server listens. + // `runServer`'s `unstable_onServerCreated` option is called after it. + unstable_onServerCreated: (metroServer: MetroServer) => void, + // Runs before Metro endpoints, which only receive a request if these + // handlers call `next()`. + // `runServer`'s `unstable_priorityMiddleware` option runs before these + // handlers. + unstable_priorityMiddleware: ReadonlyArray, unstable_serverRoot: ?string, useGlobalHotkey: boolean, verifyConnections: boolean, diff --git a/packages/metro/API.md b/packages/metro/API.md index e955a43129..b4afc4df23 100644 --- a/packages/metro/API.md +++ b/packages/metro/API.md @@ -280,6 +280,9 @@ export type RunServerOptions = Readonly<{ secureCert?: string | undefined; secureKey?: string | undefined; unstable_extraMiddleware?: ReadonlyArray | undefined; + unstable_middleware?: ReadonlyArray | undefined; + unstable_onServerCreated?: ((metroServer: MetroServer) => void) | undefined; + unstable_priorityMiddleware?: ReadonlyArray | undefined; waitForBundler?: boolean | undefined; watch?: boolean | undefined; websocketEndpoints?: Readonly<{[path: string]: Server_4}> | undefined; diff --git a/packages/metro/src/index.flow.js b/packages/metro/src/index.flow.js index 4b37a9dec5..a580ef8dc0 100644 --- a/packages/metro/src/index.flow.js +++ b/packages/metro/src/index.flow.js @@ -13,13 +13,14 @@ import type {AssetData} from './Assets'; import type {ReadOnlyGraph} from './DeltaBundler'; import type {ServerOptions} from './Server'; import type {BuildOptions, OutputOptions, RequestOptions} from './shared/types'; -import type {HandleFunction} from 'connect'; +import type {HandleFunction, Server as ConnectServer} from 'connect'; import type {TransformProfile} from 'metro-babel-transformer'; import type { ConfigT, InputConfigT, MetroConfig, Middleware, + ServerMiddleware, } from 'metro-config'; import type {CustomResolverOptions} from 'metro-resolver'; import type {CustomTransformOptions} from 'metro-transform-worker'; @@ -48,6 +49,7 @@ import { mergeConfig, resolveConfig, } from 'metro-config'; +import {defaultEnhanceMiddleware} from 'metro-config/private/defaults/defaults'; import {Terminal} from 'metro-core'; import fs from 'node:fs'; import http from 'node:http'; @@ -80,7 +82,13 @@ export type RunServerOptions = Readonly<{ secure?: boolean, // deprecated secureCert?: string, // deprecated secureKey?: string, // deprecated - unstable_extraMiddleware?: ReadonlyArray, + unstable_extraMiddleware?: ReadonlyArray, // deprecated + // Runs after `config.server.unstable_middleware`. + unstable_middleware?: ReadonlyArray, + // Called after `config.server.unstable_onServerCreated`. + unstable_onServerCreated?: (metroServer: MetroServer) => void, + // Runs before `config.server.unstable_priorityMiddleware`. + unstable_priorityMiddleware?: ReadonlyArray, waitForBundler?: boolean, watch?: boolean, websocketEndpoints?: Readonly<{ @@ -235,11 +243,20 @@ export const createConnectMiddleware = async function ( options?: RunMetroOptions, ): Promise { const metroServer = await runMetro(config, options); + config.server.unstable_onServerCreated(metroServer); let enhancedMiddleware: Middleware = metroServer.processRequest; // Enhance the resulting middleware using the config options if (config.server.enhanceMiddleware) { + if (config.server.enhanceMiddleware !== defaultEnhanceMiddleware) { + logDeprecationWarning( + 'The `server.enhanceMiddleware` config option is deprecated and will be ' + + 'removed in a later release. Please use `server.unstable_middleware` ' + + 'or `server.unstable_priorityMiddleware` instead, and ' + + '`server.unstable_onServerCreated` to access the Metro server instance.', + ); + } enhancedMiddleware = config.server.enhanceMiddleware( enhancedMiddleware, metroServer, @@ -267,7 +284,11 @@ export const createConnectMiddleware = async function ( }); }, metroServer, - middleware: enhancedMiddleware, + middleware: composeMiddleware( + config.server.unstable_priorityMiddleware, + enhancedMiddleware, + config.server.unstable_middleware, + ), async end(): Promise { await metroServer.end(); }, @@ -288,7 +309,10 @@ export const runServer = async ( secure, //deprecated secureCert, // deprecated secureKey, // deprecated - unstable_extraMiddleware, + unstable_extraMiddleware = [], // deprecated + unstable_middleware = [], + unstable_onServerCreated, + unstable_priorityMiddleware = [], waitForBundler = false, websocketEndpoints: userWebsocketEndpoints = {}, watch, @@ -296,20 +320,12 @@ export const runServer = async ( await earlyPortCheck(host, config.server.port); if (secure != null || secureCert != null || secureKey != null) { - // eslint-disable-next-line no-console - console.warn( - util.styleText(['inverse', 'yellow', 'bold'], ' DEPRECATED '), + logDeprecationWarning( 'The `secure`, `secureCert`, and `secureKey` options are now deprecated. ' + 'Please use the `secureServerOptions` object instead to pass options to ' + "Metro's https development server, or `config.server.tls` in Metro's configuration", ); } - // Lazy require - // eslint-disable-next-line import/no-commonjs - const connect = require('connect'); - - const serverApp = connect(); - const { middleware, end: endMiddleware, @@ -320,11 +336,13 @@ export const runServer = async ( watch, }); - for (const handler of unstable_extraMiddleware ?? []) { - serverApp.use(handler); - } + unstable_onServerCreated?.(metroServer); - serverApp.use(middleware); + const serverApp = composeMiddleware( + [...unstable_extraMiddleware, ...unstable_priorityMiddleware], + middleware, + unstable_middleware, + ); let httpServer; @@ -590,3 +608,30 @@ async function earlyPortCheck(host: void | string, port: number) { await new Promise(resolve => server.close(() => resolve())); } } + +function logDeprecationWarning(message: string): void { + // eslint-disable-next-line no-console + console.warn( + util.styleText(['inverse', 'yellow', 'bold'], ' DEPRECATED '), + message, + ); +} + +function composeMiddleware( + before: ReadonlyArray, + inner: Middleware, + after: ReadonlyArray, +): ConnectServer { + // Lazy require + // eslint-disable-next-line import/no-commonjs + const connect = require('connect'); + const app = connect(); + for (const entry of [...before, inner, ...after]) { + if (typeof entry === 'function') { + app.use(entry); + } else { + app.use(entry[0], entry[1]); + } + } + return app; +} diff --git a/packages/metro/src/integration_tests/__tests__/server-extraMiddleware-test.js b/packages/metro/src/integration_tests/__tests__/server-extraMiddleware-test.js new file mode 100644 index 0000000000..b8ff935dd8 --- /dev/null +++ b/packages/metro/src/integration_tests/__tests__/server-extraMiddleware-test.js @@ -0,0 +1,179 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @oncall react_native + */ + +'use strict'; + +const Metro = require('../../..'); +const MetroServer = require('../../Server').default; + +jest.useRealTimers(); +jest.setTimeout(60 * 1000); + +// Workaround for https://github.com/nodejs/node/issues/54484: +// Fetch with {Connection: 'close'} to prevent Node reusing connections across tests +const fetchAndClose = (path: string) => + fetch(path, { + headers: {Connection: 'close'}, + }); + +const BUNDLE_PATH = '/TestBundle.bundle?platform=ios&dev=true&minify=false'; + +describe('runServer custom middleware', () => { + let httpServer; + let serverClosedPromise; + + const loadConfig = (server: {...} = {}) => + Metro.loadConfig({config: require.resolve('../metro.config.js')}, {server}); + + async function startServer(config: {...}, options: {...} = {}) { + let onCloseResolve; + serverClosedPromise = new Promise(resolve => (onCloseResolve = resolve)); + ({httpServer} = await Metro.runServer(config, { + reporter: {update() {}}, + onClose: () => { + onCloseResolve(); + }, + ...options, + })); + } + + // Reads the body: undici crashes if the server closes the socket while an + // unread body is paused. + const request = async (pathname: string) => { + const response = await fetchAndClose( + 'http://localhost:' + httpServer.address().port + pathname, + ); + return {status: response.status, text: await response.text()}; + }; + + afterEach(async () => { + if (httpServer != null) { + httpServer.close(); + await serverClosedPromise; + httpServer = null; + } + }); + + test('mounts config middleware inside framework middleware', async () => { + const calls = []; + const record = + (name: string) => + (req, res, next): void => { + calls.push(name); + next(); + }; + const config = await loadConfig({ + unstable_priorityMiddleware: [record('config priority')], + unstable_middleware: [record('config middleware')], + }); + await startServer(config, { + unstable_priorityMiddleware: [record('framework priority')], + unstable_middleware: [ + record('framework middleware'), + (req, res) => { + res.end('not found'); + }, + ], + }); + + expect((await request(BUNDLE_PATH)).status).toBe(200); + expect(calls).toEqual(['framework priority', 'config priority']); + + calls.length = 0; + expect((await request('/unhandled')).text).toBe('not found'); + expect(calls).toEqual([ + 'framework priority', + 'config priority', + 'config middleware', + 'framework middleware', + ]); + }); + + test('mounts deprecated unstable_extraMiddleware before unstable_priorityMiddleware', async () => { + const calls = []; + await startServer(await loadConfig(), { + unstable_extraMiddleware: [ + (req, res, next) => { + calls.push('extra'); + next(); + }, + ], + unstable_priorityMiddleware: [ + (req, res, next) => { + calls.push('framework priority'); + next(); + }, + ], + }); + + expect((await request(BUNDLE_PATH)).status).toBe(200); + expect(calls).toEqual(['extra', 'framework priority']); + }); + + test('mounts entries with a path using connect semantics', async () => { + const config = await loadConfig({ + unstable_priorityMiddleware: [ + [ + '/custom', + (req, res) => { + res.end(`${req.url} ${req.originalUrl}`); + }, + ], + ], + }); + await startServer(config); + + expect((await request('/custom/a')).text).toBe('/a /custom/a'); + expect((await request(BUNDLE_PATH)).status).toBe(200); + }); + + test('calls config, then runServer unstable_onServerCreated, before listening', async () => { + const events = []; + await startServer( + await loadConfig({ + unstable_onServerCreated: metroServer => { + events.push(['config', metroServer instanceof MetroServer]); + }, + }), + { + unstable_onServerCreated: metroServer => { + events.push(['runServer', metroServer instanceof MetroServer]); + }, + onReady: () => { + events.push('ready'); + }, + }, + ); + + expect(events).toEqual([['config', true], ['runServer', true], 'ready']); + }); + + test('does not warn for the default server.enhanceMiddleware', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + await startServer(await loadConfig()); + + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + test('warns when server.enhanceMiddleware is set', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + await startServer( + await loadConfig({enhanceMiddleware: middleware => middleware}), + ); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('DEPRECATED'), + expect.stringContaining('`server.enhanceMiddleware`'), + ); + expect((await request(BUNDLE_PATH)).status).toBe(200); + warn.mockRestore(); + }); +});