feat(base-driver): add httpServer.frontRouter for global extension middleware (#22632)

This commit is contained in:
Mykola Mokhnach
2026-08-17 08:22:11 +02:00
committed by GitHub
parent 33459705ed
commit f06226b7bb
9 changed files with 106 additions and 16 deletions
@@ -477,9 +477,9 @@ capability.
You probably don't normally need to update the Appium server object (which is an
[Express](https://expressjs.com/) server having already been
[configured](https://github.com/appium/appium/blob/master/packages/base-driver/lib/express/server.js)
in a variety of ways). But, for example, you could add new Express middleware to the server to
support your plugin's requirements. To update the server you must implement the `static async
updateServer` method in your class. This method takes three parameters:
in a variety of ways). But, for example, you could add new routes to the server to support your
plugin's requirements. To update the server you must implement the `static async updateServer`
method in your class. This method takes three parameters:
* `expressApp`: the Express app object
* `httpServer`: the Node HTTP server object
@@ -492,6 +492,25 @@ results you'll need to test! Warning: this should be considered an advanced feat
knowledge of Express, as well as the care not to do anything that could affect the operation of
other parts of the Appium server!
`updateServer` runs *after* Appium's own routes are registered, so `expressApp.use(...)` here
won't see requests to paths Appium already owns — Express matches middleware and routes in
registration order. For middleware that must see requests to those routes too (e.g. logging or
auth), use `httpServer.frontRouter` instead: Appium mounts this
[Router](https://expressjs.com/en/5x/api/router/) before any route is registered, so anything
attached to it — even from inside `updateServer` — still runs ahead of route matching. Note this
is still *after* Appium's own baseline middleware (CORS, WebSocket upgrade handling, body
parsing, etc.), so it won't see requests Appium itself has already rejected, e.g. a CORS
preflight failure or a body that failed to parse:
```js
static async updateServer(expressApp, httpServer, cliArgs) {
httpServer.frontRouter.use((req, res, next) => {
console.log(`Incoming request: ${req.method} ${req.url}`);
next();
});
}
```
### Handle unexpected session shutdown
When developing a plugin you may want to add some cleanup logic for when a session ends. You would
@@ -159,6 +159,11 @@ describe('FakePlugin w/ FakeDriver via HTTP', function () {
// arg got through.
assert.deepStrictEqual((await axios.post(`http://${TEST_HOST}:${port}/cliArgs`)).data.usePlugins, res);
});
it('should let updateServer intercept requests to routes Appium itself owns via httpServer.frontRouter', async function () {
// /status is a built-in route, registered before updateServer runs
const res = await axios.get(`http://${TEST_HOST}:${port}/status`);
assert.strictEqual(res.headers['x-fake-plugin-pre-server'], 'true');
});
it('should modify the method map with new commands', async function () {
const driver = await wdio(wdOpts as any);
const {sessionId} = driver;
+9 -1
View File
@@ -15,7 +15,7 @@ import type {
} from '@appium/types';
import bodyParser from 'body-parser';
import express from 'express';
import type {Express, RequestHandler} from 'express';
import type {Express, RequestHandler, Router} from 'express';
import methodOverride from 'method-override';
import {DEFAULT_BASE_PATH} from '../constants';
@@ -74,6 +74,8 @@ export interface ConfigureServerOpts {
extraMethodMap?: MethodMap<ExternalDriver>;
webSocketsMapping?: StringRecord;
useLegacyUpgradeHandler?: boolean;
/** Router mounted before any route is registered; see {@linkcode AppiumServer.frontRouter}. */
frontRouter: Router;
}
/** @internal */
@@ -147,6 +149,7 @@ export async function server(opts: ServerOpts): Promise<AppiumServer> {
webSocketsMapping: appiumServer.webSocketsMapping,
useLegacyUpgradeHandler,
registerTestPages,
frontRouter: appiumServer.frontRouter,
} as ConfigureServerInternalOpts);
// allow extensions to update the app and http server objects
for (const updater of serverUpdaters) {
@@ -188,6 +191,7 @@ export function configureServer(opts: ConfigureServerOpts): void {
extraMethodMap = {},
webSocketsMapping = {},
useLegacyUpgradeHandler = true,
frontRouter,
} = opts;
const {registerTestPages} = opts as ConfigureServerInternalOpts;
const basePath = normalizeBasePath(rawBasePath);
@@ -221,6 +225,9 @@ export function configureServer(opts: ConfigureServerOpts): void {
// set up start logging (which depends on bodyParser doing its thing)
app.use(startLogFormatter);
// mounted before any route, so middleware added later (e.g. via `updateServer`) still runs first
app.use(frontRouter);
addRoutes(app, {basePath, extraMethodMap});
// Express only passes errors to an error handler registered after the middleware
@@ -307,6 +314,7 @@ function configureHttp({
}: ConfigureHttpOpts): AppiumServer {
const appiumServer = httpServer as unknown as AppiumServer;
appiumServer.webSocketsMapping = {};
appiumServer.frontRouter = express.Router();
appiumServer.addWebSocketHandler = addWebSocketHandler;
appiumServer.removeWebSocketHandler = removeWebSocketHandler;
appiumServer.removeAllWebSocketHandlers = removeAllWebSocketHandlers;
@@ -9,6 +9,9 @@ import {configureServer, normalizeBasePath, server} from '../../../lib/express/s
import {routeConfiguringFunction} from '../../../lib/protocol/protocol';
import {registerTestPages} from '../../../lib/test-pages';
// stand-in for the router `configureHttp` normally creates
const fakeFrontRouter = {} as any;
const newMethodMap = {
'/session/:sessionId/fake': {
GET: {command: 'fakeGet'},
@@ -57,8 +60,8 @@ describe('server configuration', function () {
it('should actually use the middleware', function () {
const app = fakeApp() as any;
const configureRoutes = () => {};
configureServer({app, addRoutes: configureRoutes});
assert.strictEqual(app.use.callCount, 11);
configureServer({app, addRoutes: configureRoutes, frontRouter: fakeFrontRouter});
assert.strictEqual(app.use.callCount, 12);
assert.strictEqual(app.all.callCount, 0);
});
@@ -66,8 +69,8 @@ describe('server configuration', function () {
const app = fakeApp() as any;
const configureRoutes = () => {};
// @ts-expect-error registerTestPages is not normally used in this way
configureServer({app, addRoutes: configureRoutes, registerTestPages});
assert.strictEqual(app.use.callCount, 15);
configureServer({app, addRoutes: configureRoutes, frontRouter: fakeFrontRouter, registerTestPages});
assert.strictEqual(app.use.callCount, 16);
assert.strictEqual(app.all.callCount, 4);
});
@@ -76,8 +79,8 @@ describe('server configuration', function () {
const app2 = fakeApp() as any;
const driver = fakeDriver();
const addRoutes = routeConfiguringFunction(driver as any);
configureServer({app: app1, addRoutes});
configureServer({app: app2, addRoutes, extraMethodMap: newMethodMap});
configureServer({app: app1, addRoutes, frontRouter: fakeFrontRouter});
configureServer({app: app2, addRoutes, frontRouter: fakeFrontRouter, extraMethodMap: newMethodMap});
assert.strictEqual(app2.totalCount(), app1.totalCount() + 2);
});
@@ -86,11 +89,47 @@ describe('server configuration', function () {
const app2 = fakeApp() as any;
const driver = fakeDriver();
const addRoutes = routeConfiguringFunction(driver as any);
configureServer({app: app1, addRoutes});
configureServer({app: app2, addRoutes, extraMethodMap: [] as any});
configureServer({app: app1, addRoutes, frontRouter: fakeFrontRouter});
configureServer({app: app2, addRoutes, frontRouter: fakeFrontRouter, extraMethodMap: [] as any});
assert.strictEqual(app2.totalCount(), app1.totalCount());
});
it('should mount the front router before routes are registered', function () {
const callOrder: string[] = [];
const app = fakeApp() as any;
app.use = sandbox.spy((mw: any) => {
if (mw === fakeFrontRouter) {
callOrder.push('frontRouter');
}
});
const configureRoutes = () => callOrder.push('route');
configureServer({app, addRoutes: configureRoutes, frontRouter: fakeFrontRouter});
assert.deepStrictEqual(callOrder, ['frontRouter', 'route']);
});
it('should let updateServer intercept requests to routes Appium owns via httpServer.frontRouter', async function () {
const driver = fakeDriver();
const addRoutes = routeConfiguringFunction(driver as any);
// reaches httpServer.frontRouter from the existing updateServer hook
const interceptingUpdater = async (_app: any, httpServer: any) => {
httpServer.frontRouter.use((_req: any, res: any, next: any) => {
res.set('x-pre-server', 'true');
next();
});
};
const _server = await server({
routeConfiguringFunction: addRoutes,
port,
serverUpdaters: [interceptingUpdater],
});
try {
const res = await fetch(`http://127.0.0.1:${port}/status`);
assert.strictEqual(res.headers.get('x-pre-server'), 'true');
} finally {
await _server.close();
}
});
it('should allow plugins to update the server', async function () {
const driver = fakeDriver();
const _server = await server({
+6 -1
View File
@@ -94,7 +94,7 @@ export class FakePlugin extends BasePlugin {
static async updateServer(
expressApp: Application,
_httpServer: AppiumServer,
httpServer: AppiumServer,
cliArgs: Record<string, unknown>,
): Promise<void> {
expressApp.all('/fake', FakePlugin.fakeRoute);
@@ -102,6 +102,11 @@ export class FakePlugin extends BasePlugin {
expressApp.all('/cliArgs', (req, res) => {
res.send(JSON.stringify(cliArgs));
});
// global middleware, exercised via httpServer.frontRouter
httpServer.frontRouter.use((_req, res, next) => {
res.set('x-fake-plugin-pre-server', 'true');
next();
});
}
async onIpcInit() {
@@ -41,8 +41,9 @@ describe('fake plugin', function () {
it('should update an express app with a fake route', async function () {
const app = new FakeExpress();
const httpServer = {frontRouter: {use: () => {}}};
assert.rejects(() => app.get('/fake'));
await FakePlugin.updateServer(app as any, {} as any, {});
await FakePlugin.updateServer(app as any, httpServer as any, {});
assert.equal(await app.get('/fake'), JSON.stringify({fake: 'fakeResponse'}));
});
+5
View File
@@ -323,6 +323,11 @@ export interface ExternalDriver<
*/
export interface DriverStatic<T extends Driver> {
baseVersion: string;
/**
* Allows a driver to modify the Appium server instance. Routes are already registered by the
* time this runs; for middleware that must see every request, use `httpServer.frontRouter`
* (see {@linkcode AppiumServer.frontRouter}).
*/
updateServer?: UpdateServerCallback;
newMethodMap?: MethodMap<T>;
/**
+3 -1
View File
@@ -10,7 +10,9 @@ import type {Class, StringRecord} from './util';
*/
export interface PluginStatic<P extends Plugin> {
/**
* Allows a plugin to modify the Appium server instance.
* Allows a plugin to modify the Appium server instance. Routes are already registered by the
* time this runs; for middleware that must see every request, use `httpServer.frontRouter`
* (`AppiumServerExtension` in `@appium/types`).
*/
updateServer?: UpdateServerCallback;
/**
+7 -1
View File
@@ -1,6 +1,6 @@
import type {Server as HTTPServer} from 'node:http';
import type {Express} from 'express';
import type {Express, Router} from 'express';
import type {Server as WSServer} from 'ws';
import type {ServerArgs} from './config';
@@ -41,6 +41,12 @@ export interface AppiumServerExtension {
webSocketsMapping: Record<string, WSServer>;
/** Returns true if the server operates via HTTPS protocol */
isSecure(): boolean;
/**
* A {@linkcode Router} mounted before any route is registered. Use it from
* {@linkcode UpdateServerCallback} to add middleware that must see every request, including
* ones matched by routes registered elsewhere. See https://github.com/appium/appium/issues/17411
*/
frontRouter: Router;
}
export type {WSServer};