diff --git a/README.md b/README.md index ab0b9d2..8ed2541 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,33 @@ engine.push( ); ``` +### Teardown + +If your middleware has teardown to perform, you can assign a method `destroy()` to your middleware function(s), +and calling `JsonRpcEngine.destroy()` will call this method on each middleware that has it. +A destroyed engine can no longer be used. + +```js +const middleware = (req, res, next, end) => { + /* do something */ +}; +middleware.destroy = () => { + /* perform teardown */ +}; + +const engine = new JsonRpcEngine(); +engine.push(middleware); + +/* perform work */ + +// This will call middleware.destroy() and destroy the engine itself. +engine.destroy(); + +// Calling any public method on the middleware other than `destroy()` itself +// will throw an error. +engine.handle(req); +``` + ### Gotchas Handle errors via `end(err)`, _NOT_ `next(err)`. diff --git a/src/JsonRpcEngine.ts b/src/JsonRpcEngine.ts index 8f787b4..1b1ad17 100644 --- a/src/JsonRpcEngine.ts +++ b/src/JsonRpcEngine.ts @@ -29,18 +29,29 @@ export type JsonRpcEngineEndCallback = ( error?: JsonRpcEngineCallbackError, ) => void; -export type JsonRpcMiddleware = ( - req: JsonRpcRequest, - res: PendingJsonRpcResponse, - next: JsonRpcEngineNextCallback, - end: JsonRpcEngineEndCallback, -) => void; +export interface JsonRpcMiddleware { + ( + req: JsonRpcRequest, + res: PendingJsonRpcResponse, + next: JsonRpcEngineNextCallback, + end: JsonRpcEngineEndCallback, + ): void; + destroy?: () => void | Promise; +} + +const DESTROYED_ERROR_MESSAGE = + 'This engine is destroyed and can no longer be used.'; /** * A JSON-RPC request and response processor. * Give it a stack of middleware, pass it requests, and get back responses. */ export class JsonRpcEngine extends SafeEventEmitter { + /** + * Indicating whether this engine is destroyed or not. + */ + private _isDestroyed = false; + private _middleware: JsonRpcMiddleware[]; constructor() { @@ -48,12 +59,44 @@ export class JsonRpcEngine extends SafeEventEmitter { this._middleware = []; } + /** + * Throws an error if this engine is destroyed. + */ + private _assertIsNotDestroyed() { + if (this._isDestroyed) { + throw new Error(DESTROYED_ERROR_MESSAGE); + } + } + + /** + * Calls the `destroy()` function of any middleware with that property, clears + * the middleware array, and marks this engine as destroyed. A destroyed + * engine cannot be used. + */ + destroy(): void { + this._middleware.forEach( + (middleware: JsonRpcMiddleware) => { + if ( + // `in` walks the prototype chain, which is probably the desired + // behavior here. + 'destroy' in middleware && + typeof middleware.destroy === 'function' + ) { + middleware.destroy(); + } + }, + ); + this._middleware = []; + this._isDestroyed = true; + } + /** * Add a middleware function to the engine's middleware stack. * * @param middleware - The middleware function to add. */ push(middleware: JsonRpcMiddleware): void { + this._assertIsNotDestroyed(); this._middleware.push(middleware as JsonRpcMiddleware); } @@ -101,6 +144,8 @@ export class JsonRpcEngine extends SafeEventEmitter { ): Promise[]>; handle(req: unknown, callback?: any) { + this._assertIsNotDestroyed(); + if (callback && typeof callback !== 'function') { throw new Error('"callback" must be a function if provided.'); } @@ -125,6 +170,7 @@ export class JsonRpcEngine extends SafeEventEmitter { * @returns This engine as a middleware function. */ asMiddleware(): JsonRpcMiddleware { + this._assertIsNotDestroyed(); return async (req, res, next, end) => { try { const [middlewareError, isComplete, returnHandlers] = diff --git a/src/engine.test.ts b/src/engine.test.ts index 9ea39ea..b47f657 100644 --- a/src/engine.test.ts +++ b/src/engine.test.ts @@ -5,7 +5,7 @@ import { isJsonRpcSuccess, } from '@metamask/utils'; import { ethErrors } from 'eth-rpc-errors'; -import { JsonRpcEngine } from '.'; +import { JsonRpcEngine, JsonRpcMiddleware } from '.'; const jsonrpc = '2.0' as const; @@ -528,4 +528,50 @@ describe('JsonRpcEngine', () => { await expect(engine.handle([{}] as any)).rejects.toThrow('foo'); }); + + describe('destroy', () => { + const destroyedError = new Error( + 'This engine is destroyed and can no longer be used.', + ); + + it('prevents the engine from being used', () => { + const engine = new JsonRpcEngine(); + engine.destroy(); + + expect(() => engine.handle([])).toThrow(destroyedError); + expect(() => engine.asMiddleware()).toThrow(destroyedError); + expect(() => engine.push(() => undefined)).toThrow(destroyedError); + }); + + it('destroying is idempotent', () => { + const engine = new JsonRpcEngine(); + engine.destroy(); + expect(() => engine.destroy()).not.toThrow(); + expect(() => engine.asMiddleware()).toThrow(destroyedError); + }); + + it('calls the destroy method of middleware functions', async () => { + const engine = new JsonRpcEngine(); + + engine.push((_req, res, next, _end) => { + res.result = 42; + next(); + }); + + const destroyMock = jest.fn(); + const destroyableMiddleware: JsonRpcMiddleware = ( + _req, + _res, + _next, + end, + ) => { + end(); + }; + destroyableMiddleware.destroy = destroyMock; + engine.push(destroyableMiddleware); + + engine.destroy(); + expect(destroyMock).toHaveBeenCalledTimes(1); + }); + }); });