From 8c7ef766060441d92ec0fc5789281a1bacf09356 Mon Sep 17 00:00:00 2001 From: chimurai <655241+chimurai@users.noreply.github.com> Date: Mon, 11 Apr 2022 23:30:30 +0200 Subject: [PATCH] refactor(handlers): refactor to plugins [BREAKING CHANGE] (#745) * feat(debug): debug proxy server errors * refactor(handlers): migrate error, response and log handlers to plugins [BREAKING CHANGE] * refactor(proxy events): refactor to proxy-events plugin [BREAKING CHANGE] --- README.md | 35 +++-- examples/response-interceptor/index.js | 28 ++-- package.json | 2 + recipes/proxy-events.md | 30 ++-- src/_handlers.ts | 87 ----------- src/debug.ts | 6 + src/http-proxy-middleware.ts | 38 ++--- .../default/debug-proxy-errors-plugin.ts | 65 ++++++++ src/plugins/default/error-response-plugin.ts | 19 +++ src/plugins/default/index.ts | 4 + src/plugins/default/logger-plugin.ts | 25 +++ src/plugins/default/proxy-events.ts | 31 ++++ src/status-code.ts | 21 +++ src/types.ts | 82 +++++----- test/e2e/express-error-middleware.spec.ts | 24 +++ test/e2e/http-proxy-middleware.spec.ts | 56 +++---- test/e2e/response-interceptor.spec.ts | 22 ++- test/e2e/test-kit.ts | 4 +- test/types.spec.ts | 44 ++---- test/unit/handlers.spec.ts | 143 ------------------ test/unit/status-code.spec.ts | 19 +++ yarn.lock | 19 +++ 22 files changed, 401 insertions(+), 403 deletions(-) delete mode 100644 src/_handlers.ts create mode 100644 src/debug.ts create mode 100644 src/plugins/default/debug-proxy-errors-plugin.ts create mode 100644 src/plugins/default/error-response-plugin.ts create mode 100644 src/plugins/default/index.ts create mode 100644 src/plugins/default/logger-plugin.ts create mode 100644 src/plugins/default/proxy-events.ts create mode 100644 src/status-code.ts create mode 100644 test/e2e/express-error-middleware.spec.ts delete mode 100644 test/unit/handlers.spec.ts create mode 100644 test/unit/status-code.spec.ts diff --git a/README.md b/README.md index ef2f1838..b9d039cf 100644 --- a/README.md +++ b/README.md @@ -274,8 +274,8 @@ router: async function(req) { ### `plugins` (Array) ```js -const simpleRequestLogger = (proxy, options) => { - proxy.on('proxyReq', (proxyReq, req, res) => { +const simpleRequestLogger = (proxyServer, options) => { + proxyServer.on('proxyReq', (proxyReq, req, res) => { console.log(`[HPM] [${req.method}] ${req.url}`); // outputs: [HPM] GET /users }); }, @@ -323,9 +323,26 @@ function logProvider(provider) { ## `http-proxy` events -Subscribe to [http-proxy events](https://github.com/nodejitsu/node-http-proxy#listening-for-proxy-events): +Subscribe to [http-proxy events](https://github.com/nodejitsu/node-http-proxy#listening-for-proxy-events) with the `on` option: -- **option.onError**: function, subscribe to http-proxy's `error` event for custom error handling. +```js +createProxyMiddleware({ + target: 'http://www.example.org', + on: { + proxyReq: (proxyReq, req, res) => { + /* handle proxyReq */ + }, + proxyRes: (proxyRes, req, res) => { + /* handle proxyRes */ + }, + error: (err, req, res) => { + /* handle error */ + }, + }, +}); +``` + +- **option.on.error**: function, subscribe to http-proxy's `error` event for custom error handling. ```javascript function onError(err, req, res, target) { @@ -336,7 +353,7 @@ Subscribe to [http-proxy events](https://github.com/nodejitsu/node-http-proxy#li } ``` -- **option.onProxyRes**: function, subscribe to http-proxy's `proxyRes` event. +- **option.on.proxyRes**: function, subscribe to http-proxy's `proxyRes` event. ```javascript function onProxyRes(proxyRes, req, res) { @@ -345,7 +362,7 @@ Subscribe to [http-proxy events](https://github.com/nodejitsu/node-http-proxy#li } ``` -- **option.onProxyReq**: function, subscribe to http-proxy's `proxyReq` event. +- **option.on.proxyReq**: function, subscribe to http-proxy's `proxyReq` event. ```javascript function onProxyReq(proxyReq, req, res) { @@ -355,7 +372,7 @@ Subscribe to [http-proxy events](https://github.com/nodejitsu/node-http-proxy#li } ``` -- **option.onProxyReqWs**: function, subscribe to http-proxy's `proxyReqWs` event. +- **option.on.proxyReqWs**: function, subscribe to http-proxy's `proxyReqWs` event. ```javascript function onProxyReqWs(proxyReq, req, socket, options, head) { @@ -364,7 +381,7 @@ Subscribe to [http-proxy events](https://github.com/nodejitsu/node-http-proxy#li } ``` -- **option.onOpen**: function, subscribe to http-proxy's `open` event. +- **option.on.open**: function, subscribe to http-proxy's `open` event. ```javascript function onOpen(proxySocket) { @@ -373,7 +390,7 @@ Subscribe to [http-proxy events](https://github.com/nodejitsu/node-http-proxy#li } ``` -- **option.onClose**: function, subscribe to http-proxy's `close` event. +- **option.on.close**: function, subscribe to http-proxy's `close` event. ```javascript function onClose(res, socket, head) { diff --git a/examples/response-interceptor/index.js b/examples/response-interceptor/index.js index 64b8ee74..024da92b 100644 --- a/examples/response-interceptor/index.js +++ b/examples/response-interceptor/index.js @@ -45,23 +45,25 @@ const jsonPlaceholderProxy = createProxyMiddleware({ }, changeOrigin: true, // for vhosted sites, changes host header to match to target's host selfHandleResponse: true, // manually call res.end(); IMPORTANT: res.end() is called internally by responseInterceptor() - onProxyRes: responseInterceptor(async (buffer, proxyRes, req, res) => { - // log original request and proxied request info - const exchange = `[DEBUG] ${req.method} ${req.path} -> ${proxyRes.req.protocol}//${proxyRes.req.host}${proxyRes.req.path} [${proxyRes.statusCode}]`; - console.log(exchange); + on: { + proxyRes: responseInterceptor(async (buffer, proxyRes, req, res) => { + // log original request and proxied request info + const exchange = `[DEBUG] ${req.method} ${req.path} -> ${proxyRes.req.protocol}//${proxyRes.req.host}${proxyRes.req.path} [${proxyRes.statusCode}]`; + console.log(exchange); - // log original response - // console.log(`[DEBUG] original response:\n${buffer.toString('utf8')}`); + // log original response + // console.log(`[DEBUG] original response:\n${buffer.toString('utf8')}`); - // set response content-type - res.setHeader('content-type', 'application/json; charset=utf-8'); + // set response content-type + res.setHeader('content-type', 'application/json; charset=utf-8'); - // set response status code - res.statusCode = 418; + // set response status code + res.statusCode = 418; - // return a complete different response - return JSON.stringify(favoriteFoods); - }), + // return a complete different response + return JSON.stringify(favoriteFoods); + }), + }, logLevel: 'debug', }); diff --git a/package.json b/package.json index 079120f4..911e0ce3 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "devDependencies": { "@commitlint/cli": "16.2.1", "@commitlint/config-conventional": "16.2.1", + "@types/debug": "4.1.7", "@types/express": "4.17.13", "@types/is-glob": "4.0.2", "@types/jest": "27.4.0", @@ -85,6 +86,7 @@ }, "dependencies": { "@types/http-proxy": "^1.17.8", + "debug": "^4.3.4", "http-proxy": "^1.18.1", "is-glob": "^4.0.1", "is-plain-obj": "^3.0.0", diff --git a/recipes/proxy-events.md b/recipes/proxy-events.md index 4a3e7ca2..d3594e2b 100644 --- a/recipes/proxy-events.md +++ b/recipes/proxy-events.md @@ -1,8 +1,8 @@ # Proxy Events -Subscribe to [`http-proxy`](https://github.com/nodejitsu/node-http-proxy) [![GitHub stars](https://img.shields.io/github/stars/nodejitsu/node-http-proxy.svg?style=social&label=Star)](https://github.com/nodejitsu/node-http-proxy) events: `error`, `proxyReq`, `proxyReqWs`, `proxyRes`, `open`, `close`. +Subscribe to [`http-proxy`](https://github.com/nodejitsu/node-http-proxy) [![GitHub stars](https://img.shields.io/github/stars/nodejitsu/node-http-proxy.svg?style=social&label=Star)](https://github.com/nodejitsu/node-http-proxy) events: `error`, `proxyReq`, `proxyReqWs`, `proxyRes`, `open`, `close`, `start`, `end`, `econnreset`. -## onError +## on.error Subscribe to http-proxy's [error event](https://www.npmjs.com/package/http-proxy#listening-for-proxy-events). @@ -14,12 +14,15 @@ const onError = function (err, req, res) { console.log('And we are reporting a custom error message.'); }; -const options = { target: 'http://localhost:3000', onError: onError }; +const options = { + target: 'http://localhost:3000', + on: { 'error', onError } +}; const apiProxy = createProxyMiddleware(options); ``` -## onProxyReq +## on.proxyReq Subscribe to http-proxy's [proxyReq event](https://www.npmjs.com/package/http-proxy#listening-for-proxy-events). @@ -31,12 +34,15 @@ const onProxyReq = function (proxyReq, req, res) { proxyReq.setHeader('x-added', 'foobar'); }; -const options = { target: 'http://localhost:3000', onProxyReq: onProxyReq }; +const options = { + target: 'http://localhost:3000', + on: { 'proxyReq', onProxyReq } +}; const apiProxy = createProxyMiddleware(options); ``` -## onProxyReqWs +## on.proxyReqWs Subscribe to http-proxy's [proxyReqWs event](https://www.npmjs.com/package/http-proxy#listening-for-proxy-events). @@ -48,12 +54,15 @@ const onProxyReqWs = function (proxyReq, req, socket, options, head) { proxyReq.setHeader('X-Special-Proxy-Header', 'foobar'); }; -const options = { target: 'http://localhost:3000', onProxyReqWs: onProxyReqWs }; +const options = { + target: 'http://localhost:3000', + on: { 'proxyReqWs', onProxyReqWs } +}; const apiProxy = createProxyMiddleware(options); ``` -## onProxyRes +## on.proxyRes Subscribe to http-proxy's [proxyRes event](https://www.npmjs.com/package/http-proxy#listening-for-proxy-events). @@ -68,7 +77,10 @@ const onProxyRes = function (proxyRes, req, res) { delete proxyRes.headers['x-removed']; }; -const options = { target: 'http://localhost:3000', onProxyRes: onProxyRes }; +const options = { + target: 'http://localhost:3000', + on: { 'proxyRes', onProxyRes } +}; const apiProxy = createProxyMiddleware(options); ``` diff --git a/src/_handlers.ts b/src/_handlers.ts deleted file mode 100644 index 768b2016..00000000 --- a/src/_handlers.ts +++ /dev/null @@ -1,87 +0,0 @@ -import type { Options, Request, Response } from './types'; -import type * as httpProxy from 'http-proxy'; -import { getInstance } from './logger'; -const logger = getInstance(); - -export function init(proxy: httpProxy, option: Options): void { - const handlers = getHandlers(option); - - for (const eventName of Object.keys(handlers)) { - proxy.on(eventName, handlers[eventName]); - } - - logger.debug('[HPM] Subscribed to http-proxy events:', Object.keys(handlers)); -} - -type HttpProxyEventName = 'error' | 'proxyReq' | 'proxyReqWs' | 'proxyRes' | 'open' | 'close'; - -export function getHandlers(options: Options) { - // https://github.com/nodejitsu/node-http-proxy#listening-for-proxy-events - const proxyEventsMap: Record = { - error: 'onError', - proxyReq: 'onProxyReq', - proxyReqWs: 'onProxyReqWs', - proxyRes: 'onProxyRes', - open: 'onOpen', - close: 'onClose', - }; - - const handlers: any = {}; - - for (const [eventName, onEventName] of Object.entries(proxyEventsMap)) { - // all handlers for the http-proxy events are prefixed with 'on'. - // loop through options and try to find these handlers - // and add them to the handlers object for subscription in init(). - const fnHandler = options ? options[onEventName] : null; - - if (typeof fnHandler === 'function') { - handlers[eventName] = fnHandler; - } - } - - // add default error handler in absence of error handler - if (typeof handlers.error !== 'function') { - handlers.error = defaultErrorHandler; - } - - // add default close handler in absence of close handler - if (typeof handlers.close !== 'function') { - handlers.close = logClose; - } - - return handlers; -} - -function defaultErrorHandler(err, req: Request, res: Response) { - // Re-throw error. Not recoverable since req & res are empty. - if (!req && !res) { - throw err; // "Error: Must provide a proper URL as target" - } - - const host = req.headers && req.headers.host; - const code = err.code; - - if (res.writeHead && !res.headersSent) { - if (/HPE_INVALID/.test(code)) { - res.writeHead(502); - } else { - switch (code) { - case 'ECONNRESET': - case 'ENOTFOUND': - case 'ECONNREFUSED': - case 'ETIMEDOUT': - res.writeHead(504); - break; - default: - res.writeHead(500); - } - } - } - - res.end(`Error occurred while trying to proxy: ${host}${req.url}`); -} - -function logClose(req, socket, head) { - // view disconnected websocket connections - logger.info('[HPM] Client disconnected'); -} diff --git a/src/debug.ts b/src/debug.ts new file mode 100644 index 00000000..bc985921 --- /dev/null +++ b/src/debug.ts @@ -0,0 +1,6 @@ +import * as createDebug from 'debug'; + +/** + * Debug instance with the given namespace: hpm + */ +export const debug = createDebug('hpm'); diff --git a/src/http-proxy-middleware.ts b/src/http-proxy-middleware.ts index 2d92956a..0d273184 100644 --- a/src/http-proxy-middleware.ts +++ b/src/http-proxy-middleware.ts @@ -1,13 +1,17 @@ import type * as https from 'https'; -import type * as express from 'express'; -import type { Request, RequestHandler, Response, Options, Filter } from './types'; +import type { Request, RequestHandler, Options, Filter } from './types'; import * as httpProxy from 'http-proxy'; import { verifyConfig } from './configuration'; import { matchPathFilter } from './path-filter'; -import * as handlers from './_handlers'; import { getArrow, getInstance } from './logger'; import * as PathRewriter from './path-rewriter'; import * as Router from './router'; +import { + debugProxyErrorsPlugin, + createLoggerPlugin, + errorResponsePlugin, + proxyEventsPlugin, +} from './plugins/default'; export class HttpProxyMiddleware { private logger = getInstance(); @@ -29,12 +33,6 @@ export class HttpProxyMiddleware { this.pathRewriter = PathRewriter.createPathRewriter(this.proxyOptions.pathRewrite); // returns undefined when "pathRewrite" is not provided - // attach handler to http-proxy events - handlers.init(this.proxy, this.proxyOptions); - - // log errors for debug purpose - this.proxy.on('error', this.logError); - // https://github.com/chimurai/http-proxy-middleware/issues/19 // expose function to upgrade externally this.middleware.upgrade = (req, socket, head) => { @@ -82,8 +80,14 @@ export class HttpProxyMiddleware { }; private registerPlugins(proxy: httpProxy, options: Options) { + const defaultPlugins = [ + debugProxyErrorsPlugin, + proxyEventsPlugin, + createLoggerPlugin(), + errorResponsePlugin, + ]; const plugins = options.plugins ?? []; - plugins.forEach((plugin) => plugin(proxy, options)); + [...defaultPlugins, ...plugins].forEach((plugin) => plugin(proxy, options)); } private catchUpgradeRequest = (server: https.Server) => { @@ -175,18 +179,4 @@ export class HttpProxyMiddleware { } } }; - - private logError = (err, req: Request, res: Response, target?) => { - const hostname = - req.headers?.host || - (req as Request).hostname || - (req as Request).host; // (websocket) || (node0.10 || node 4/5) - const requestHref = `${hostname}${req.url}`; - const targetHref = `${target?.href}`; // target is undefined when websocket errors - - const errorMessage = '[HPM] Error occurred while proxying request %s to %s [%s] (%s)'; - const errReference = 'https://nodejs.org/api/errors.html#errors_common_system_errors'; // link to Node Common Systems Errors page - - this.logger.error(errorMessage, requestHref, targetHref, err.code || err, errReference); - }; } diff --git a/src/plugins/default/debug-proxy-errors-plugin.ts b/src/plugins/default/debug-proxy-errors-plugin.ts new file mode 100644 index 00000000..01172b55 --- /dev/null +++ b/src/plugins/default/debug-proxy-errors-plugin.ts @@ -0,0 +1,65 @@ +import { debug } from '../../debug'; +import { Plugin } from '../../types'; + +const debugError = debug.extend('debug-proxy-errors-plugin'); + +/** + * Subscribe to {@link https://www.npmjs.com/package/http-proxy#listening-for-proxy-events http-proxy error events} to prevent server from crashing. + * Errors are logged with {@link https://www.npmjs.com/package/debug debug} library. + */ +export const debugProxyErrorsPlugin: Plugin = (proxyServer): void => { + /** + * http-proxy doesn't handle any errors by default (https://github.com/http-party/node-http-proxy#listening-for-proxy-events) + * Prevent server from crashing when http-proxy errors (uncaught errors) + */ + proxyServer.on('error', (error, req, res, target) => { + debugError(`http-proxy error event: \n%O`, error); + }); + + proxyServer.on('proxyReq', (proxyReq, req, socket) => { + socket.on('error', (error) => { + debugError('Socket error in proxyReq event: \n%O', error); + }); + }); + + /** + * Fix SSE close events + * @link https://github.com/chimurai/http-proxy-middleware/issues/678 + * @link https://github.com/http-party/node-http-proxy/issues/1520#issue-877626125 + */ + proxyServer.on('proxyRes', (proxyRes, req, res) => { + res.on('close', () => { + if (!res.writableEnded) { + debugError('Destroying proxyRes in proxyRes close event'); + proxyRes.destroy(); + } + }); + }); + + /** + * Fix crash when target server restarts + * https://github.com/chimurai/http-proxy-middleware/issues/476#issuecomment-746329030 + * https://github.com/webpack/webpack-dev-server/issues/1642#issuecomment-790602225 + */ + proxyServer.on('proxyReqWs', (proxyReq, req, socket) => { + socket.on('error', (error) => { + debugError('Socket error in proxyReqWs event: \n%O', error); + }); + }); + + proxyServer.on('open', (proxySocket) => { + proxySocket.on('error', (error) => { + debugError('Socket error in open event: \n%O', error); + }); + }); + + proxyServer.on('close', (req, socket, head) => { + socket.on('error', (error) => { + debugError('Socket error in close event: \n%O', error); + }); + }); + + proxyServer.on('econnreset', (error, req, res, target) => { + debugError(`http-proxy econnreset event: \n%O`, error); + }); +}; diff --git a/src/plugins/default/error-response-plugin.ts b/src/plugins/default/error-response-plugin.ts new file mode 100644 index 00000000..cc1e4803 --- /dev/null +++ b/src/plugins/default/error-response-plugin.ts @@ -0,0 +1,19 @@ +import { getStatusCode } from '../../status-code'; +import { Plugin, Response } from '../../types'; + +export const errorResponsePlugin: Plugin = (proxyServer, options) => { + proxyServer.on('error', (err, req, res: Response, target?) => { + // Re-throw error. Not recoverable since req & res are empty. + if (!req && !res) { + throw err; // "Error: Must provide a proper URL as target" + } + + if (res.writeHead && !res.headersSent) { + const statusCode = getStatusCode((err as unknown as any).code); + res.writeHead(statusCode); + } + + const host = req.headers && req.headers.host; + res.end(`Error occurred while trying to proxy: ${host}${req.url}`); + }); +}; diff --git a/src/plugins/default/index.ts b/src/plugins/default/index.ts new file mode 100644 index 00000000..418cb0ab --- /dev/null +++ b/src/plugins/default/index.ts @@ -0,0 +1,4 @@ +export * from './debug-proxy-errors-plugin'; +export * from './error-response-plugin'; +export * from './logger-plugin'; +export * from './proxy-events'; diff --git a/src/plugins/default/logger-plugin.ts b/src/plugins/default/logger-plugin.ts new file mode 100644 index 00000000..fdc37e2b --- /dev/null +++ b/src/plugins/default/logger-plugin.ts @@ -0,0 +1,25 @@ +import { Plugin } from '../../types'; + +export function createLoggerPlugin(logger: Console = console): Plugin { + const loggerPlugin: Plugin = (proxyServer, options) => { + proxyServer.on('error', (err, req, res, target?) => { + const hostname = req?.headers?.host; + const requestHref = `${hostname}${req?.url}`; + const targetHref = `${(target as unknown as any)?.href}`; // target is undefined when websocket errors + + const errorMessage = '[HPM] Error occurred while proxying request %s to %s [%s] (%s)'; + const errReference = 'https://nodejs.org/api/errors.html#errors_common_system_errors'; // link to Node Common Systems Errors page + + logger.error(errorMessage, requestHref, targetHref, (err as any).code || err, errReference); + }); + + /** + * When client closes WebSocket connection + */ + proxyServer.on('close', (req, proxySocket, proxyHead) => { + logger.log('[HPM] Client disconnected'); + }); + }; + + return loggerPlugin; +} diff --git a/src/plugins/default/proxy-events.ts b/src/plugins/default/proxy-events.ts new file mode 100644 index 00000000..636e7637 --- /dev/null +++ b/src/plugins/default/proxy-events.ts @@ -0,0 +1,31 @@ +import { debug } from '../../debug'; +import { Plugin } from '../../types'; + +const log = debug.extend('proxy-events-plugin'); + +/** + * Implements option.on object to subscribe to http-proxy events. + * + * @example + * ```js + * createProxyMiddleware({ + * on: { + * error: (error, req, res, target) => {}, + * proxyReq: (proxyReq, req, res, options) => {}, + * proxyReqWs: (proxyReq, req, socket, options) => {}, + * proxyRes: (proxyRes, req, res) => {}, + * open: (proxySocket) => {}, + * close: (proxyRes, proxySocket, proxyHead) => {}, + * start: (req, res, target) => {}, + * end: (req, res, proxyRes) => {}, + * econnreset: (error, req, res, target) => {}, + * } + * }); + * ``` + */ +export const proxyEventsPlugin: Plugin = (proxyServer, options) => { + Object.entries(options.on || {}).forEach(([eventName, handler]) => { + proxyServer.on(eventName, handler as (...args: unknown[]) => void); + log(`registered event handler: "${eventName}"`); + }); +}; diff --git a/src/status-code.ts b/src/status-code.ts new file mode 100644 index 00000000..6ccf093e --- /dev/null +++ b/src/status-code.ts @@ -0,0 +1,21 @@ +export function getStatusCode(errorCode: string): number { + let statusCode: number; + + if (/HPE_INVALID/.test(errorCode)) { + statusCode = 502; + } else { + switch (errorCode) { + case 'ECONNRESET': + case 'ENOTFOUND': + case 'ECONNREFUSED': + case 'ETIMEDOUT': + statusCode = 504; + break; + default: + statusCode = 500; + break; + } + } + + return statusCode; +} diff --git a/src/types.ts b/src/types.ts index e3b845ec..9edaccf6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,7 +8,6 @@ import type * as http from 'http'; import type * as httpProxy from 'http-proxy'; import type * as net from 'net'; -import type * as url from 'url'; export type Request = T; export type Response = T; @@ -21,7 +20,19 @@ export interface RequestHandler { export type Filter = string | string[] | ((pathname: string, req: Request) => boolean); -export type Plugin = (proxy: httpProxy, options: Options) => void; +export type Plugin = (proxyServer: httpProxy, options: Options) => void; + +export type OnProxyEvent = { + error?: httpProxy.ErrorCallback; + proxyReq?: httpProxy.ProxyReqCallback; + proxyReqWs?: httpProxy.ProxyReqWsCallback; + proxyRes?: httpProxy.ProxyResCallback; + open?: httpProxy.OpenCallback; + close?: httpProxy.CloseCallback; + start?: httpProxy.StartCallback; + end?: httpProxy.EndCallback; + econnreset?: httpProxy.EconnresetCallback; +}; export interface Options extends httpProxy.ServerOptions { /** @@ -35,22 +46,41 @@ export interface Options extends httpProxy.ServerOptions { | ((path: string, req: Request) => string) | ((path: string, req: Request) => Promise); /** - * Plugins have access to the internal http-proxy instance to customize behavior. + * Access the internal http-proxy server instance to customize behavior + * + * @example + * ```js + * createProxyMiddleware({ + * plugins: [(proxyServer, options) => { + * proxyServer.on('error', (error, req, res) => { + * console.error(error); + * }); + * }] + * }); + * ``` */ plugins?: Plugin[]; + /** + * Listen to http-proxy events + * @see {@link OnProxyEvent} for available events + * @example + * ```js + * createProxyMiddleware({ + * on: { + * error: (error, req, res, target) => { + * console.error(error); + * } + * } + * }); + * ``` + */ + on?: OnProxyEvent; router?: | { [hostOrPath: string]: httpProxy.ServerOptions['target'] } | ((req: Request) => httpProxy.ServerOptions['target']) | ((req: Request) => Promise); logLevel?: 'debug' | 'info' | 'warn' | 'error' | 'silent'; logProvider?: LogProviderCallback; - - onError?: OnErrorCallback; - onProxyRes?: OnProxyResCallback; - onProxyReq?: OnProxyReqCallback; - onProxyReqWs?: OnProxyReqWsCallback; - onOpen?: OnOpenCallback; - onClose?: OnCloseCallback; } interface LogProvider { @@ -64,35 +94,3 @@ interface LogProvider { type Logger = (...args: any[]) => void; export type LogProviderCallback = (provider: LogProvider) => LogProvider; - -/** - * Use types based on the events listeners from http-proxy - * https://github.com/DefinitelyTyped/DefinitelyTyped/blob/51504fd999031b7f025220fab279f1b2155cbaff/types/http-proxy/index.d.ts - */ -export type OnErrorCallback = ( - err: Error, - req: Request, - res: Response, - target?: string | Partial -) => void; -export type OnProxyResCallback = ( - proxyRes: http.IncomingMessage, - req: Request, - res: Response -) => void; -export type OnProxyReqCallback = ( - proxyReq: http.ClientRequest, - req: Request, - res: Response, - options: httpProxy.ServerOptions -) => void; -export type OnProxyReqWsCallback = ( - proxyReq: http.ClientRequest, - req: Request, - socket: net.Socket, - options: httpProxy.ServerOptions, - head: any -) => void; -export type OnCloseCallback = (proxyRes: Response, proxySocket: net.Socket, proxyHead: any) => void; - -export type OnOpenCallback = (proxySocket: net.Socket) => void; diff --git a/test/e2e/express-error-middleware.spec.ts b/test/e2e/express-error-middleware.spec.ts new file mode 100644 index 00000000..53a6ac2b --- /dev/null +++ b/test/e2e/express-error-middleware.spec.ts @@ -0,0 +1,24 @@ +import { createApp, createProxyMiddleware } from './test-kit'; +import * as request from 'supertest'; + +describe('express error middleware', () => { + it('should propagate error to express', async () => { + let httpProxyError: Error | undefined; + + const proxyMiddleware = createProxyMiddleware({ + changeOrigin: true, + router: (req) => undefined, // Trigger "Error: Must provide a proper URL as target" + }); + + const errorMiddleware = (err, req, res, next) => { + httpProxyError = err; + res.status(504).send('Something broke!'); + }; + + const app = createApp(proxyMiddleware, errorMiddleware); + const response = await request(app).get('/get').expect(504); + + expect(httpProxyError.message).toBe('Must provide a proper URL as target'); + expect(response.text).toBe('Something broke!'); + }); +}); diff --git a/test/e2e/http-proxy-middleware.spec.ts b/test/e2e/http-proxy-middleware.spec.ts index 6437746e..ff491b7f 100644 --- a/test/e2e/http-proxy-middleware.spec.ts +++ b/test/e2e/http-proxy-middleware.spec.ts @@ -92,7 +92,9 @@ describe('E2E http-proxy-middleware', () => { createProxyMiddleware({ target: `http://localhost:${mockTargetServer.port}`, pathFilter: '/api', - onProxyReq: fixRequestBody, + on: { + proxyReq: fixRequestBody, + }, }) ) ); @@ -111,7 +113,9 @@ describe('E2E http-proxy-middleware', () => { createProxyMiddleware({ target: `http://localhost:${mockTargetServer.port}`, pathFilter: '/api', - onProxyReq: fixRequestBody, + on: { + proxyReq: fixRequestBody, + }, }) ) ); @@ -265,7 +269,7 @@ describe('E2E http-proxy-middleware', () => { }); }); - describe('option.onError - with default error handling', () => { + describe('default httpProxy on error handling', () => { beforeEach(() => { agent = request( createApp( @@ -282,34 +286,6 @@ describe('E2E http-proxy-middleware', () => { }); }); - describe('option.onError - custom error handling', () => { - beforeEach(() => { - agent = request( - createApp( - createProxyMiddleware({ - target: `http://localhost:666`, // unreachable host on port:666 - onError(err, req, res) { - if (err) { - res.writeHead(418); // different error code - res.end("I'm a teapot"); // no response body - } - }, - }) - ) - ); - }); - - it('should respond with custom http status code', async () => { - const response = await agent.get(`/api/some/endpoint`).expect(418); - expect(response.status).toBe(418); - }); - - it('should respond with custom status message', async () => { - const response = await agent.get(`/api/some/endpoint`).expect(418); - expect(response.text).toBe("I'm a teapot"); - }); - }); - describe('option.onProxyRes', () => { beforeEach(() => { agent = request( @@ -317,11 +293,13 @@ describe('E2E http-proxy-middleware', () => { createProxyMiddleware({ target: `http://localhost:${mockTargetServer.port}`, pathFilter: '/api', - onProxyRes(proxyRes, req, res) { - // tslint:disable-next-line: no-string-literal - proxyRes['headers']['x-added'] = 'foobar'; // add custom header to response - // tslint:disable-next-line: no-string-literal - delete proxyRes['headers']['x-removed']; + on: { + proxyRes: (proxyRes, req, res) => { + // tslint:disable-next-line: no-string-literal + proxyRes['headers']['x-added'] = 'foobar'; // add custom header to response + // tslint:disable-next-line: no-string-literal + delete proxyRes['headers']['x-removed']; + }, }, }) ) @@ -355,8 +333,10 @@ describe('E2E http-proxy-middleware', () => { createProxyMiddleware({ target: `http://localhost:${mockTargetServer.port}`, pathFilter: '/api', - onProxyReq(proxyReq, req, res) { - proxyReq.setHeader('x-added', 'added-from-hpm'); // add custom header to request + on: { + proxyReq: (proxyReq, req, res) => { + proxyReq.setHeader('x-added', 'added-from-hpm'); // add custom header to request + }, }, }) ) diff --git a/test/e2e/response-interceptor.spec.ts b/test/e2e/response-interceptor.spec.ts index 561a7791..38c07c20 100644 --- a/test/e2e/response-interceptor.spec.ts +++ b/test/e2e/response-interceptor.spec.ts @@ -13,10 +13,12 @@ describe('responseInterceptor()', () => { target: `http://httpbin.org`, changeOrigin: true, // for vhosted sites, changes host header to match to target's host selfHandleResponse: true, - onProxyRes: responseInterceptor(async (responseBuffer, proxyRes, req, res) => { - res.setHeader('content-type', 'application/json; charset=utf-8'); - return JSON.stringify({ foo: 'bar', favorite: '叉燒包' }); - }), + on: { + proxyRes: responseInterceptor(async (responseBuffer, proxyRes, req, res) => { + res.setHeader('content-type', 'application/json; charset=utf-8'); + return JSON.stringify({ foo: 'bar', favorite: '叉燒包' }); + }), + }, }) ) ); @@ -49,9 +51,11 @@ describe('responseInterceptor()', () => { target: `http://httpbin.org`, changeOrigin: true, // for vhosted sites, changes host header to match to target's host selfHandleResponse: true, - onProxyRes: responseInterceptor(async (responseBuffer, proxyRes, req, res) => { - return responseBuffer; - }), + on: { + proxyRes: responseInterceptor(async (responseBuffer, proxyRes, req, res) => { + return responseBuffer; + }), + }, }) ) ); @@ -75,7 +79,9 @@ describe('responseInterceptor()', () => { target: `http://httpbin.org`, changeOrigin: true, // for vhosted sites, changes host header to match to target's host selfHandleResponse: true, - onProxyRes: responseInterceptor(async (buffer) => buffer), + on: { + proxyRes: responseInterceptor(async (buffer) => buffer), + }, }) ) ); diff --git a/test/e2e/test-kit.ts b/test/e2e/test-kit.ts index a8de75bb..53a89786 100644 --- a/test/e2e/test-kit.ts +++ b/test/e2e/test-kit.ts @@ -1,9 +1,9 @@ import * as express from 'express'; -import { Express, RequestHandler } from 'express'; +import { Express, RequestHandler, ErrorRequestHandler } from 'express'; export { createProxyMiddleware, responseInterceptor, fixRequestBody } from '../../src/index'; -export function createApp(...middlewares: RequestHandler[]): Express { +export function createApp(...middlewares: (RequestHandler | ErrorRequestHandler)[]): Express { const app = express(); app.use(...middlewares); return app; diff --git a/test/types.spec.ts b/test/types.spec.ts index dd3538c8..c057a89c 100644 --- a/test/types.spec.ts +++ b/test/types.spec.ts @@ -129,38 +129,26 @@ describe('http-proxy-middleware TypeScript Types', () => { }); }); - describe('HPM http-proxy events', () => { - it('should have onError type', () => { - options = { onError: (err, req, res) => {} }; - expect(options).toBeDefined(); - }); - - it('should have onProxyReq type', () => { - options = { onProxyReq: (proxyReq, req, res) => {} }; - expect(options).toBeDefined(); - }); - - it('should have onProxyRes type', () => { - options = { onProxyRes: (proxyRes, req, res) => {} }; - expect(options).toBeDefined(); - }); - - it('should have onProxyReqWs type', () => { + describe('on', () => { + it('should have on events', () => { options = { - onProxyReqWs: (proxyReq, req, socket, opts, head) => {}, + on: { + error: (error, req, res, target) => {}, + proxyReq: (proxyReq, req, res, options) => {}, + proxyReqWs: (proxyReq, req, socket, options) => {}, + proxyRes: (proxyRes, req, res) => {}, + open: (proxySocket) => {}, + close: (proxyRes, proxySocket, proxyHead) => {}, + start: (req, res, target) => {}, + end: (req, res, proxyRes) => {}, + econnreset: (error, req, res, target) => {}, + + // @ts-expect-error explanation: should error when unknown event is passed + unknownEventName: () => {}, + }, }; expect(options).toBeDefined(); }); - - it('should have onOpen type', () => { - options = { onOpen: (proxySocket) => {} }; - expect(options).toBeDefined(); - }); - - it('should have onClose type', () => { - options = { onClose: (res, socket, head) => {} }; - expect(options).toBeDefined(); - }); }); }); }); diff --git a/test/unit/handlers.spec.ts b/test/unit/handlers.spec.ts deleted file mode 100644 index 22a8cfc1..00000000 --- a/test/unit/handlers.spec.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { getHandlers } from '../../src/_handlers'; - -describe('handlers factory', () => { - let handlersMap; - - it('should return default handlers when no handlers are provided', () => { - handlersMap = getHandlers(undefined); - expect(typeof handlersMap.error).toBe('function'); - expect(typeof handlersMap.close).toBe('function'); - }); - - describe('custom handlers', () => { - beforeEach(() => { - const fnCustom = () => { - return 42; - }; - - const proxyOptions = { - target: 'http://www.example.org', - onError: fnCustom, - onOpen: fnCustom, - onClose: fnCustom, - onProxyReq: fnCustom, - onProxyReqWs: fnCustom, - onProxyRes: fnCustom, - onDummy: fnCustom, - foobar: fnCustom, - }; - - handlersMap = getHandlers(proxyOptions); - }); - - it('should only return http-proxy handlers', () => { - expect(typeof handlersMap.error).toBe('function'); - expect(typeof handlersMap.open).toBe('function'); - expect(typeof handlersMap.close).toBe('function'); - expect(typeof handlersMap.proxyReq).toBe('function'); - expect(typeof handlersMap.proxyReqWs).toBe('function'); - expect(typeof handlersMap.proxyRes).toBe('function'); - expect(handlersMap.dummy).toBeUndefined(); - expect(handlersMap.foobar).toBeUndefined(); - expect(handlersMap.target).toBeUndefined(); - }); - - it('should use the provided custom handlers', () => { - expect(handlersMap.error()).toBe(42); - expect(handlersMap.open()).toBe(42); - expect(handlersMap.close()).toBe(42); - expect(handlersMap.proxyReq()).toBe(42); - expect(handlersMap.proxyReqWs()).toBe(42); - expect(handlersMap.proxyRes()).toBe(42); - }); - }); -}); - -describe('default proxy error handler', () => { - const mockError = { - code: 'ECONNREFUSED', - }; - - const mockReq = { - headers: { - host: 'localhost:3000', - }, - url: '/api', - }; - - const proxyOptions = { - target: { - host: 'localhost.dev', - }, - }; - - let httpErrorCode; - let errorMessage; - - const mockRes = { - writeHead(v) { - httpErrorCode = v; - return v; - }, - end(v) { - errorMessage = v; - return v; - }, - headersSent: false, - }; - - let proxyError; - - beforeEach(() => { - const handlersMap = getHandlers(undefined); - proxyError = handlersMap.error; - }); - - afterEach(() => { - httpErrorCode = undefined; - errorMessage = undefined; - }); - - const codes = [ - ['HPE_INVALID_FOO', 502], - ['HPE_INVALID_BAR', 502], - ['ECONNREFUSED', 504], - ['ENOTFOUND', 504], - ['ECONNREFUSED', 504], - ['ETIMEDOUT', 504], - ['any', 500], - ]; - - codes.forEach((item) => { - const msg = item[0]; - const code = item[1]; - it('should set the http status code for ' + msg + ' to: ' + code, () => { - proxyError({ code: msg }, mockReq, mockRes, proxyOptions); - expect(httpErrorCode).toBe(code); - }); - }); - - it('should end the response and return error message', () => { - proxyError(mockError, mockReq, mockRes, proxyOptions); - expect(errorMessage).toBe('Error occurred while trying to proxy: localhost:3000/api'); - }); - - it('should not set the http status code to: 500 if headers have already been sent', () => { - mockRes.headersSent = true; - proxyError(mockError, mockReq, mockRes, proxyOptions); - expect(httpErrorCode).toBeUndefined(); - }); - - it('should end the response and return error message', () => { - mockRes.headersSent = true; - proxyError(mockError, mockReq, mockRes, proxyOptions); - expect(errorMessage).toBe('Error occurred while trying to proxy: localhost:3000/api'); - }); - - it('should re-throw error from http-proxy when target is missing', () => { - mockRes.headersSent = true; - const error = new Error('Must provide a proper URL as target'); - const fn = () => proxyError(error, undefined, undefined, proxyOptions); - expect(fn).toThrowError(error); - }); -}); diff --git a/test/unit/status-code.spec.ts b/test/unit/status-code.spec.ts new file mode 100644 index 00000000..ceb91177 --- /dev/null +++ b/test/unit/status-code.spec.ts @@ -0,0 +1,19 @@ +import { getStatusCode } from '../../src/status-code'; + +describe('getStatusCode', () => { + const errorCodes = { + HPE_INVALID_FOO: 502, + HPE_INVALID_BAR: 502, + ECONNREFUSED: 504, + ECONNRESET: 504, + ENOTFOUND: 504, + ETIMEDOUT: 504, + any: 500, + }; + + it('should return http status code from error code', () => { + Object.entries(errorCodes).forEach(([errorCode, statusCode]) => { + expect(getStatusCode(errorCode)).toBe(statusCode); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 3b8c65c6..ae7876e8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1061,6 +1061,13 @@ resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.12.tgz#6b2c510a7ad7039e98e7b8d3d6598f4359e5c080" integrity sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw== +"@types/debug@4.1.7": + version "4.1.7" + resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.7.tgz#7cc0ea761509124709b8b2d1090d8f6c17aadb82" + integrity sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg== + dependencies: + "@types/ms" "*" + "@types/express-serve-static-core@^4.17.18": version "4.17.24" resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.24.tgz#ea41f93bf7e0d59cd5a76665068ed6aab6815c07" @@ -1148,6 +1155,11 @@ resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.2.tgz#ee771e2ba4b3dc5b372935d549fd9617bf345b8c" integrity sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ== +"@types/ms@*": + version "0.7.31" + resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.31.tgz#31b7ca6407128a3d2bbc27fe2d21b345397f6197" + integrity sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA== + "@types/node@*": version "17.0.10" resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.10.tgz#616f16e9d3a2a3d618136b1be244315d95bd7cab" @@ -2292,6 +2304,13 @@ debug@^3.1.1: dependencies: ms "^2.1.1" +debug@^4.3.4: + version "4.3.4" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" + integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== + dependencies: + ms "2.1.2" + debug@~3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"