From 928da10ffdb08bf2ee8d45ab938760b94312696e Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Wed, 9 Jul 2025 15:27:01 -0700 Subject: [PATCH 01/75] refactor: Move existing implementation to /legacy dir --- packages/json-rpc-engine/src/index.ts | 15 ++++++++------- .../src/{ => legacy}/JsonRpcEngine.test.ts | 4 ++-- .../src/{ => legacy}/JsonRpcEngine.ts | 2 +- .../src/{ => legacy}/asMiddleware.test.ts | 2 +- .../{ => legacy}/createAsyncMiddleware.test.ts | 4 ++-- .../src/{ => legacy}/createAsyncMiddleware.ts | 0 .../{ => legacy}/createScaffoldMiddleware.test.ts | 4 ++-- .../src/{ => legacy}/createScaffoldMiddleware.ts | 0 .../src/{ => legacy}/idRemapMiddleware.test.ts | 2 +- .../src/{ => legacy}/idRemapMiddleware.ts | 2 +- .../src/{ => legacy}/mergeMiddleware.test.ts | 2 +- .../src/{ => legacy}/mergeMiddleware.ts | 0 12 files changed, 19 insertions(+), 18 deletions(-) rename packages/json-rpc-engine/src/{ => legacy}/JsonRpcEngine.test.ts (99%) rename packages/json-rpc-engine/src/{ => legacy}/JsonRpcEngine.ts (99%) rename packages/json-rpc-engine/src/{ => legacy}/asMiddleware.test.ts (99%) rename packages/json-rpc-engine/src/{ => legacy}/createAsyncMiddleware.test.ts (96%) rename packages/json-rpc-engine/src/{ => legacy}/createAsyncMiddleware.ts (100%) rename packages/json-rpc-engine/src/{ => legacy}/createScaffoldMiddleware.test.ts (93%) rename packages/json-rpc-engine/src/{ => legacy}/createScaffoldMiddleware.ts (100%) rename packages/json-rpc-engine/src/{ => legacy}/idRemapMiddleware.test.ts (96%) rename packages/json-rpc-engine/src/{ => legacy}/idRemapMiddleware.ts (94%) rename packages/json-rpc-engine/src/{ => legacy}/mergeMiddleware.test.ts (99%) rename packages/json-rpc-engine/src/{ => legacy}/mergeMiddleware.ts (100%) diff --git a/packages/json-rpc-engine/src/index.ts b/packages/json-rpc-engine/src/index.ts index 14fb822b6b..e985be7a9d 100644 --- a/packages/json-rpc-engine/src/index.ts +++ b/packages/json-rpc-engine/src/index.ts @@ -1,11 +1,12 @@ +// Legacy export type { AsyncJsonRpcEngineNextCallback, AsyncJsonrpcMiddleware, -} from './createAsyncMiddleware'; -export { createAsyncMiddleware } from './createAsyncMiddleware'; -export { createScaffoldMiddleware } from './createScaffoldMiddleware'; +} from './legacy/createAsyncMiddleware'; +export { createAsyncMiddleware } from './legacy/createAsyncMiddleware'; +export { createScaffoldMiddleware } from './legacy/createScaffoldMiddleware'; export { getUniqueId } from './getUniqueId'; -export { createIdRemapMiddleware } from './idRemapMiddleware'; +export { createIdRemapMiddleware } from './legacy/idRemapMiddleware'; export type { JsonRpcEngineCallbackError, JsonRpcEngineReturnHandler, @@ -13,6 +14,6 @@ export type { JsonRpcEngineEndCallback, JsonRpcMiddleware, JsonRpcNotificationHandler, -} from './JsonRpcEngine'; -export { JsonRpcEngine } from './JsonRpcEngine'; -export { mergeMiddleware } from './mergeMiddleware'; +} from './legacy/JsonRpcEngine'; +export { JsonRpcEngine } from './legacy/JsonRpcEngine'; +export { mergeMiddleware } from './legacy/mergeMiddleware'; diff --git a/packages/json-rpc-engine/src/JsonRpcEngine.test.ts b/packages/json-rpc-engine/src/legacy/JsonRpcEngine.test.ts similarity index 99% rename from packages/json-rpc-engine/src/JsonRpcEngine.test.ts rename to packages/json-rpc-engine/src/legacy/JsonRpcEngine.test.ts index 83dd990bef..974d742d5b 100644 --- a/packages/json-rpc-engine/src/JsonRpcEngine.test.ts +++ b/packages/json-rpc-engine/src/legacy/JsonRpcEngine.test.ts @@ -7,8 +7,8 @@ import { isJsonRpcSuccess, } from '@metamask/utils'; -import type { JsonRpcMiddleware } from '.'; -import { JsonRpcEngine } from '.'; +import type { JsonRpcMiddleware } from '..'; +import { JsonRpcEngine } from '..'; const jsonrpc = '2.0' as const; diff --git a/packages/json-rpc-engine/src/JsonRpcEngine.ts b/packages/json-rpc-engine/src/legacy/JsonRpcEngine.ts similarity index 99% rename from packages/json-rpc-engine/src/JsonRpcEngine.ts rename to packages/json-rpc-engine/src/legacy/JsonRpcEngine.ts index 3bd6e8b076..20af59b319 100644 --- a/packages/json-rpc-engine/src/JsonRpcEngine.ts +++ b/packages/json-rpc-engine/src/legacy/JsonRpcEngine.ts @@ -352,7 +352,7 @@ export class JsonRpcEngine extends SafeEventEmitter { */ // This function is used in tests, so we cannot easily change it to use the // hash syntax. - // eslint-disable-next-line no-restricted-syntax + private async _promiseHandle( request: JsonRpcRequest | JsonRpcNotification, ): Promise { diff --git a/packages/json-rpc-engine/src/asMiddleware.test.ts b/packages/json-rpc-engine/src/legacy/asMiddleware.test.ts similarity index 99% rename from packages/json-rpc-engine/src/asMiddleware.test.ts rename to packages/json-rpc-engine/src/legacy/asMiddleware.test.ts index f914677d63..a1888f2113 100644 --- a/packages/json-rpc-engine/src/asMiddleware.test.ts +++ b/packages/json-rpc-engine/src/legacy/asMiddleware.test.ts @@ -1,7 +1,7 @@ import type { JsonRpcRequest } from '@metamask/utils'; import { assertIsJsonRpcSuccess, isJsonRpcSuccess } from '@metamask/utils'; -import { JsonRpcEngine } from '.'; +import { JsonRpcEngine } from '..'; const jsonrpc = '2.0' as const; diff --git a/packages/json-rpc-engine/src/createAsyncMiddleware.test.ts b/packages/json-rpc-engine/src/legacy/createAsyncMiddleware.test.ts similarity index 96% rename from packages/json-rpc-engine/src/createAsyncMiddleware.test.ts rename to packages/json-rpc-engine/src/legacy/createAsyncMiddleware.test.ts index 1cd50f2b5b..ad7d4941bb 100644 --- a/packages/json-rpc-engine/src/createAsyncMiddleware.test.ts +++ b/packages/json-rpc-engine/src/legacy/createAsyncMiddleware.test.ts @@ -1,6 +1,6 @@ import { assertIsJsonRpcSuccess } from '@metamask/utils'; -import { JsonRpcEngine, createAsyncMiddleware } from '.'; +import { JsonRpcEngine, createAsyncMiddleware } from '..'; const jsonrpc = '2.0' as const; @@ -37,7 +37,7 @@ describe('createAsyncMiddleware', () => { await next(); expect(response.result).toBe(1234); // override value - response.result = 42; // eslint-disable-line require-atomic-updates + response.result = 42; }), ); diff --git a/packages/json-rpc-engine/src/createAsyncMiddleware.ts b/packages/json-rpc-engine/src/legacy/createAsyncMiddleware.ts similarity index 100% rename from packages/json-rpc-engine/src/createAsyncMiddleware.ts rename to packages/json-rpc-engine/src/legacy/createAsyncMiddleware.ts diff --git a/packages/json-rpc-engine/src/createScaffoldMiddleware.test.ts b/packages/json-rpc-engine/src/legacy/createScaffoldMiddleware.test.ts similarity index 93% rename from packages/json-rpc-engine/src/createScaffoldMiddleware.test.ts rename to packages/json-rpc-engine/src/legacy/createScaffoldMiddleware.test.ts index 0900ec870d..36a46e0251 100644 --- a/packages/json-rpc-engine/src/createScaffoldMiddleware.test.ts +++ b/packages/json-rpc-engine/src/legacy/createScaffoldMiddleware.test.ts @@ -5,8 +5,8 @@ import { assertIsJsonRpcFailure, } from '@metamask/utils'; -import type { JsonRpcMiddleware } from '.'; -import { JsonRpcEngine, createScaffoldMiddleware } from '.'; +import type { JsonRpcMiddleware } from '..'; +import { JsonRpcEngine, createScaffoldMiddleware } from '..'; describe('createScaffoldMiddleware', () => { it('basic middleware test', async () => { diff --git a/packages/json-rpc-engine/src/createScaffoldMiddleware.ts b/packages/json-rpc-engine/src/legacy/createScaffoldMiddleware.ts similarity index 100% rename from packages/json-rpc-engine/src/createScaffoldMiddleware.ts rename to packages/json-rpc-engine/src/legacy/createScaffoldMiddleware.ts diff --git a/packages/json-rpc-engine/src/idRemapMiddleware.test.ts b/packages/json-rpc-engine/src/legacy/idRemapMiddleware.test.ts similarity index 96% rename from packages/json-rpc-engine/src/idRemapMiddleware.test.ts rename to packages/json-rpc-engine/src/legacy/idRemapMiddleware.test.ts index 5e1e6341d8..734f5377f7 100644 --- a/packages/json-rpc-engine/src/idRemapMiddleware.test.ts +++ b/packages/json-rpc-engine/src/legacy/idRemapMiddleware.test.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import { JsonRpcEngine, createIdRemapMiddleware } from '.'; +import { JsonRpcEngine, createIdRemapMiddleware } from '..'; describe('idRemapMiddleware', () => { it('basic middleware test', async () => { diff --git a/packages/json-rpc-engine/src/idRemapMiddleware.ts b/packages/json-rpc-engine/src/legacy/idRemapMiddleware.ts similarity index 94% rename from packages/json-rpc-engine/src/idRemapMiddleware.ts rename to packages/json-rpc-engine/src/legacy/idRemapMiddleware.ts index ebf176f62e..8be5ed6e5f 100644 --- a/packages/json-rpc-engine/src/idRemapMiddleware.ts +++ b/packages/json-rpc-engine/src/legacy/idRemapMiddleware.ts @@ -1,7 +1,7 @@ import type { Json, JsonRpcParams } from '@metamask/utils'; -import { getUniqueId } from './getUniqueId'; import type { JsonRpcMiddleware } from './JsonRpcEngine'; +import { getUniqueId } from '../getUniqueId'; /** * Returns a middleware function that overwrites the `id` property of each diff --git a/packages/json-rpc-engine/src/mergeMiddleware.test.ts b/packages/json-rpc-engine/src/legacy/mergeMiddleware.test.ts similarity index 99% rename from packages/json-rpc-engine/src/mergeMiddleware.test.ts rename to packages/json-rpc-engine/src/legacy/mergeMiddleware.test.ts index c6c7950610..b2b14e6663 100644 --- a/packages/json-rpc-engine/src/mergeMiddleware.test.ts +++ b/packages/json-rpc-engine/src/legacy/mergeMiddleware.test.ts @@ -1,7 +1,7 @@ import type { JsonRpcRequest } from '@metamask/utils'; import { assertIsJsonRpcSuccess, hasProperty } from '@metamask/utils'; -import { JsonRpcEngine, mergeMiddleware } from '.'; +import { JsonRpcEngine, mergeMiddleware } from '..'; const jsonrpc = '2.0' as const; diff --git a/packages/json-rpc-engine/src/mergeMiddleware.ts b/packages/json-rpc-engine/src/legacy/mergeMiddleware.ts similarity index 100% rename from packages/json-rpc-engine/src/mergeMiddleware.ts rename to packages/json-rpc-engine/src/legacy/mergeMiddleware.ts From e5215d5067cb8c3b8865c0f6f2a0249e12b54d63 Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Thu, 10 Jul 2025 14:35:49 -0700 Subject: [PATCH 02/75] refactor: Add /legacy export path --- packages/json-rpc-engine/package.json | 10 ++++++++++ packages/json-rpc-engine/src/index.test.ts | 5 ----- packages/json-rpc-engine/src/index.ts | 18 ------------------ .../src/legacy/JsonRpcEngine.test.ts | 4 ++-- .../src/legacy/asMiddleware.test.ts | 2 +- .../src/legacy/createAsyncMiddleware.test.ts | 2 +- .../legacy/createScaffoldMiddleware.test.ts | 4 ++-- .../src/legacy/idRemapMiddleware.test.ts | 2 +- .../json-rpc-engine/src/legacy/index.test.ts | 15 +++++++++++++++ packages/json-rpc-engine/src/legacy/index.ts | 17 +++++++++++++++++ .../src/legacy/mergeMiddleware.test.ts | 2 +- 11 files changed, 50 insertions(+), 31 deletions(-) create mode 100644 packages/json-rpc-engine/src/legacy/index.test.ts create mode 100644 packages/json-rpc-engine/src/legacy/index.ts diff --git a/packages/json-rpc-engine/package.json b/packages/json-rpc-engine/package.json index 4832ebde11..b66e81e169 100644 --- a/packages/json-rpc-engine/package.json +++ b/packages/json-rpc-engine/package.json @@ -27,6 +27,16 @@ "default": "./dist/index.cjs" } }, + "./legacy": { + "import": { + "types": "./dist/legacy/index.d.mts", + "default": "./dist/legacy/index.mjs" + }, + "require": { + "types": "./dist/legacy/index.d.cts", + "default": "./dist/legacy/index.cjs" + } + }, "./package.json": "./package.json" }, "main": "./dist/index.cjs", diff --git a/packages/json-rpc-engine/src/index.test.ts b/packages/json-rpc-engine/src/index.test.ts index 2c72ee9dd4..cba4229fb1 100644 --- a/packages/json-rpc-engine/src/index.test.ts +++ b/packages/json-rpc-engine/src/index.test.ts @@ -4,12 +4,7 @@ describe('@metamask/json-rpc-engine', () => { it('has expected JavaScript exports', () => { expect(Object.keys(allExports)).toMatchInlineSnapshot(` Array [ - "createAsyncMiddleware", - "createScaffoldMiddleware", "getUniqueId", - "createIdRemapMiddleware", - "JsonRpcEngine", - "mergeMiddleware", ] `); }); diff --git a/packages/json-rpc-engine/src/index.ts b/packages/json-rpc-engine/src/index.ts index e985be7a9d..9db96b8b22 100644 --- a/packages/json-rpc-engine/src/index.ts +++ b/packages/json-rpc-engine/src/index.ts @@ -1,19 +1 @@ -// Legacy -export type { - AsyncJsonRpcEngineNextCallback, - AsyncJsonrpcMiddleware, -} from './legacy/createAsyncMiddleware'; -export { createAsyncMiddleware } from './legacy/createAsyncMiddleware'; -export { createScaffoldMiddleware } from './legacy/createScaffoldMiddleware'; export { getUniqueId } from './getUniqueId'; -export { createIdRemapMiddleware } from './legacy/idRemapMiddleware'; -export type { - JsonRpcEngineCallbackError, - JsonRpcEngineReturnHandler, - JsonRpcEngineNextCallback, - JsonRpcEngineEndCallback, - JsonRpcMiddleware, - JsonRpcNotificationHandler, -} from './legacy/JsonRpcEngine'; -export { JsonRpcEngine } from './legacy/JsonRpcEngine'; -export { mergeMiddleware } from './legacy/mergeMiddleware'; diff --git a/packages/json-rpc-engine/src/legacy/JsonRpcEngine.test.ts b/packages/json-rpc-engine/src/legacy/JsonRpcEngine.test.ts index 974d742d5b..83dd990bef 100644 --- a/packages/json-rpc-engine/src/legacy/JsonRpcEngine.test.ts +++ b/packages/json-rpc-engine/src/legacy/JsonRpcEngine.test.ts @@ -7,8 +7,8 @@ import { isJsonRpcSuccess, } from '@metamask/utils'; -import type { JsonRpcMiddleware } from '..'; -import { JsonRpcEngine } from '..'; +import type { JsonRpcMiddleware } from '.'; +import { JsonRpcEngine } from '.'; const jsonrpc = '2.0' as const; diff --git a/packages/json-rpc-engine/src/legacy/asMiddleware.test.ts b/packages/json-rpc-engine/src/legacy/asMiddleware.test.ts index a1888f2113..f914677d63 100644 --- a/packages/json-rpc-engine/src/legacy/asMiddleware.test.ts +++ b/packages/json-rpc-engine/src/legacy/asMiddleware.test.ts @@ -1,7 +1,7 @@ import type { JsonRpcRequest } from '@metamask/utils'; import { assertIsJsonRpcSuccess, isJsonRpcSuccess } from '@metamask/utils'; -import { JsonRpcEngine } from '..'; +import { JsonRpcEngine } from '.'; const jsonrpc = '2.0' as const; diff --git a/packages/json-rpc-engine/src/legacy/createAsyncMiddleware.test.ts b/packages/json-rpc-engine/src/legacy/createAsyncMiddleware.test.ts index ad7d4941bb..491fe23879 100644 --- a/packages/json-rpc-engine/src/legacy/createAsyncMiddleware.test.ts +++ b/packages/json-rpc-engine/src/legacy/createAsyncMiddleware.test.ts @@ -1,6 +1,6 @@ import { assertIsJsonRpcSuccess } from '@metamask/utils'; -import { JsonRpcEngine, createAsyncMiddleware } from '..'; +import { JsonRpcEngine, createAsyncMiddleware } from '.'; const jsonrpc = '2.0' as const; diff --git a/packages/json-rpc-engine/src/legacy/createScaffoldMiddleware.test.ts b/packages/json-rpc-engine/src/legacy/createScaffoldMiddleware.test.ts index 36a46e0251..0900ec870d 100644 --- a/packages/json-rpc-engine/src/legacy/createScaffoldMiddleware.test.ts +++ b/packages/json-rpc-engine/src/legacy/createScaffoldMiddleware.test.ts @@ -5,8 +5,8 @@ import { assertIsJsonRpcFailure, } from '@metamask/utils'; -import type { JsonRpcMiddleware } from '..'; -import { JsonRpcEngine, createScaffoldMiddleware } from '..'; +import type { JsonRpcMiddleware } from '.'; +import { JsonRpcEngine, createScaffoldMiddleware } from '.'; describe('createScaffoldMiddleware', () => { it('basic middleware test', async () => { diff --git a/packages/json-rpc-engine/src/legacy/idRemapMiddleware.test.ts b/packages/json-rpc-engine/src/legacy/idRemapMiddleware.test.ts index 734f5377f7..5e1e6341d8 100644 --- a/packages/json-rpc-engine/src/legacy/idRemapMiddleware.test.ts +++ b/packages/json-rpc-engine/src/legacy/idRemapMiddleware.test.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -import { JsonRpcEngine, createIdRemapMiddleware } from '..'; +import { JsonRpcEngine, createIdRemapMiddleware } from '.'; describe('idRemapMiddleware', () => { it('basic middleware test', async () => { diff --git a/packages/json-rpc-engine/src/legacy/index.test.ts b/packages/json-rpc-engine/src/legacy/index.test.ts new file mode 100644 index 0000000000..4f6942ef55 --- /dev/null +++ b/packages/json-rpc-engine/src/legacy/index.test.ts @@ -0,0 +1,15 @@ +import * as allExports from '.'; + +describe('@metamask/json-rpc-engine/legacy', () => { + it('has expected JavaScript exports', () => { + expect(Object.keys(allExports)).toMatchInlineSnapshot(` + Array [ + "createAsyncMiddleware", + "createScaffoldMiddleware", + "createIdRemapMiddleware", + "JsonRpcEngine", + "mergeMiddleware", + ] + `); + }); +}); diff --git a/packages/json-rpc-engine/src/legacy/index.ts b/packages/json-rpc-engine/src/legacy/index.ts new file mode 100644 index 0000000000..82460dfc15 --- /dev/null +++ b/packages/json-rpc-engine/src/legacy/index.ts @@ -0,0 +1,17 @@ +export type { + AsyncJsonRpcEngineNextCallback, + AsyncJsonrpcMiddleware, +} from './createAsyncMiddleware'; +export { createAsyncMiddleware } from './createAsyncMiddleware'; +export { createScaffoldMiddleware } from './createScaffoldMiddleware'; +export { createIdRemapMiddleware } from './idRemapMiddleware'; +export type { + JsonRpcEngineCallbackError, + JsonRpcEngineReturnHandler, + JsonRpcEngineNextCallback, + JsonRpcEngineEndCallback, + JsonRpcMiddleware, + JsonRpcNotificationHandler, +} from './JsonRpcEngine'; +export { JsonRpcEngine } from './JsonRpcEngine'; +export { mergeMiddleware } from './mergeMiddleware'; diff --git a/packages/json-rpc-engine/src/legacy/mergeMiddleware.test.ts b/packages/json-rpc-engine/src/legacy/mergeMiddleware.test.ts index b2b14e6663..c6c7950610 100644 --- a/packages/json-rpc-engine/src/legacy/mergeMiddleware.test.ts +++ b/packages/json-rpc-engine/src/legacy/mergeMiddleware.test.ts @@ -1,7 +1,7 @@ import type { JsonRpcRequest } from '@metamask/utils'; import { assertIsJsonRpcSuccess, hasProperty } from '@metamask/utils'; -import { JsonRpcEngine, mergeMiddleware } from '..'; +import { JsonRpcEngine, mergeMiddleware } from '.'; const jsonrpc = '2.0' as const; From 499f5e53c75265d78b428ffdd29389735a8ebe73 Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Mon, 14 Jul 2025 14:31:16 -0700 Subject: [PATCH 03/75] refactor: Deprecate all /legacy exports --- .../json-rpc-engine/src/JsonRpcEngineV2.ts | 243 ++++++++++++++++++ .../src/legacy/JsonRpcEngine.ts | 18 ++ .../src/legacy/createAsyncMiddleware.ts | 1 + .../src/legacy/createScaffoldMiddleware.ts | 1 + .../src/legacy/idRemapMiddleware.ts | 1 + .../src/legacy/mergeMiddleware.ts | 1 + 6 files changed, 265 insertions(+) create mode 100644 packages/json-rpc-engine/src/JsonRpcEngineV2.ts diff --git a/packages/json-rpc-engine/src/JsonRpcEngineV2.ts b/packages/json-rpc-engine/src/JsonRpcEngineV2.ts new file mode 100644 index 0000000000..88d421c2d0 --- /dev/null +++ b/packages/json-rpc-engine/src/JsonRpcEngineV2.ts @@ -0,0 +1,243 @@ +import type { + Json, + JsonRpcRequest, + JsonRpcNotification, + NonEmptyArray, +} from '@metamask/utils'; +import { produce } from 'immer'; + +import { isRequest, JsonRpcEngineError, stringify } from './utils'; +import type { JsonRpcCall } from './utils'; + +export const EndNotification = Symbol.for('JsonRpcEngine:EndNotification'); + +type Context = Record; + +type ReturnHandler = ( + result: Result, +) => void | Result | Promise; + +type MiddlewareResultConstraint = + Request extends JsonRpcNotification + ? Request extends JsonRpcRequest + ? void | Json | ReturnHandler + : void | typeof EndNotification + : void | Json | ReturnHandler; + +type HandledResult> = + Exclude | void; + +export type JsonRpcMiddleware< + Request extends JsonRpcCall, + Result extends MiddlewareResultConstraint, +> = (request: Request, context: Context) => Result | Promise; + +type Options< + Request extends JsonRpcCall, + Result extends MiddlewareResultConstraint, +> = { + middleware: NonEmptyArray>; +}; + +/** + * A JSON-RPC request and response processor. + * + * Give it a stack of middleware, pass it requests, and get back responses. + * + * @template Request - The type of request to handle. + * @template Result - The type of result to return. + */ +export class JsonRpcEngineV2< + Request extends JsonRpcCall, + Result extends MiddlewareResultConstraint, +> { + static readonly EndNotification = EndNotification; + + readonly #middleware: readonly JsonRpcMiddleware[]; + + constructor({ middleware }: Options) { + this.#middleware = [...middleware]; + } + + /** + * Handle a JSON-RPC request. A response will be returned. + * + * @param request - The JSON-RPC request to handle. + * @returns The JSON-RPC response. + */ + async handle( + request: Request & JsonRpcRequest, + ): Promise, void>>; + + /** + * Handle a JSON-RPC notification. No response will be returned. + * + * @param notification - The JSON-RPC notification to handle. + */ + async handle(notification: Request & JsonRpcNotification): Promise; + + async handle(request: Request): Promise> { + const result = await this.#handle(request); + return result === EndNotification + ? undefined + : (result as HandledResult); + } + + // This exists because a JsonRpcCall overload of handle() cannot coexist with + // the other overloads due to type union / overload shenanigans. + /** + * Handle a JSON-RPC call. A response will be returned if the call is a request. + * + * @param call - The JSON-RPC call to handle. + * @returns The JSON-RPC response, if any. + */ + async handleAny(call: Request): Promise | void> { + return this.handle(call); + } + + async #handle(request: Request, context: Context = {}): Promise { + const { result, returnHandlers, finalRequest } = await this.#runMiddleware( + request, + context, + ); + + return await this.#runReturnHandlers(result, returnHandlers, finalRequest); + } + + /** + * Run the middleware for a request. + * + * @param originalRequest - The request to run the middleware for. + * @param context - The context to pass to the middleware. + * @returns The result from the middleware. + */ + async #runMiddleware( + originalRequest: Request, + context: Context, + ): Promise<{ + result: Extract; + returnHandlers: ReturnHandler[]; + finalRequest: Readonly; + }> { + const returnHandlers: ReturnHandler[] = []; + + let request = structuredClone(originalRequest); + let result: Extract | undefined; + + for (const middleware of this.#middleware) { + let currentResult: Result | undefined; + request = await updateRequest(request, async (draft) => { + currentResult = await middleware(draft, context); + }); + + if (typeof currentResult === 'function') { + returnHandlers.push(currentResult); + } else if (currentResult !== undefined) { + // Cast required due to incorrect type narrowing + result = currentResult as Extract< + Result, + Json | typeof EndNotification + >; + break; + } + } + + if (result === undefined) { + throw new JsonRpcEngineError( + `Nothing ended request: ${stringify(request)}`, + ); + } else if (isRequest(originalRequest)) { + if (result === EndNotification) { + throw new JsonRpcEngineError( + `Request handled as notification: ${stringify(request)}`, + ); + } + } else if (result !== EndNotification) { + throw new JsonRpcEngineError( + `Notification handled as request: ${stringify(request)}`, + ); + } + + return { + result, + returnHandlers, + finalRequest: request, + }; + } + + /** + * Run the return handlers for a result. May or may not return a new result. + * + * @param initialResult - The initial result from the middleware. + * @param returnHandlers - The return handlers to run. + * @param request - The request that caused the result. Only used for logging. + * Will not be passed to the return handlers. + * @returns The final result. + */ + async #runReturnHandlers( + initialResult: Extract, + returnHandlers: ReturnHandler[], + request: Readonly, + ): Promise { + if (returnHandlers.length === 0) { + return initialResult; + } + + if (initialResult === EndNotification) { + throw new JsonRpcEngineError( + `Received return handlers for notification: ${stringify(request)}`, + ); + } + + let result = initialResult; + for (const returnHandler of returnHandlers) { + result = await produce(result, async (draft: Json) => { + return await returnHandler(draft); + }); + } + + return result; + } + + /** + * Convert the engine into a JSON-RPC middleware. + * + * @returns The JSON-RPC middleware. + */ + asMiddleware(): JsonRpcMiddleware { + return async (request, context) => this.#handle(request, context); + } +} + +// Properties of a request that you're not allowed to modify. +const readonlyProps = ['id', 'jsonrpc'] as const; + +/** + * Update a request using Immer. + * + * @param request - The request to update. + * @param recipe - The recipe function. + * @returns The updated request. + */ +async function updateRequest( + request: Request, + recipe: (request: Request) => Promise, +): Promise { + return produce(request, async (draft) => { + const draftProxy = new Proxy(draft, { + set(target, prop, value) { + if (readonlyProps.includes(prop as (typeof readonlyProps)[number])) { + throw new JsonRpcEngineError( + `Middleware attempted to modify readonly property "${String(prop)}" for request: ${stringify(request)}`, + ); + } + return Reflect.set(target, prop, value); + }, + }); + + // The Jest parser encounters "TS2589: Type instantiation is excessively + // deep and possibly infinite." + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await recipe(draftProxy as any); + }); +} diff --git a/packages/json-rpc-engine/src/legacy/JsonRpcEngine.ts b/packages/json-rpc-engine/src/legacy/JsonRpcEngine.ts index 20af59b319..a59576b5ac 100644 --- a/packages/json-rpc-engine/src/legacy/JsonRpcEngine.ts +++ b/packages/json-rpc-engine/src/legacy/JsonRpcEngine.ts @@ -17,16 +17,28 @@ import { export type JsonRpcEngineCallbackError = Error | SerializedJsonRpcError | null; +/** + * @deprecated Use `JsonRpcEngineV2` and its corresponding types instead. + */ export type JsonRpcEngineReturnHandler = ( done: (error?: unknown) => void, ) => void; +/** + * @deprecated Use `JsonRpcEngineV2` and its corresponding types instead. + */ export type JsonRpcEngineNextCallback = ( returnHandlerCallback?: JsonRpcEngineReturnHandler, ) => void; +/** + * @deprecated Use `JsonRpcEngineV2` and its corresponding types instead. + */ export type JsonRpcEngineEndCallback = (error?: unknown) => void; +/** + * @deprecated Use `JsonRpcEngineV2` and its corresponding types instead. + */ export type JsonRpcMiddleware< Params extends JsonRpcParams, Result extends Json, @@ -43,6 +55,9 @@ export type JsonRpcMiddleware< const DESTROYED_ERROR_MESSAGE = 'This engine is destroyed and can no longer be used.'; +/** + * @deprecated Use `JsonRpcEngineV2` and its corresponding types instead. + */ export type JsonRpcNotificationHandler = ( notification: JsonRpcNotification, ) => void | Promise; @@ -62,7 +77,10 @@ type JsonRpcEngineArgs = { /** * A JSON-RPC request and response processor. + * * Give it a stack of middleware, pass it requests, and get back responses. + * + * @deprecated Use `JsonRpcEngineV2` instead. */ export class JsonRpcEngine extends SafeEventEmitter { /** diff --git a/packages/json-rpc-engine/src/legacy/createAsyncMiddleware.ts b/packages/json-rpc-engine/src/legacy/createAsyncMiddleware.ts index 5ec2b53541..5ca43da4af 100644 --- a/packages/json-rpc-engine/src/legacy/createAsyncMiddleware.ts +++ b/packages/json-rpc-engine/src/legacy/createAsyncMiddleware.ts @@ -40,6 +40,7 @@ type ReturnHandlerCallback = Parameters[0]; * The return handler will always be called. Its resolution of the promise * enables the control flow described above. * + * @deprecated Use `JsonRpcEngineV2` and its corresponding types instead. * @param asyncMiddleware - The asynchronous middleware function to wrap. * @returns The wrapped asynchronous middleware function, ready to be consumed * by JsonRpcEngine. diff --git a/packages/json-rpc-engine/src/legacy/createScaffoldMiddleware.ts b/packages/json-rpc-engine/src/legacy/createScaffoldMiddleware.ts index 04c2a90d58..eac2a66667 100644 --- a/packages/json-rpc-engine/src/legacy/createScaffoldMiddleware.ts +++ b/packages/json-rpc-engine/src/legacy/createScaffoldMiddleware.ts @@ -13,6 +13,7 @@ type ScaffoldMiddlewareHandler< * object is requested, this middleware will pass it to the corresponding * handler and return the result. * + * @deprecated Use `JsonRpcEngineV2` and its corresponding types instead. * @param handlers - The RPC method handler functions. * @returns The scaffold middleware function. */ diff --git a/packages/json-rpc-engine/src/legacy/idRemapMiddleware.ts b/packages/json-rpc-engine/src/legacy/idRemapMiddleware.ts index 8be5ed6e5f..ab33f74a2a 100644 --- a/packages/json-rpc-engine/src/legacy/idRemapMiddleware.ts +++ b/packages/json-rpc-engine/src/legacy/idRemapMiddleware.ts @@ -10,6 +10,7 @@ import { getUniqueId } from '../getUniqueId'; * * If used, should be the first middleware in the stack. * + * @deprecated Use `JsonRpcEngineV2` and its corresponding types instead. * @returns The ID remap middleware function. */ export function createIdRemapMiddleware(): JsonRpcMiddleware< diff --git a/packages/json-rpc-engine/src/legacy/mergeMiddleware.ts b/packages/json-rpc-engine/src/legacy/mergeMiddleware.ts index ba4efbe0b1..ab39e5b293 100644 --- a/packages/json-rpc-engine/src/legacy/mergeMiddleware.ts +++ b/packages/json-rpc-engine/src/legacy/mergeMiddleware.ts @@ -6,6 +6,7 @@ import { JsonRpcEngine } from './JsonRpcEngine'; /** * Takes a stack of middleware and joins them into a single middleware function. * + * @deprecated Use `JsonRpcEngineV2` and its corresponding types instead. * @param middlewareStack - The middleware stack to merge. * @returns The merged middleware function. */ From 67fde3f4c17da43e7bebcf976e1691b5d675e50c Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Mon, 14 Jul 2025 15:03:09 -0700 Subject: [PATCH 04/75] fix: Legacy json-rpc-engine paths --- eslint-warning-thresholds.json | 4 ++-- packages/base-controller/src/BaseController.test.ts | 2 +- .../composable-controller/src/ComposableController.test.ts | 2 +- .../src/wallet-getPermissions.ts | 2 +- .../src/wallet-requestPermissions.ts | 2 +- .../src/wallet-revokePermissions.ts | 2 +- .../eth-json-rpc-provider/src/provider-from-engine.test.ts | 2 +- packages/eth-json-rpc-provider/src/provider-from-engine.ts | 2 +- .../src/provider-from-middleware.test.ts | 2 +- .../eth-json-rpc-provider/src/provider-from-middleware.ts | 4 ++-- .../src/safe-event-emitter-provider.test.ts | 2 +- .../eth-json-rpc-provider/src/safe-event-emitter-provider.ts | 2 +- packages/json-rpc-middleware-stream/src/createEngineStream.ts | 2 +- .../json-rpc-middleware-stream/src/createStreamMiddleware.ts | 2 +- packages/json-rpc-middleware-stream/src/index.test.ts | 2 +- .../src/handlers/wallet-createSession.ts | 2 +- .../src/handlers/wallet-revokeSession.ts | 2 +- .../src/middlewares/MultichainMiddlewareManager.ts | 2 +- .../middlewares/multichainMethodCallValidatorMiddleware.ts | 2 +- packages/network-controller/src/create-network-client.ts | 4 ++-- .../permission-controller/src/PermissionController.test.ts | 2 +- packages/permission-controller/src/permission-middleware.ts | 4 ++-- .../src/rpc-methods/getPermissions.test.ts | 2 +- .../permission-controller/src/rpc-methods/getPermissions.ts | 2 +- .../src/rpc-methods/requestPermissions.test.ts | 2 +- .../src/rpc-methods/requestPermissions.ts | 2 +- .../src/rpc-methods/revokePermissions.test.ts | 2 +- .../src/rpc-methods/revokePermissions.ts | 2 +- packages/permission-controller/src/utils.ts | 2 +- .../permission-log-controller/src/PermissionLogController.ts | 2 +- .../tests/PermissionLogController.test.ts | 2 +- .../src/SelectedNetworkMiddleware.ts | 2 +- .../tests/SelectedNetworkMiddleware.test.ts | 2 +- tests/fake-provider.ts | 2 +- 34 files changed, 38 insertions(+), 38 deletions(-) diff --git a/eslint-warning-thresholds.json b/eslint-warning-thresholds.json index 0114cb500b..02188aa566 100644 --- a/eslint-warning-thresholds.json +++ b/eslint-warning-thresholds.json @@ -191,10 +191,10 @@ "packages/gas-fee-controller/src/determineGasFeeCalculations.ts": { "jsdoc/tag-lines": 4 }, - "packages/json-rpc-engine/src/JsonRpcEngine.test.ts": { + "packages/json-rpc-engine/src/legacy/JsonRpcEngine.test.ts": { "jest/no-conditional-in-test": 2 }, - "packages/json-rpc-engine/src/JsonRpcEngine.ts": { + "packages/json-rpc-engine/src/legacy/JsonRpcEngine.ts": { "@typescript-eslint/prefer-promise-reject-errors": 2 }, "packages/json-rpc-middleware-stream/src/index.test.ts": { diff --git a/packages/base-controller/src/BaseController.test.ts b/packages/base-controller/src/BaseController.test.ts index 9fe417892c..0eb36fe4c9 100644 --- a/packages/base-controller/src/BaseController.test.ts +++ b/packages/base-controller/src/BaseController.test.ts @@ -14,7 +14,7 @@ import { } from './BaseController'; import { Messenger } from './Messenger'; import type { RestrictedMessenger } from './RestrictedMessenger'; -import { JsonRpcEngine } from '../../json-rpc-engine/src'; +import { JsonRpcEngine } from '../../json-rpc-engine/src/legacy'; export const countControllerName = 'CountController'; diff --git a/packages/composable-controller/src/ComposableController.test.ts b/packages/composable-controller/src/ComposableController.test.ts index c5c8bce434..d9fcabe4bc 100644 --- a/packages/composable-controller/src/ComposableController.test.ts +++ b/packages/composable-controller/src/ComposableController.test.ts @@ -1,6 +1,6 @@ import type { RestrictedMessenger } from '@metamask/base-controller'; import { BaseController, Messenger } from '@metamask/base-controller'; -import { JsonRpcEngine } from '@metamask/json-rpc-engine'; +import { JsonRpcEngine } from '@metamask/json-rpc-engine/legacy'; import type { Patch } from 'immer'; import * as sinon from 'sinon'; diff --git a/packages/eip1193-permission-middleware/src/wallet-getPermissions.ts b/packages/eip1193-permission-middleware/src/wallet-getPermissions.ts index e6fc15be93..eec622f98c 100644 --- a/packages/eip1193-permission-middleware/src/wallet-getPermissions.ts +++ b/packages/eip1193-permission-middleware/src/wallet-getPermissions.ts @@ -7,7 +7,7 @@ import { import type { AsyncJsonRpcEngineNextCallback, JsonRpcEngineEndCallback, -} from '@metamask/json-rpc-engine'; +} from '@metamask/json-rpc-engine/legacy'; import { type CaveatSpecificationConstraint, MethodNames, diff --git a/packages/eip1193-permission-middleware/src/wallet-requestPermissions.ts b/packages/eip1193-permission-middleware/src/wallet-requestPermissions.ts index 06fd2b983d..3e63576e68 100644 --- a/packages/eip1193-permission-middleware/src/wallet-requestPermissions.ts +++ b/packages/eip1193-permission-middleware/src/wallet-requestPermissions.ts @@ -8,7 +8,7 @@ import { isPlainObject } from '@metamask/controller-utils'; import type { AsyncJsonRpcEngineNextCallback, JsonRpcEngineEndCallback, -} from '@metamask/json-rpc-engine'; +} from '@metamask/json-rpc-engine/legacy'; import { type Caveat, type CaveatSpecificationConstraint, diff --git a/packages/eip1193-permission-middleware/src/wallet-revokePermissions.ts b/packages/eip1193-permission-middleware/src/wallet-revokePermissions.ts index af9b2ccf2b..fa5454aa4c 100644 --- a/packages/eip1193-permission-middleware/src/wallet-revokePermissions.ts +++ b/packages/eip1193-permission-middleware/src/wallet-revokePermissions.ts @@ -2,7 +2,7 @@ import { Caip25EndowmentPermissionName } from '@metamask/chain-agnostic-permissi import type { AsyncJsonRpcEngineNextCallback, JsonRpcEngineEndCallback, -} from '@metamask/json-rpc-engine'; +} from '@metamask/json-rpc-engine/legacy'; import { invalidParams, MethodNames } from '@metamask/permission-controller'; import { isNonEmptyArray, diff --git a/packages/eth-json-rpc-provider/src/provider-from-engine.test.ts b/packages/eth-json-rpc-provider/src/provider-from-engine.test.ts index ca90a1deac..be5e51c9b5 100644 --- a/packages/eth-json-rpc-provider/src/provider-from-engine.test.ts +++ b/packages/eth-json-rpc-provider/src/provider-from-engine.test.ts @@ -1,4 +1,4 @@ -import { JsonRpcEngine } from '@metamask/json-rpc-engine'; +import { JsonRpcEngine } from '@metamask/json-rpc-engine/legacy'; import { providerErrors } from '@metamask/rpc-errors'; import { providerFromEngine } from './provider-from-engine'; diff --git a/packages/eth-json-rpc-provider/src/provider-from-engine.ts b/packages/eth-json-rpc-provider/src/provider-from-engine.ts index 00c62bd543..18ba797aef 100644 --- a/packages/eth-json-rpc-provider/src/provider-from-engine.ts +++ b/packages/eth-json-rpc-provider/src/provider-from-engine.ts @@ -1,4 +1,4 @@ -import type { JsonRpcEngine } from '@metamask/json-rpc-engine'; +import type { JsonRpcEngine } from '@metamask/json-rpc-engine/legacy'; import { SafeEventEmitterProvider } from './safe-event-emitter-provider'; diff --git a/packages/eth-json-rpc-provider/src/provider-from-middleware.test.ts b/packages/eth-json-rpc-provider/src/provider-from-middleware.test.ts index c3e73d3153..881b54c8f6 100644 --- a/packages/eth-json-rpc-provider/src/provider-from-middleware.test.ts +++ b/packages/eth-json-rpc-provider/src/provider-from-middleware.test.ts @@ -1,4 +1,4 @@ -import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine'; +import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine/legacy'; import { providerErrors } from '@metamask/rpc-errors'; import { providerFromMiddleware } from './provider-from-middleware'; diff --git a/packages/eth-json-rpc-provider/src/provider-from-middleware.ts b/packages/eth-json-rpc-provider/src/provider-from-middleware.ts index 461af24788..0dd1f014d9 100644 --- a/packages/eth-json-rpc-provider/src/provider-from-middleware.ts +++ b/packages/eth-json-rpc-provider/src/provider-from-middleware.ts @@ -1,5 +1,5 @@ -import { JsonRpcEngine } from '@metamask/json-rpc-engine'; -import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine'; +import { JsonRpcEngine } from '@metamask/json-rpc-engine/legacy'; +import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine/legacy'; import type { Json, JsonRpcParams } from '@metamask/utils'; import { providerFromEngine } from './provider-from-engine'; diff --git a/packages/eth-json-rpc-provider/src/safe-event-emitter-provider.test.ts b/packages/eth-json-rpc-provider/src/safe-event-emitter-provider.test.ts index 9bd35b38ef..046d447728 100644 --- a/packages/eth-json-rpc-provider/src/safe-event-emitter-provider.test.ts +++ b/packages/eth-json-rpc-provider/src/safe-event-emitter-provider.test.ts @@ -1,7 +1,7 @@ import { Web3Provider } from '@ethersproject/providers'; import EthQuery from '@metamask/eth-query'; import EthJsQuery from '@metamask/ethjs-query'; -import { JsonRpcEngine } from '@metamask/json-rpc-engine'; +import { JsonRpcEngine } from '@metamask/json-rpc-engine/legacy'; import { providerErrors } from '@metamask/rpc-errors'; import { type JsonRpcRequest, type Json } from '@metamask/utils'; import { BrowserProvider } from 'ethers'; diff --git a/packages/eth-json-rpc-provider/src/safe-event-emitter-provider.ts b/packages/eth-json-rpc-provider/src/safe-event-emitter-provider.ts index 69ed56eee7..971a4e7d42 100644 --- a/packages/eth-json-rpc-provider/src/safe-event-emitter-provider.ts +++ b/packages/eth-json-rpc-provider/src/safe-event-emitter-provider.ts @@ -1,4 +1,4 @@ -import type { JsonRpcEngine } from '@metamask/json-rpc-engine'; +import type { JsonRpcEngine } from '@metamask/json-rpc-engine/legacy'; import { JsonRpcError } from '@metamask/rpc-errors'; import SafeEventEmitter from '@metamask/safe-event-emitter'; import type { diff --git a/packages/json-rpc-middleware-stream/src/createEngineStream.ts b/packages/json-rpc-middleware-stream/src/createEngineStream.ts index f2dc51f9d5..169d7ee489 100644 --- a/packages/json-rpc-middleware-stream/src/createEngineStream.ts +++ b/packages/json-rpc-middleware-stream/src/createEngineStream.ts @@ -1,4 +1,4 @@ -import type { JsonRpcEngine } from '@metamask/json-rpc-engine'; +import type { JsonRpcEngine } from '@metamask/json-rpc-engine/legacy'; import type { JsonRpcRequest } from '@metamask/utils'; import { Duplex } from 'readable-stream'; diff --git a/packages/json-rpc-middleware-stream/src/createStreamMiddleware.ts b/packages/json-rpc-middleware-stream/src/createStreamMiddleware.ts index 3cbf8a048e..af91600dcc 100644 --- a/packages/json-rpc-middleware-stream/src/createStreamMiddleware.ts +++ b/packages/json-rpc-middleware-stream/src/createStreamMiddleware.ts @@ -2,7 +2,7 @@ import type { JsonRpcEngineNextCallback, JsonRpcEngineEndCallback, JsonRpcMiddleware, -} from '@metamask/json-rpc-engine'; +} from '@metamask/json-rpc-engine/legacy'; import SafeEventEmitter from '@metamask/safe-event-emitter'; import { hasProperty, diff --git a/packages/json-rpc-middleware-stream/src/index.test.ts b/packages/json-rpc-middleware-stream/src/index.test.ts index 6395c629a7..d62cc8b6f3 100644 --- a/packages/json-rpc-middleware-stream/src/index.test.ts +++ b/packages/json-rpc-middleware-stream/src/index.test.ts @@ -1,4 +1,4 @@ -import { JsonRpcEngine } from '@metamask/json-rpc-engine'; +import { JsonRpcEngine } from '@metamask/json-rpc-engine/legacy'; import PortStream from 'extension-port-stream'; import type { Duplex } from 'stream'; import type { Runtime } from 'webextension-polyfill-ts'; diff --git a/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts b/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts index bad5605633..ed84072ae4 100644 --- a/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts +++ b/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts @@ -19,7 +19,7 @@ import { isEqualCaseInsensitive } from '@metamask/controller-utils'; import type { JsonRpcEngineEndCallback, JsonRpcEngineNextCallback, -} from '@metamask/json-rpc-engine'; +} from '@metamask/json-rpc-engine/legacy'; import type { NetworkController } from '@metamask/network-controller'; import { invalidParams, diff --git a/packages/multichain-api-middleware/src/handlers/wallet-revokeSession.ts b/packages/multichain-api-middleware/src/handlers/wallet-revokeSession.ts index 59fced841d..6b81efcb4b 100644 --- a/packages/multichain-api-middleware/src/handlers/wallet-revokeSession.ts +++ b/packages/multichain-api-middleware/src/handlers/wallet-revokeSession.ts @@ -2,7 +2,7 @@ import { Caip25EndowmentPermissionName } from '@metamask/chain-agnostic-permissi import type { JsonRpcEngineNextCallback, JsonRpcEngineEndCallback, -} from '@metamask/json-rpc-engine'; +} from '@metamask/json-rpc-engine/legacy'; import { PermissionDoesNotExistError, UnrecognizedSubjectError, diff --git a/packages/multichain-api-middleware/src/middlewares/MultichainMiddlewareManager.ts b/packages/multichain-api-middleware/src/middlewares/MultichainMiddlewareManager.ts index cb996646c0..9119646ac5 100644 --- a/packages/multichain-api-middleware/src/middlewares/MultichainMiddlewareManager.ts +++ b/packages/multichain-api-middleware/src/middlewares/MultichainMiddlewareManager.ts @@ -2,7 +2,7 @@ import type { ExternalScopeString } from '@metamask/chain-agnostic-permission'; import type { JsonRpcEngineEndCallback, JsonRpcEngineNextCallback, -} from '@metamask/json-rpc-engine'; +} from '@metamask/json-rpc-engine/legacy'; import { rpcErrors } from '@metamask/rpc-errors'; import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; diff --git a/packages/multichain-api-middleware/src/middlewares/multichainMethodCallValidatorMiddleware.ts b/packages/multichain-api-middleware/src/middlewares/multichainMethodCallValidatorMiddleware.ts index 7797793084..6a0e8a43d1 100644 --- a/packages/multichain-api-middleware/src/middlewares/multichainMethodCallValidatorMiddleware.ts +++ b/packages/multichain-api-middleware/src/middlewares/multichainMethodCallValidatorMiddleware.ts @@ -1,5 +1,5 @@ import { MultiChainOpenRPCDocument } from '@metamask/api-specs'; -import { createAsyncMiddleware } from '@metamask/json-rpc-engine'; +import { createAsyncMiddleware } from '@metamask/json-rpc-engine/legacy'; import { rpcErrors } from '@metamask/rpc-errors'; import { isObject } from '@metamask/utils'; import type { JsonRpcError, JsonRpcParams } from '@metamask/utils'; diff --git a/packages/network-controller/src/create-network-client.ts b/packages/network-controller/src/create-network-client.ts index c6388ae3c1..34fe1eb0db 100644 --- a/packages/network-controller/src/create-network-client.ts +++ b/packages/network-controller/src/create-network-client.ts @@ -22,8 +22,8 @@ import { createScaffoldMiddleware, JsonRpcEngine, mergeMiddleware, -} from '@metamask/json-rpc-engine'; -import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine'; +} from '@metamask/json-rpc-engine/legacy'; +import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine/legacy'; import type { Hex, Json, JsonRpcParams } from '@metamask/utils'; import type { NetworkControllerMessenger } from './NetworkController'; diff --git a/packages/permission-controller/src/PermissionController.test.ts b/packages/permission-controller/src/PermissionController.test.ts index 1d9ae75786..e6e6466907 100644 --- a/packages/permission-controller/src/PermissionController.test.ts +++ b/packages/permission-controller/src/PermissionController.test.ts @@ -6,7 +6,7 @@ import type { } from '@metamask/approval-controller'; import { Messenger } from '@metamask/base-controller'; import { isPlainObject } from '@metamask/controller-utils'; -import { JsonRpcEngine } from '@metamask/json-rpc-engine'; +import { JsonRpcEngine } from '@metamask/json-rpc-engine/legacy'; import type { Json, JsonRpcRequest } from '@metamask/utils'; import { assertIsJsonRpcFailure, diff --git a/packages/permission-controller/src/permission-middleware.ts b/packages/permission-controller/src/permission-middleware.ts index e661464211..dc4cf0272e 100644 --- a/packages/permission-controller/src/permission-middleware.ts +++ b/packages/permission-controller/src/permission-middleware.ts @@ -1,10 +1,10 @@ -import { createAsyncMiddleware } from '@metamask/json-rpc-engine'; +import { createAsyncMiddleware } from '@metamask/json-rpc-engine/legacy'; import type { // eslint-disable-next-line @typescript-eslint/no-unused-vars JsonRpcEngine, JsonRpcMiddleware, AsyncJsonRpcEngineNextCallback, -} from '@metamask/json-rpc-engine'; +} from '@metamask/json-rpc-engine/legacy'; import type { Json, PendingJsonRpcResponse, diff --git a/packages/permission-controller/src/rpc-methods/getPermissions.test.ts b/packages/permission-controller/src/rpc-methods/getPermissions.test.ts index f7724f4617..8a11273141 100644 --- a/packages/permission-controller/src/rpc-methods/getPermissions.test.ts +++ b/packages/permission-controller/src/rpc-methods/getPermissions.test.ts @@ -1,4 +1,4 @@ -import { JsonRpcEngine } from '@metamask/json-rpc-engine'; +import { JsonRpcEngine } from '@metamask/json-rpc-engine/legacy'; import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; import { assertIsJsonRpcSuccess } from '@metamask/utils'; diff --git a/packages/permission-controller/src/rpc-methods/getPermissions.ts b/packages/permission-controller/src/rpc-methods/getPermissions.ts index b711997322..5d181927e3 100644 --- a/packages/permission-controller/src/rpc-methods/getPermissions.ts +++ b/packages/permission-controller/src/rpc-methods/getPermissions.ts @@ -1,4 +1,4 @@ -import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; +import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine/legacy'; import type { PendingJsonRpcResponse } from '@metamask/utils'; import type { PermissionConstraint } from '../Permission'; diff --git a/packages/permission-controller/src/rpc-methods/requestPermissions.test.ts b/packages/permission-controller/src/rpc-methods/requestPermissions.test.ts index 77dfc93ae5..df033e92a4 100644 --- a/packages/permission-controller/src/rpc-methods/requestPermissions.test.ts +++ b/packages/permission-controller/src/rpc-methods/requestPermissions.test.ts @@ -1,7 +1,7 @@ import { JsonRpcEngine, createAsyncMiddleware, -} from '@metamask/json-rpc-engine'; +} from '@metamask/json-rpc-engine/legacy'; import { rpcErrors, serializeError } from '@metamask/rpc-errors'; import { assertIsJsonRpcFailure, diff --git a/packages/permission-controller/src/rpc-methods/requestPermissions.ts b/packages/permission-controller/src/rpc-methods/requestPermissions.ts index a74ba98696..c615db8af9 100644 --- a/packages/permission-controller/src/rpc-methods/requestPermissions.ts +++ b/packages/permission-controller/src/rpc-methods/requestPermissions.ts @@ -1,5 +1,5 @@ import { isPlainObject } from '@metamask/controller-utils'; -import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; +import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine/legacy'; import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; import { invalidParams } from '../errors'; diff --git a/packages/permission-controller/src/rpc-methods/revokePermissions.test.ts b/packages/permission-controller/src/rpc-methods/revokePermissions.test.ts index 5d9a9fdc21..2ebba18940 100644 --- a/packages/permission-controller/src/rpc-methods/revokePermissions.test.ts +++ b/packages/permission-controller/src/rpc-methods/revokePermissions.test.ts @@ -1,4 +1,4 @@ -import { JsonRpcEngine } from '@metamask/json-rpc-engine'; +import { JsonRpcEngine } from '@metamask/json-rpc-engine/legacy'; import { rpcErrors } from '@metamask/rpc-errors'; import type { Json, JsonRpcRequest } from '@metamask/utils'; import { diff --git a/packages/permission-controller/src/rpc-methods/revokePermissions.ts b/packages/permission-controller/src/rpc-methods/revokePermissions.ts index 9823b072c6..186bd880b1 100644 --- a/packages/permission-controller/src/rpc-methods/revokePermissions.ts +++ b/packages/permission-controller/src/rpc-methods/revokePermissions.ts @@ -1,4 +1,4 @@ -import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; +import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine/legacy'; import { isNonEmptyArray, type Json, diff --git a/packages/permission-controller/src/utils.ts b/packages/permission-controller/src/utils.ts index d29c2db358..2f798207df 100644 --- a/packages/permission-controller/src/utils.ts +++ b/packages/permission-controller/src/utils.ts @@ -1,7 +1,7 @@ import type { JsonRpcEngineEndCallback, JsonRpcEngineNextCallback, -} from '@metamask/json-rpc-engine'; +} from '@metamask/json-rpc-engine/legacy'; import type { Json, JsonRpcParams, diff --git a/packages/permission-log-controller/src/PermissionLogController.ts b/packages/permission-log-controller/src/PermissionLogController.ts index a75cb9aad9..4773613e51 100644 --- a/packages/permission-log-controller/src/PermissionLogController.ts +++ b/packages/permission-log-controller/src/PermissionLogController.ts @@ -2,7 +2,7 @@ import { BaseController, type RestrictedMessenger, } from '@metamask/base-controller'; -import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine'; +import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine/legacy'; import { type Json, type JsonRpcRequest, diff --git a/packages/permission-log-controller/tests/PermissionLogController.test.ts b/packages/permission-log-controller/tests/PermissionLogController.test.ts index 695b3887cf..e47296c8a0 100644 --- a/packages/permission-log-controller/tests/PermissionLogController.test.ts +++ b/packages/permission-log-controller/tests/PermissionLogController.test.ts @@ -2,7 +2,7 @@ import { Messenger } from '@metamask/base-controller'; import type { JsonRpcEngineReturnHandler, JsonRpcEngineNextCallback, -} from '@metamask/json-rpc-engine'; +} from '@metamask/json-rpc-engine/legacy'; import { type PendingJsonRpcResponse, type JsonRpcRequest, diff --git a/packages/selected-network-controller/src/SelectedNetworkMiddleware.ts b/packages/selected-network-controller/src/SelectedNetworkMiddleware.ts index eb84a503e9..94c47212d0 100644 --- a/packages/selected-network-controller/src/SelectedNetworkMiddleware.ts +++ b/packages/selected-network-controller/src/SelectedNetworkMiddleware.ts @@ -1,4 +1,4 @@ -import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine'; +import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine/legacy'; import type { NetworkClientId } from '@metamask/network-controller'; import type { Json, JsonRpcParams, JsonRpcRequest } from '@metamask/utils'; diff --git a/packages/selected-network-controller/tests/SelectedNetworkMiddleware.test.ts b/packages/selected-network-controller/tests/SelectedNetworkMiddleware.test.ts index 13d66bcacd..4e78f969ee 100644 --- a/packages/selected-network-controller/tests/SelectedNetworkMiddleware.test.ts +++ b/packages/selected-network-controller/tests/SelectedNetworkMiddleware.test.ts @@ -1,5 +1,5 @@ import { Messenger } from '@metamask/base-controller'; -import { JsonRpcEngine } from '@metamask/json-rpc-engine'; +import { JsonRpcEngine } from '@metamask/json-rpc-engine/legacy'; import type { JsonRpcResponse } from '@metamask/utils'; import { SelectedNetworkControllerActionTypes } from '../src/SelectedNetworkController'; diff --git a/tests/fake-provider.ts b/tests/fake-provider.ts index 4e76a4136f..b7e9e6a29a 100644 --- a/tests/fake-provider.ts +++ b/tests/fake-provider.ts @@ -1,5 +1,5 @@ import { SafeEventEmitterProvider } from '@metamask/eth-json-rpc-provider'; -import { JsonRpcEngine } from '@metamask/json-rpc-engine'; +import { JsonRpcEngine } from '@metamask/json-rpc-engine/legacy'; import type { Json, JsonRpcId, From 67da90076279e32fb9b1fe9c12024f2e8d727701 Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Tue, 15 Jul 2025 12:24:50 -0700 Subject: [PATCH 05/75] chore: Silence some lint warnings in the legacy engine --- packages/json-rpc-engine/src/legacy/JsonRpcEngine.test.ts | 4 ++++ packages/json-rpc-engine/src/legacy/JsonRpcEngine.ts | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/packages/json-rpc-engine/src/legacy/JsonRpcEngine.test.ts b/packages/json-rpc-engine/src/legacy/JsonRpcEngine.test.ts index 83dd990bef..ff0f0cc845 100644 --- a/packages/json-rpc-engine/src/legacy/JsonRpcEngine.test.ts +++ b/packages/json-rpc-engine/src/legacy/JsonRpcEngine.test.ts @@ -428,6 +428,8 @@ describe('JsonRpcEngine', () => { const engine = new JsonRpcEngine(); engine.push(function (request, response, _next, end) { + // Separate handling for the 4th request. + // eslint-disable-next-line jest/no-conditional-in-test if (request.id === 4) { delete response.result; response.error = rpcErrors.internal({ message: 'foobar' }); @@ -465,6 +467,8 @@ describe('JsonRpcEngine', () => { const engine = new JsonRpcEngine(); engine.push(function (request, response, _next, end) { + // Separate handling for the 4th request. + // eslint-disable-next-line jest/no-conditional-in-test if (request.id === 4) { delete response.result; response.error = rpcErrors.internal({ message: 'foobar' }); diff --git a/packages/json-rpc-engine/src/legacy/JsonRpcEngine.ts b/packages/json-rpc-engine/src/legacy/JsonRpcEngine.ts index a59576b5ac..aa5e73ddec 100644 --- a/packages/json-rpc-engine/src/legacy/JsonRpcEngine.ts +++ b/packages/json-rpc-engine/src/legacy/JsonRpcEngine.ts @@ -379,6 +379,8 @@ export class JsonRpcEngine extends SafeEventEmitter { // For notifications, the response will be `undefined`, and any caught // errors are unexpected and should be surfaced to the caller. if (error && res === undefined) { + // We are not going to change this behavior. + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors reject(error); } else { // Excepting notifications, there will always be a response, and it will @@ -622,6 +624,8 @@ export class JsonRpcEngine extends SafeEventEmitter { ): Promise { for (const handler of handlers) { await new Promise((resolve, reject) => { + // We are not going to change this behavior. + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors handler((error) => (error ? reject(error) : resolve())); }); } From 3d7f3efd4dfd5114787e2352dd71989f77c3d281 Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Thu, 10 Jul 2025 12:45:28 -0700 Subject: [PATCH 06/75] feat: Add MiddlewareEngine --- packages/json-rpc-engine/package.json | 4 +- .../json-rpc-engine/src/MiddlewareEngine.ts | 162 ++++++++++++++++++ .../src/legacy/JsonRpcEngine.ts | 18 +- packages/json-rpc-engine/src/utils.ts | 29 ++++ yarn.lock | 2 + 5 files changed, 201 insertions(+), 14 deletions(-) create mode 100644 packages/json-rpc-engine/src/MiddlewareEngine.ts create mode 100644 packages/json-rpc-engine/src/utils.ts diff --git a/packages/json-rpc-engine/package.json b/packages/json-rpc-engine/package.json index b66e81e169..42503d869e 100644 --- a/packages/json-rpc-engine/package.json +++ b/packages/json-rpc-engine/package.json @@ -68,13 +68,15 @@ "dependencies": { "@metamask/rpc-errors": "^7.0.2", "@metamask/safe-event-emitter": "^3.0.0", - "@metamask/utils": "^11.4.2" + "@metamask/utils": "^11.4.2", + "immer": "^9.0.6" }, "devDependencies": { "@lavamoat/allow-scripts": "^3.0.4", "@lavamoat/preinstall-always-fail": "^2.1.0", "@metamask/auto-changelog": "^3.4.4", "@types/jest": "^27.4.1", + "@types/lodash": "^4.14.191", "deepmerge": "^4.2.2", "jest": "^27.5.1", "jest-it-up": "^2.0.2", diff --git a/packages/json-rpc-engine/src/MiddlewareEngine.ts b/packages/json-rpc-engine/src/MiddlewareEngine.ts new file mode 100644 index 0000000000..4c157dcb67 --- /dev/null +++ b/packages/json-rpc-engine/src/MiddlewareEngine.ts @@ -0,0 +1,162 @@ +import type { + Json, + JsonRpcRequest, + JsonRpcNotification, + JsonRpcParams, + NonEmptyArray, +} from '@metamask/utils'; +import { freeze } from 'immer'; +import cloneDeep from 'lodash/clonedeep'; + +import { stringify } from './utils'; +import type { JsonRpcCall } from './utils'; + +export const EndNotification = Symbol.for('MiddlewareEngine:EndNotification'); + +type Context = Record; + +type ReturnHandler< + Result extends MiddlewareResultConstraint>, +> = (result: Readonly) => Result | Promise; + +type MiddlewareResultConstraint> = + Message extends JsonRpcNotification + ? void | typeof EndNotification + : void | Json | ReturnHandler>; + +export type JsonRpcMiddleware< + Message extends JsonRpcCall, + Result extends MiddlewareResultConstraint, +> = (message: Readonly, context: Context) => Result | Promise; + +type Options< + Message extends JsonRpcCall, + Result extends MiddlewareResultConstraint, +> = { + middleware: NonEmptyArray>; +}; + +export class MiddlewareEngine< + Message extends JsonRpcCall, + Result extends MiddlewareResultConstraint, +> { + readonly #middleware: readonly JsonRpcMiddleware[]; + + constructor({ middleware }: Options) { + this.#middleware = [...middleware]; + } + + /** + * Handle a JSON-RPC request. A response will be returned. + * + * @param request - The JSON-RPC request to handle. + * @returns The JSON-RPC response. + */ + async handle(request: JsonRpcRequest & Message): Promise; + + /** + * Handle a JSON-RPC notification. No response will be returned. + * + * @param message - The JSON-RPC notification to handle. + */ + async handle(message: JsonRpcNotification & Message): Promise; + + async handle(req: Message): Promise { + const result = await this.#handle(req); + return result === EndNotification ? undefined : result; + } + + async #handle(message: Message, context: Context = {}): Promise { + const immutableMessage = freeze(cloneDeep(message), true); + const returnHandlers: ReturnHandler[] = []; + + let result: Result | undefined; + for (const middleware of this.#middleware) { + const currentResult = await middleware(immutableMessage, context); + + if (typeof currentResult === 'function') { + returnHandlers.push(currentResult); + } else if (currentResult !== undefined) { + result = currentResult; + break; + } + } + + if (result === undefined) { + throw new Error(`Nothing ended request:\n${stringify(message)}`); + } + + if (returnHandlers.length > 0) { + if (result === EndNotification) { + throw new Error( + `Received return handlers for notification:\n${stringify(message)}`, + ); + } + + result = freeze(result, true); + for (const returnHandler of returnHandlers) { + const updatedResult = await returnHandler(result); + if (updatedResult !== undefined) { + result = freeze(updatedResult, true); + } + } + } + + return result; + } + + /** + * Convert the engine into a JSON-RPC middleware. + * + * @returns The JSON-RPC middleware. + */ + asMiddleware(): JsonRpcMiddleware { + return async (req, context) => this.#handle(req, context); + } +} + +// type Bar = JsonRpcMiddleware, typeof EndNotification>; + +// const bar: Bar = (req, context) => { +// return EndNotification; +// }; + +// export type JsonRpcMiddleware< +// Message extends JsonRpcCall, +// Result extends Json, +// > = (message: Readonly, context: Context) => +// // | Promise | Result | void; +// | void +// | Promise +// | (Message extends JsonRpcNotification +// ? typeof EndNotification | Promise +// : Result | ReturnHandler | Promise>); + +// type RequestMiddleware, Result extends Json> = ( +// req: Readonly, +// context: Context, +// ) => void | Promise | Result | Promise; + +// type NotificationMiddleware> = ( +// req: Readonly, +// context: Context, +// ) => +// | void +// | Promise +// | typeof EndNotification +// | Promise; + +// type CallMiddleware, Result extends Json> = ( +// req: Readonly, +// context: Context, +// ) => +// | void +// | Promise +// | (Message extends JsonRpcNotification +// ? typeof EndNotification | Promise +// : Result | Promise); + +// export type JsonRpcMiddleware< +// Message extends JsonRpcCall, +// Result extends Json, +// > = RequestMiddleware | NotificationMiddleware | CallMiddleware; diff --git a/packages/json-rpc-engine/src/legacy/JsonRpcEngine.ts b/packages/json-rpc-engine/src/legacy/JsonRpcEngine.ts index aa5e73ddec..7824a3fcee 100644 --- a/packages/json-rpc-engine/src/legacy/JsonRpcEngine.ts +++ b/packages/json-rpc-engine/src/legacy/JsonRpcEngine.ts @@ -15,6 +15,8 @@ import { isJsonRpcRequest, } from '@metamask/utils'; +import { stringify } from '../utils'; + export type JsonRpcEngineCallbackError = Error | SerializedJsonRpcError | null; /** @@ -590,7 +592,7 @@ export class JsonRpcEngine extends SafeEventEmitter { new JsonRpcError( errorCodes.rpc.internal, `JsonRpcEngine: "next" return handlers must be functions. ` + - `Received "${typeof returnHandler}" for request:\n${jsonify( + `Received "${typeof returnHandler}" for request:\n${stringify( request, )}`, { request }, @@ -648,7 +650,7 @@ export class JsonRpcEngine extends SafeEventEmitter { if (!hasProperty(response, 'result') && !hasProperty(response, 'error')) { throw new JsonRpcError( errorCodes.rpc.internal, - `JsonRpcEngine: Response has no error or result for request:\n${jsonify( + `JsonRpcEngine: Response has no error or result for request:\n${stringify( request, )}`, { request }, @@ -658,19 +660,9 @@ export class JsonRpcEngine extends SafeEventEmitter { if (!isComplete) { throw new JsonRpcError( errorCodes.rpc.internal, - `JsonRpcEngine: Nothing ended request:\n${jsonify(request)}`, + `JsonRpcEngine: Nothing ended request:\n${stringify(request)}`, { request }, ); } } } - -/** - * JSON-stringifies a request object. - * - * @param request - The request object to JSON-stringify. - * @returns The JSON-stringified request object. - */ -function jsonify(request: JsonRpcRequest): string { - return JSON.stringify(request, null, 2); -} diff --git a/packages/json-rpc-engine/src/utils.ts b/packages/json-rpc-engine/src/utils.ts new file mode 100644 index 0000000000..ea2370d2e2 --- /dev/null +++ b/packages/json-rpc-engine/src/utils.ts @@ -0,0 +1,29 @@ +import type { Json } from '@metamask/utils'; +import { + hasProperty, + type JsonRpcNotification, + type JsonRpcParams, + type JsonRpcRequest, +} from '@metamask/utils'; + +export type JsonRpcCall = + | JsonRpcNotification + | JsonRpcRequest; + +export const isRequest = ( + msg: JsonRpcCall, +): msg is JsonRpcRequest => hasProperty(msg, 'id'); + +export const isNotification = ( + msg: JsonRpcCall, +): msg is JsonRpcNotification => !isRequest(msg); + +/** + * JSON-stringifies a JSON value. + * + * @param value - The value to stringify. + * @returns The stringified value. + */ +export function stringify(value: Json | Readonly): string { + return JSON.stringify(value, null, 2); +} diff --git a/yarn.lock b/yarn.lock index 3f667bbdaf..b9f25674d0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3597,7 +3597,9 @@ __metadata: "@metamask/safe-event-emitter": "npm:^3.0.0" "@metamask/utils": "npm:^11.4.2" "@types/jest": "npm:^27.4.1" + "@types/lodash": "npm:^4.14.191" deepmerge: "npm:^4.2.2" + immer: "npm:^9.0.6" jest: "npm:^27.5.1" jest-it-up: "npm:^2.0.2" ts-jest: "npm:^27.1.4" From 7cf81be166312634070f93dd4d78674131ecda11 Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Mon, 14 Jul 2025 10:46:22 -0700 Subject: [PATCH 07/75] feat: MiddlewareEngine: types WIP --- .../src/MiddlewareEngine.test.ts | 106 ++++++++++++++ .../json-rpc-engine/src/MiddlewareEngine.ts | 129 +++++++++++++----- packages/json-rpc-engine/src/utils.ts | 14 +- 3 files changed, 214 insertions(+), 35 deletions(-) create mode 100644 packages/json-rpc-engine/src/MiddlewareEngine.test.ts diff --git a/packages/json-rpc-engine/src/MiddlewareEngine.test.ts b/packages/json-rpc-engine/src/MiddlewareEngine.test.ts new file mode 100644 index 0000000000..aafe42fddf --- /dev/null +++ b/packages/json-rpc-engine/src/MiddlewareEngine.test.ts @@ -0,0 +1,106 @@ +import { Json } from '@metamask/utils'; + +import type { JsonRpcMiddleware } from './MiddlewareEngine'; +import { MiddlewareEngine, EndNotification } from './MiddlewareEngine'; +import type { JsonRpcCall, JsonRpcNotification, JsonRpcRequest } from './utils'; + +describe('MiddlewareEngine', () => { + it('should handle a request', async () => { + const engine = new MiddlewareEngine({ + middleware: [ + (req: JsonRpcNotification, context): void => {}, + (req: JsonRpcNotification, context): typeof EndNotification => { + return EndNotification; + }, + (req: JsonRpcCall, context): void | typeof EndNotification => { + return EndNotification; + }, + // (req: JsonRpcRequest, context): void | typeof EndNotification => { + // return EndNotification; + // }, + ], + }); + + const middleware: JsonRpcMiddleware = ( + req, + context, + ) => {}; + const middleware2: JsonRpcMiddleware< + JsonRpcRequest, + // @ts-expect-error Should be illegal. + typeof EndNotification + > = (req, context) => { + return EndNotification; + }; + type foo = ReturnType; + + const engine2 = new MiddlewareEngine({ + middleware: [ + // @ts-expect-error Should be illegal. + (req: JsonRpcRequest, context): void | typeof EndNotification => { + return EndNotification; + }, + ], + }); + + const engine3 = new MiddlewareEngine({ + middleware: [ + ((req: JsonRpcRequest, context) => { + return null; + }) as JsonRpcMiddleware, + ], + }); + + const engine4 = new MiddlewareEngine({ + middleware: [ + ((req: JsonRpcCall, context): null => { + return null; + }) as JsonRpcMiddleware, + ], + }); + + const reqRes = await engine4.handle({ + id: '1', + method: 'foo', + jsonrpc: '2.0', + params: [], + }); + + const notifRes = await engine4.handle({ + method: 'foo', + jsonrpc: '2.0', + params: [], + }); + + const callRes = await engine4.handleAny({ + id: '1', + method: 'foo', + jsonrpc: '2.0', + params: [], + }); + + const a: JsonRpcRequest = { + id: '1', + method: 'foo', + jsonrpc: '2.0', + params: [], + }; + + const foo: JsonRpcCall = { + id: '1', + method: 'foo', + jsonrpc: '2.0', + params: [], + }; + + const bar: JsonRpcNotification = { + method: 'foo', + jsonrpc: '2.0', + params: [], + }; + + const fizz: JsonRpcNotification = foo; + + const b: JsonRpcRequest = foo; + }); +}); diff --git a/packages/json-rpc-engine/src/MiddlewareEngine.ts b/packages/json-rpc-engine/src/MiddlewareEngine.ts index 4c157dcb67..2c31945f02 100644 --- a/packages/json-rpc-engine/src/MiddlewareEngine.ts +++ b/packages/json-rpc-engine/src/MiddlewareEngine.ts @@ -2,44 +2,50 @@ import type { Json, JsonRpcRequest, JsonRpcNotification, - JsonRpcParams, NonEmptyArray, } from '@metamask/utils'; import { freeze } from 'immer'; import cloneDeep from 'lodash/clonedeep'; -import { stringify } from './utils'; +import { isRequest, stringify } from './utils'; import type { JsonRpcCall } from './utils'; export const EndNotification = Symbol.for('MiddlewareEngine:EndNotification'); type Context = Record; -type ReturnHandler< - Result extends MiddlewareResultConstraint>, -> = (result: Readonly) => Result | Promise; +type ReturnHandler = ( + result: Readonly, +) => void | Result | Promise; -type MiddlewareResultConstraint> = +type MiddlewareResultConstraint = Message extends JsonRpcNotification - ? void | typeof EndNotification - : void | Json | ReturnHandler>; + ? Message extends JsonRpcRequest + ? void | Json | ReturnHandler + : void | typeof EndNotification + : void | Json | ReturnHandler; + +type HandledResult> = + Exclude | void; export type JsonRpcMiddleware< - Message extends JsonRpcCall, + Message extends JsonRpcCall, Result extends MiddlewareResultConstraint, > = (message: Readonly, context: Context) => Result | Promise; type Options< - Message extends JsonRpcCall, + Message extends JsonRpcCall, Result extends MiddlewareResultConstraint, > = { middleware: NonEmptyArray>; }; export class MiddlewareEngine< - Message extends JsonRpcCall, + Message extends JsonRpcCall, Result extends MiddlewareResultConstraint, > { + static readonly EndNotification = EndNotification; + readonly #middleware: readonly JsonRpcMiddleware[]; constructor({ middleware }: Options) { @@ -52,57 +58,118 @@ export class MiddlewareEngine< * @param request - The JSON-RPC request to handle. * @returns The JSON-RPC response. */ - async handle(request: JsonRpcRequest & Message): Promise; + async handle( + request: Message & JsonRpcRequest, + ): Promise, void>>; /** * Handle a JSON-RPC notification. No response will be returned. * * @param message - The JSON-RPC notification to handle. */ - async handle(message: JsonRpcNotification & Message): Promise; + async handle(message: Message & JsonRpcNotification): Promise; - async handle(req: Message): Promise { + async handle(req: Message): Promise> { const result = await this.#handle(req); - return result === EndNotification ? undefined : result; + return result === EndNotification + ? undefined + : (result as HandledResult); + } + + // This exists because a JsonRpcCall overload of handle() cannot coexist with + // the other overloads due to type union / overload shenanigans. + /** + * Handle a JSON-RPC call. A response will be returned if the call is a request. + * + * @param message - The JSON-RPC call to handle. + * @returns The JSON-RPC response, if any. + */ + async handleAny(message: Message): Promise | void> { + return this.handle(message); } async #handle(message: Message, context: Context = {}): Promise { const immutableMessage = freeze(cloneDeep(message), true); - const returnHandlers: ReturnHandler[] = []; - let result: Result | undefined; + const { result, returnHandlers } = await this.#runMiddleware( + immutableMessage, + context, + ); + + return await this.#runReturnHandlers(message, result, returnHandlers); + } + + async #runMiddleware( + message: Readonly, + context: Context, + ): Promise<{ + result: Extract; + returnHandlers: ReturnHandler[]; + }> { + const returnHandlers: ReturnHandler[] = []; + + let result: Extract | undefined; for (const middleware of this.#middleware) { - const currentResult = await middleware(immutableMessage, context); + const currentResult = await middleware(message, context); if (typeof currentResult === 'function') { returnHandlers.push(currentResult); } else if (currentResult !== undefined) { - result = currentResult; + // Cast required due to incorrect type narrowing + result = currentResult as Extract< + Result, + Json | typeof EndNotification + >; break; } } if (result === undefined) { - throw new Error(`Nothing ended request:\n${stringify(message)}`); - } - - if (returnHandlers.length > 0) { + throw new Error(`Nothing ended call:\n${stringify(message)}`); + } else if (isRequest(message)) { if (result === EndNotification) { throw new Error( - `Received return handlers for notification:\n${stringify(message)}`, + `Request handled as notification:\n${stringify(message)}`, ); } + } else if (result !== EndNotification) { + throw new Error( + `Notification handled as request:\n${stringify(message)}`, + ); + } + + return { + result: result as Extract, + returnHandlers, + }; + } + + async #runReturnHandlers( + message: Readonly, + initialResult: Extract, + returnHandlers: ReturnHandler[], + ): Promise> { + freeze(initialResult, true); + + if (returnHandlers.length === 0) { + return initialResult; + } + + if (initialResult === EndNotification) { + throw new Error( + `Received return handlers for notification:\n${stringify(message)}`, + ); + } - result = freeze(result, true); - for (const returnHandler of returnHandlers) { - const updatedResult = await returnHandler(result); - if (updatedResult !== undefined) { - result = freeze(updatedResult, true); - } + let finalResult: Json = initialResult; + for (const returnHandler of returnHandlers) { + const updatedResult = await returnHandler(finalResult); + if (updatedResult !== undefined) { + finalResult = freeze(updatedResult, true); } } - return result; + return finalResult as Extract; } /** diff --git a/packages/json-rpc-engine/src/utils.ts b/packages/json-rpc-engine/src/utils.ts index ea2370d2e2..c34fa73bb2 100644 --- a/packages/json-rpc-engine/src/utils.ts +++ b/packages/json-rpc-engine/src/utils.ts @@ -1,17 +1,23 @@ import type { Json } from '@metamask/utils'; import { hasProperty, - type JsonRpcNotification, + type JsonRpcNotification as BaseJsonRpcNotification, type JsonRpcParams, - type JsonRpcRequest, + type JsonRpcRequest as BaseJsonRpcRequest, } from '@metamask/utils'; -export type JsonRpcCall = +export type JsonRpcNotification = + BaseJsonRpcNotification; + +export type JsonRpcRequest = + BaseJsonRpcRequest; + +export type JsonRpcCall = | JsonRpcNotification | JsonRpcRequest; export const isRequest = ( - msg: JsonRpcCall, + msg: JsonRpcCall | Readonly>, ): msg is JsonRpcRequest => hasProperty(msg, 'id'); export const isNotification = ( From 50096d234377bbeb2e3fa885c923a393dd8ed14b Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Mon, 14 Jul 2025 12:13:47 -0700 Subject: [PATCH 08/75] feat: MiddlewareEngine: immutability WIP --- .../json-rpc-engine/src/MiddlewareEngine.ts | 187 +++++++++--------- 1 file changed, 92 insertions(+), 95 deletions(-) diff --git a/packages/json-rpc-engine/src/MiddlewareEngine.ts b/packages/json-rpc-engine/src/MiddlewareEngine.ts index 2c31945f02..bf0e62b68d 100644 --- a/packages/json-rpc-engine/src/MiddlewareEngine.ts +++ b/packages/json-rpc-engine/src/MiddlewareEngine.ts @@ -1,11 +1,10 @@ -import type { - Json, - JsonRpcRequest, - JsonRpcNotification, - NonEmptyArray, +import { + type Json, + type JsonRpcRequest, + type JsonRpcNotification, + type NonEmptyArray, + hasProperty, } from '@metamask/utils'; -import { freeze } from 'immer'; -import cloneDeep from 'lodash/clonedeep'; import { isRequest, stringify } from './utils'; import type { JsonRpcCall } from './utils'; @@ -15,12 +14,12 @@ export const EndNotification = Symbol.for('MiddlewareEngine:EndNotification'); type Context = Record; type ReturnHandler = ( - result: Readonly, + result: Result, ) => void | Result | Promise; -type MiddlewareResultConstraint = - Message extends JsonRpcNotification - ? Message extends JsonRpcRequest +type MiddlewareResultConstraint = + Request extends JsonRpcNotification + ? Request extends JsonRpcRequest ? void | Json | ReturnHandler : void | typeof EndNotification : void | Json | ReturnHandler; @@ -29,26 +28,26 @@ type HandledResult> = Exclude | void; export type JsonRpcMiddleware< - Message extends JsonRpcCall, - Result extends MiddlewareResultConstraint, -> = (message: Readonly, context: Context) => Result | Promise; + Request extends JsonRpcCall, + Result extends MiddlewareResultConstraint, +> = (request: Request, context: Context) => Result | Promise; type Options< - Message extends JsonRpcCall, - Result extends MiddlewareResultConstraint, + Request extends JsonRpcCall, + Result extends MiddlewareResultConstraint, > = { - middleware: NonEmptyArray>; + middleware: NonEmptyArray>; }; export class MiddlewareEngine< - Message extends JsonRpcCall, - Result extends MiddlewareResultConstraint, + Request extends JsonRpcCall, + Result extends MiddlewareResultConstraint, > { static readonly EndNotification = EndNotification; - readonly #middleware: readonly JsonRpcMiddleware[]; + readonly #middleware: readonly JsonRpcMiddleware[]; - constructor({ middleware }: Options) { + constructor({ middleware }: Options) { this.#middleware = [...middleware]; } @@ -59,18 +58,18 @@ export class MiddlewareEngine< * @returns The JSON-RPC response. */ async handle( - request: Message & JsonRpcRequest, + request: Request & JsonRpcRequest, ): Promise, void>>; /** * Handle a JSON-RPC notification. No response will be returned. * - * @param message - The JSON-RPC notification to handle. + * @param notification - The JSON-RPC notification to handle. */ - async handle(message: Message & JsonRpcNotification): Promise; + async handle(notification: Request & JsonRpcNotification): Promise; - async handle(req: Message): Promise> { - const result = await this.#handle(req); + async handle(request: Request): Promise> { + const result = await this.#handle(request); return result === EndNotification ? undefined : (result as HandledResult); @@ -81,36 +80,48 @@ export class MiddlewareEngine< /** * Handle a JSON-RPC call. A response will be returned if the call is a request. * - * @param message - The JSON-RPC call to handle. + * @param request - The JSON-RPC call to handle. * @returns The JSON-RPC response, if any. */ - async handleAny(message: Message): Promise | void> { - return this.handle(message); + async handleAny(request: Request): Promise | void> { + return this.handle(request); } - async #handle(message: Message, context: Context = {}): Promise { - const immutableMessage = freeze(cloneDeep(message), true); - - const { result, returnHandlers } = await this.#runMiddleware( - immutableMessage, + async #handle(request: Request, context: Context = {}): Promise { + const { result, returnHandlers, finalRequest } = await this.#runMiddleware( + request, context, ); - return await this.#runReturnHandlers(message, result, returnHandlers); + return await this.#runReturnHandlers(result, returnHandlers, finalRequest); } + /** + * Run the middleware for a request. + * + * @param request - The request to run the middleware for. + * @param context - The context to pass to the middleware. + * @returns The result from the middleware. + */ async #runMiddleware( - message: Readonly, + request: Request, context: Context, ): Promise<{ result: Extract; returnHandlers: ReturnHandler[]; + finalRequest: Readonly; }> { const returnHandlers: ReturnHandler[] = []; + // Each middleware receives its own copy of the request. + let requestCopy = copyRequest(request); let result: Extract | undefined; + for (const middleware of this.#middleware) { - const currentResult = await middleware(message, context); + const currentResult = await middleware(requestCopy, context); + // Immediately make a new copy of the request, to effectively revoke the + // ability of the previous middleware to modify the request. + requestCopy = copyRequest(requestCopy); if (typeof currentResult === 'function') { returnHandlers.push(currentResult); @@ -125,47 +136,55 @@ export class MiddlewareEngine< } if (result === undefined) { - throw new Error(`Nothing ended call:\n${stringify(message)}`); - } else if (isRequest(message)) { + throw new Error(`Nothing ended call:\n${stringify(requestCopy)}`); + } else if (isRequest(request)) { if (result === EndNotification) { throw new Error( - `Request handled as notification:\n${stringify(message)}`, + `Request handled as notification:\n${stringify(requestCopy)}`, ); } } else if (result !== EndNotification) { throw new Error( - `Notification handled as request:\n${stringify(message)}`, + `Notification handled as request:\n${stringify(requestCopy)}`, ); } return { result: result as Extract, returnHandlers, + finalRequest: requestCopy, }; } + /** + * Run the return handlers for a result. May or may not return a new result. + * + * @param initialResult - The initial result from the middleware. + * @param returnHandlers - The return handlers to run. + * @param request - The request that caused the result. Only used for logging. + * Will not be passed to the return handlers. + * @returns The final result. + */ async #runReturnHandlers( - message: Readonly, initialResult: Extract, returnHandlers: ReturnHandler[], - ): Promise> { - freeze(initialResult, true); - + request: Readonly, + ): Promise { if (returnHandlers.length === 0) { return initialResult; } if (initialResult === EndNotification) { throw new Error( - `Received return handlers for notification:\n${stringify(message)}`, + `Received return handlers for notification:\n${stringify(request)}`, ); } - let finalResult: Json = initialResult; + let finalResult: Json = structuredClone(initialResult) as Json; for (const returnHandler of returnHandlers) { const updatedResult = await returnHandler(finalResult); if (updatedResult !== undefined) { - finalResult = freeze(updatedResult, true); + finalResult = structuredClone(updatedResult); } } @@ -177,53 +196,31 @@ export class MiddlewareEngine< * * @returns The JSON-RPC middleware. */ - asMiddleware(): JsonRpcMiddleware { - return async (req, context) => this.#handle(req, context); + asMiddleware(): JsonRpcMiddleware { + return async (request, context) => this.#handle(request, context); } } -// type Bar = JsonRpcMiddleware, typeof EndNotification>; - -// const bar: Bar = (req, context) => { -// return EndNotification; -// }; - -// export type JsonRpcMiddleware< -// Message extends JsonRpcCall, -// Result extends Json, -// > = (message: Readonly, context: Context) => -// // | Promise | Result | void; -// | void -// | Promise -// | (Message extends JsonRpcNotification -// ? typeof EndNotification | Promise -// : Result | ReturnHandler | Promise>); - -// type RequestMiddleware, Result extends Json> = ( -// req: Readonly, -// context: Context, -// ) => void | Promise | Result | Promise; - -// type NotificationMiddleware> = ( -// req: Readonly, -// context: Context, -// ) => -// | void -// | Promise -// | typeof EndNotification -// | Promise; - -// type CallMiddleware, Result extends Json> = ( -// req: Readonly, -// context: Context, -// ) => -// | void -// | Promise -// | (Message extends JsonRpcNotification -// ? typeof EndNotification | Promise -// : Result | Promise); - -// export type JsonRpcMiddleware< -// Message extends JsonRpcCall, -// Result extends Json, -// > = RequestMiddleware | NotificationMiddleware | CallMiddleware; +// Properties of a request that you're not allowed to modify. +const readonlyProps = ['id', 'jsonrpc'] as const; + +/** + * Make a copy of a request. + * + * @param request - The request to copy. + * @returns The copied request. + */ +function copyRequest(request: Target): Target { + const copy = structuredClone(request); + readonlyProps.forEach((prop) => { + if (hasProperty(copy, prop)) { + Object.defineProperty(copy, prop, { + value: copy[prop], + writable: false, + configurable: false, + enumerable: true, + }); + } + }); + return copy; +} From 383bfb7f509db1d0ffeb2419e402ee9f58f68c6b Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Mon, 14 Jul 2025 14:10:55 -0700 Subject: [PATCH 09/75] feat: MiddlewareEngine: immutability using immer WIP --- .../json-rpc-engine/src/JsonRpcEngineV2.ts | 4 +- .../json-rpc-engine/src/MiddlewareEngine.ts | 83 ++++++++++--------- 2 files changed, 45 insertions(+), 42 deletions(-) diff --git a/packages/json-rpc-engine/src/JsonRpcEngineV2.ts b/packages/json-rpc-engine/src/JsonRpcEngineV2.ts index 88d421c2d0..8a1c56c57b 100644 --- a/packages/json-rpc-engine/src/JsonRpcEngineV2.ts +++ b/packages/json-rpc-engine/src/JsonRpcEngineV2.ts @@ -4,7 +4,7 @@ import type { JsonRpcNotification, NonEmptyArray, } from '@metamask/utils'; -import { produce } from 'immer'; +import { freeze, produce } from 'immer'; import { isRequest, JsonRpcEngineError, stringify } from './utils'; import type { JsonRpcCall } from './utils'; @@ -121,7 +121,7 @@ export class JsonRpcEngineV2< }> { const returnHandlers: ReturnHandler[] = []; - let request = structuredClone(originalRequest); + let request = freeze({ ...originalRequest }); let result: Extract | undefined; for (const middleware of this.#middleware) { diff --git a/packages/json-rpc-engine/src/MiddlewareEngine.ts b/packages/json-rpc-engine/src/MiddlewareEngine.ts index bf0e62b68d..2de44be6e7 100644 --- a/packages/json-rpc-engine/src/MiddlewareEngine.ts +++ b/packages/json-rpc-engine/src/MiddlewareEngine.ts @@ -1,10 +1,10 @@ -import { - type Json, - type JsonRpcRequest, - type JsonRpcNotification, - type NonEmptyArray, - hasProperty, +import type { + Json, + JsonRpcRequest, + JsonRpcNotification, + NonEmptyArray, } from '@metamask/utils'; +import { produce } from 'immer'; import { isRequest, stringify } from './utils'; import type { JsonRpcCall } from './utils'; @@ -99,12 +99,12 @@ export class MiddlewareEngine< /** * Run the middleware for a request. * - * @param request - The request to run the middleware for. + * @param originalRequest - The request to run the middleware for. * @param context - The context to pass to the middleware. * @returns The result from the middleware. */ async #runMiddleware( - request: Request, + originalRequest: Request, context: Context, ): Promise<{ result: Extract; @@ -113,15 +113,14 @@ export class MiddlewareEngine< }> { const returnHandlers: ReturnHandler[] = []; - // Each middleware receives its own copy of the request. - let requestCopy = copyRequest(request); + let request = structuredClone(originalRequest); let result: Extract | undefined; for (const middleware of this.#middleware) { - const currentResult = await middleware(requestCopy, context); - // Immediately make a new copy of the request, to effectively revoke the - // ability of the previous middleware to modify the request. - requestCopy = copyRequest(requestCopy); + let currentResult: Result | undefined; + request = await updateRequest(request, async (draft) => { + currentResult = await middleware(draft, context); + }); if (typeof currentResult === 'function') { returnHandlers.push(currentResult); @@ -136,23 +135,23 @@ export class MiddlewareEngine< } if (result === undefined) { - throw new Error(`Nothing ended call:\n${stringify(requestCopy)}`); - } else if (isRequest(request)) { + throw new Error(`Nothing ended call:\n${stringify(request)}`); + } else if (isRequest(originalRequest)) { if (result === EndNotification) { throw new Error( - `Request handled as notification:\n${stringify(requestCopy)}`, + `Request handled as notification:\n${stringify(request)}`, ); } } else if (result !== EndNotification) { throw new Error( - `Notification handled as request:\n${stringify(requestCopy)}`, + `Notification handled as request:\n${stringify(request)}`, ); } return { - result: result as Extract, + result, returnHandlers, - finalRequest: requestCopy, + finalRequest: request, }; } @@ -180,12 +179,11 @@ export class MiddlewareEngine< ); } - let finalResult: Json = structuredClone(initialResult) as Json; + let finalResult = initialResult as Json; for (const returnHandler of returnHandlers) { - const updatedResult = await returnHandler(finalResult); - if (updatedResult !== undefined) { - finalResult = structuredClone(updatedResult); - } + finalResult = await produce(finalResult, async (draft: Json) => { + return await returnHandler(draft); + }); } return finalResult as Extract; @@ -205,22 +203,27 @@ export class MiddlewareEngine< const readonlyProps = ['id', 'jsonrpc'] as const; /** - * Make a copy of a request. + * Update a request using Immer. * - * @param request - The request to copy. - * @returns The copied request. + * @param request - The request to update. + * @param recipe - The recipe function. + * @returns The updated request. */ -function copyRequest(request: Target): Target { - const copy = structuredClone(request); - readonlyProps.forEach((prop) => { - if (hasProperty(copy, prop)) { - Object.defineProperty(copy, prop, { - value: copy[prop], - writable: false, - configurable: false, - enumerable: true, - }); - } +async function updateRequest( + request: Request, + recipe: (request: Request) => Promise, +): Promise { + return produce(request, async (draft) => { + const draftProxy = new Proxy(draft, { + set(target, prop, value) { + if (readonlyProps.includes(prop as (typeof readonlyProps)[number])) { + throw new TypeError( + `Cannot assign to read only property '${String(prop)}'`, + ); + } + return Reflect.set(target, prop, value); + }, + }); + await recipe(draftProxy as Request); }); - return copy; } From 022b24fe8d9b5a52ca54c47afb809a345488e73b Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Mon, 14 Jul 2025 14:24:19 -0700 Subject: [PATCH 10/75] feat: JsonRpcEngineV2: Rename and add internal error class --- ...Engine.test.ts => JsonRpcEngineV2.test.ts} | 14 +- .../json-rpc-engine/src/MiddlewareEngine.ts | 229 ------------------ packages/json-rpc-engine/src/index.test.ts | 2 + packages/json-rpc-engine/src/index.ts | 1 + packages/json-rpc-engine/src/utils.ts | 7 + 5 files changed, 17 insertions(+), 236 deletions(-) rename packages/json-rpc-engine/src/{MiddlewareEngine.test.ts => JsonRpcEngineV2.test.ts} (87%) delete mode 100644 packages/json-rpc-engine/src/MiddlewareEngine.ts diff --git a/packages/json-rpc-engine/src/MiddlewareEngine.test.ts b/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts similarity index 87% rename from packages/json-rpc-engine/src/MiddlewareEngine.test.ts rename to packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts index aafe42fddf..a727836aaf 100644 --- a/packages/json-rpc-engine/src/MiddlewareEngine.test.ts +++ b/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts @@ -1,12 +1,12 @@ import { Json } from '@metamask/utils'; -import type { JsonRpcMiddleware } from './MiddlewareEngine'; -import { MiddlewareEngine, EndNotification } from './MiddlewareEngine'; +import type { JsonRpcMiddleware } from './JsonRpcEngineV2'; +import { JsonRpcEngineV2, EndNotification } from './JsonRpcEngineV2'; import type { JsonRpcCall, JsonRpcNotification, JsonRpcRequest } from './utils'; -describe('MiddlewareEngine', () => { +describe('JsonRpcEngineV2', () => { it('should handle a request', async () => { - const engine = new MiddlewareEngine({ + const engine = new JsonRpcEngineV2({ middleware: [ (req: JsonRpcNotification, context): void => {}, (req: JsonRpcNotification, context): typeof EndNotification => { @@ -34,7 +34,7 @@ describe('MiddlewareEngine', () => { }; type foo = ReturnType; - const engine2 = new MiddlewareEngine({ + const engine2 = new JsonRpcEngineV2({ middleware: [ // @ts-expect-error Should be illegal. (req: JsonRpcRequest, context): void | typeof EndNotification => { @@ -43,7 +43,7 @@ describe('MiddlewareEngine', () => { ], }); - const engine3 = new MiddlewareEngine({ + const engine3 = new JsonRpcEngineV2({ middleware: [ ((req: JsonRpcRequest, context) => { return null; @@ -51,7 +51,7 @@ describe('MiddlewareEngine', () => { ], }); - const engine4 = new MiddlewareEngine({ + const engine4 = new JsonRpcEngineV2({ middleware: [ ((req: JsonRpcCall, context): null => { return null; diff --git a/packages/json-rpc-engine/src/MiddlewareEngine.ts b/packages/json-rpc-engine/src/MiddlewareEngine.ts deleted file mode 100644 index 2de44be6e7..0000000000 --- a/packages/json-rpc-engine/src/MiddlewareEngine.ts +++ /dev/null @@ -1,229 +0,0 @@ -import type { - Json, - JsonRpcRequest, - JsonRpcNotification, - NonEmptyArray, -} from '@metamask/utils'; -import { produce } from 'immer'; - -import { isRequest, stringify } from './utils'; -import type { JsonRpcCall } from './utils'; - -export const EndNotification = Symbol.for('MiddlewareEngine:EndNotification'); - -type Context = Record; - -type ReturnHandler = ( - result: Result, -) => void | Result | Promise; - -type MiddlewareResultConstraint = - Request extends JsonRpcNotification - ? Request extends JsonRpcRequest - ? void | Json | ReturnHandler - : void | typeof EndNotification - : void | Json | ReturnHandler; - -type HandledResult> = - Exclude | void; - -export type JsonRpcMiddleware< - Request extends JsonRpcCall, - Result extends MiddlewareResultConstraint, -> = (request: Request, context: Context) => Result | Promise; - -type Options< - Request extends JsonRpcCall, - Result extends MiddlewareResultConstraint, -> = { - middleware: NonEmptyArray>; -}; - -export class MiddlewareEngine< - Request extends JsonRpcCall, - Result extends MiddlewareResultConstraint, -> { - static readonly EndNotification = EndNotification; - - readonly #middleware: readonly JsonRpcMiddleware[]; - - constructor({ middleware }: Options) { - this.#middleware = [...middleware]; - } - - /** - * Handle a JSON-RPC request. A response will be returned. - * - * @param request - The JSON-RPC request to handle. - * @returns The JSON-RPC response. - */ - async handle( - request: Request & JsonRpcRequest, - ): Promise, void>>; - - /** - * Handle a JSON-RPC notification. No response will be returned. - * - * @param notification - The JSON-RPC notification to handle. - */ - async handle(notification: Request & JsonRpcNotification): Promise; - - async handle(request: Request): Promise> { - const result = await this.#handle(request); - return result === EndNotification - ? undefined - : (result as HandledResult); - } - - // This exists because a JsonRpcCall overload of handle() cannot coexist with - // the other overloads due to type union / overload shenanigans. - /** - * Handle a JSON-RPC call. A response will be returned if the call is a request. - * - * @param request - The JSON-RPC call to handle. - * @returns The JSON-RPC response, if any. - */ - async handleAny(request: Request): Promise | void> { - return this.handle(request); - } - - async #handle(request: Request, context: Context = {}): Promise { - const { result, returnHandlers, finalRequest } = await this.#runMiddleware( - request, - context, - ); - - return await this.#runReturnHandlers(result, returnHandlers, finalRequest); - } - - /** - * Run the middleware for a request. - * - * @param originalRequest - The request to run the middleware for. - * @param context - The context to pass to the middleware. - * @returns The result from the middleware. - */ - async #runMiddleware( - originalRequest: Request, - context: Context, - ): Promise<{ - result: Extract; - returnHandlers: ReturnHandler[]; - finalRequest: Readonly; - }> { - const returnHandlers: ReturnHandler[] = []; - - let request = structuredClone(originalRequest); - let result: Extract | undefined; - - for (const middleware of this.#middleware) { - let currentResult: Result | undefined; - request = await updateRequest(request, async (draft) => { - currentResult = await middleware(draft, context); - }); - - if (typeof currentResult === 'function') { - returnHandlers.push(currentResult); - } else if (currentResult !== undefined) { - // Cast required due to incorrect type narrowing - result = currentResult as Extract< - Result, - Json | typeof EndNotification - >; - break; - } - } - - if (result === undefined) { - throw new Error(`Nothing ended call:\n${stringify(request)}`); - } else if (isRequest(originalRequest)) { - if (result === EndNotification) { - throw new Error( - `Request handled as notification:\n${stringify(request)}`, - ); - } - } else if (result !== EndNotification) { - throw new Error( - `Notification handled as request:\n${stringify(request)}`, - ); - } - - return { - result, - returnHandlers, - finalRequest: request, - }; - } - - /** - * Run the return handlers for a result. May or may not return a new result. - * - * @param initialResult - The initial result from the middleware. - * @param returnHandlers - The return handlers to run. - * @param request - The request that caused the result. Only used for logging. - * Will not be passed to the return handlers. - * @returns The final result. - */ - async #runReturnHandlers( - initialResult: Extract, - returnHandlers: ReturnHandler[], - request: Readonly, - ): Promise { - if (returnHandlers.length === 0) { - return initialResult; - } - - if (initialResult === EndNotification) { - throw new Error( - `Received return handlers for notification:\n${stringify(request)}`, - ); - } - - let finalResult = initialResult as Json; - for (const returnHandler of returnHandlers) { - finalResult = await produce(finalResult, async (draft: Json) => { - return await returnHandler(draft); - }); - } - - return finalResult as Extract; - } - - /** - * Convert the engine into a JSON-RPC middleware. - * - * @returns The JSON-RPC middleware. - */ - asMiddleware(): JsonRpcMiddleware { - return async (request, context) => this.#handle(request, context); - } -} - -// Properties of a request that you're not allowed to modify. -const readonlyProps = ['id', 'jsonrpc'] as const; - -/** - * Update a request using Immer. - * - * @param request - The request to update. - * @param recipe - The recipe function. - * @returns The updated request. - */ -async function updateRequest( - request: Request, - recipe: (request: Request) => Promise, -): Promise { - return produce(request, async (draft) => { - const draftProxy = new Proxy(draft, { - set(target, prop, value) { - if (readonlyProps.includes(prop as (typeof readonlyProps)[number])) { - throw new TypeError( - `Cannot assign to read only property '${String(prop)}'`, - ); - } - return Reflect.set(target, prop, value); - }, - }); - await recipe(draftProxy as Request); - }); -} diff --git a/packages/json-rpc-engine/src/index.test.ts b/packages/json-rpc-engine/src/index.test.ts index cba4229fb1..0c2f317b66 100644 --- a/packages/json-rpc-engine/src/index.test.ts +++ b/packages/json-rpc-engine/src/index.test.ts @@ -5,6 +5,8 @@ describe('@metamask/json-rpc-engine', () => { expect(Object.keys(allExports)).toMatchInlineSnapshot(` Array [ "getUniqueId", + "EndNotification", + "JsonRpcEngineV2", ] `); }); diff --git a/packages/json-rpc-engine/src/index.ts b/packages/json-rpc-engine/src/index.ts index 9db96b8b22..93859a753a 100644 --- a/packages/json-rpc-engine/src/index.ts +++ b/packages/json-rpc-engine/src/index.ts @@ -1 +1,2 @@ export { getUniqueId } from './getUniqueId'; +export * from './JsonRpcEngineV2'; diff --git a/packages/json-rpc-engine/src/utils.ts b/packages/json-rpc-engine/src/utils.ts index c34fa73bb2..574702227a 100644 --- a/packages/json-rpc-engine/src/utils.ts +++ b/packages/json-rpc-engine/src/utils.ts @@ -33,3 +33,10 @@ export const isNotification = ( export function stringify(value: Json | Readonly): string { return JSON.stringify(value, null, 2); } + +export class JsonRpcEngineError extends Error { + constructor(message: string) { + super(message); + this.name = 'JsonRpcEngineError'; + } +} From c8baf7f9b8682e815d48d3546c3c7255049f0ac2 Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Tue, 15 Jul 2025 12:25:47 -0700 Subject: [PATCH 11/75] test: Add initial JsonRpcEngineV2 tests --- packages/json-rpc-engine/package.json | 1 + .../src/JsonRpcEngineV2.test.ts | 683 +++++++++++++++--- .../json-rpc-engine/src/JsonRpcEngineV2.ts | 47 +- packages/json-rpc-engine/src/utils.ts | 17 + yarn.lock | 1 + 5 files changed, 641 insertions(+), 108 deletions(-) diff --git a/packages/json-rpc-engine/package.json b/packages/json-rpc-engine/package.json index 42503d869e..28c86649e5 100644 --- a/packages/json-rpc-engine/package.json +++ b/packages/json-rpc-engine/package.json @@ -80,6 +80,7 @@ "deepmerge": "^4.2.2", "jest": "^27.5.1", "jest-it-up": "^2.0.2", + "lodash": "^4.17.21", "ts-jest": "^27.1.4", "typedoc": "^0.24.8", "typescript": "~5.2.2" diff --git a/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts b/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts index a727836aaf..c60bd9c5a5 100644 --- a/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts +++ b/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts @@ -1,106 +1,621 @@ +import type { JsonRpcId, NonEmptyArray } from '@metamask/utils'; import { Json } from '@metamask/utils'; +import { original as getOriginalState } from 'immer'; +import cloneDeep from 'lodash/cloneDeep'; -import type { JsonRpcMiddleware } from './JsonRpcEngineV2'; +import type { + JsonRpcMiddleware, + MiddlewareResultConstraint, +} from './JsonRpcEngineV2'; import { JsonRpcEngineV2, EndNotification } from './JsonRpcEngineV2'; -import type { JsonRpcCall, JsonRpcNotification, JsonRpcRequest } from './utils'; +import { + cloneRequest, + JsonRpcEngineError, + stringify, + type JsonRpcCall, + type JsonRpcNotification, + type JsonRpcRequest, +} from './utils'; + +const jsonrpc = '2.0' as const; + +// Mock structuredClone if it's not available. +globalThis.structuredClone = + typeof globalThis.structuredClone === 'function' + ? globalThis.structuredClone + : cloneDeep; + +const makeRequest = ( + params: Partial = {}, +): Request => + ({ + jsonrpc, + id: '1', + method: 'test_request', + params: [] as Request['params'], + ...params, + }) as Request; + +/** + * Wraps a set of mock middleware functions such that they receive the + * original request object as opposed to the immer draft object, which + * is revoked by the time we can observe it. + * + * @param middleware - The first middleware. This param exists to ensure that + * at least one middleware is provided. + * @param rest - The rest of the middleware. + * @returns An array of the wrapped middleware functions. + */ +const makeMockMiddleware = < + Request extends JsonRpcCall, + Result extends MiddlewareResultConstraint, +>( + middleware: JsonRpcMiddleware, + ...rest: JsonRpcMiddleware[] +): NonEmptyArray> => + [middleware, ...rest].map( + (fn) => (request: Request, context: Record) => + fn(getOriginalState(request) as Request, context), + ) as NonEmptyArray>; describe('JsonRpcEngineV2', () => { - it('should handle a request', async () => { - const engine = new JsonRpcEngineV2({ - middleware: [ - (req: JsonRpcNotification, context): void => {}, - (req: JsonRpcNotification, context): typeof EndNotification => { - return EndNotification; - }, - (req: JsonRpcCall, context): void | typeof EndNotification => { - return EndNotification; - }, - // (req: JsonRpcRequest, context): void | typeof EndNotification => { - // return EndNotification; - // }, - ], + describe('handle', () => { + describe('notifications', () => { + it('passes the notification through middleware', async () => { + const middleware: JsonRpcMiddleware = jest.fn( + () => EndNotification, + ); + const engine = new JsonRpcEngineV2({ + middleware: makeMockMiddleware(middleware), + }); + const notification = { jsonrpc, method: 'test_request' }; + + await engine.handle(notification); + + expect(middleware).toHaveBeenCalledTimes(1); + expect(middleware).toHaveBeenCalledWith(notification, {}); + }); + + it('returns no result', async () => { + const engine = new JsonRpcEngineV2({ + middleware: [jest.fn(() => EndNotification)], + }); + const notification = { jsonrpc, method: 'test_request' }; + + const result = await engine.handle(notification); + + expect(result).toBeUndefined(); + }); + + it('returns no result, with multiple middleware', async () => { + const engine = new JsonRpcEngineV2({ + middleware: makeMockMiddleware( + jest.fn(), + jest.fn(() => EndNotification), + ), + }); + const notification = { jsonrpc, method: 'test_request' }; + + const result = await engine.handle(notification); + + expect(result).toBeUndefined(); + }); + + it('throws if a middleware throws', async () => { + const engine = new JsonRpcEngineV2({ + middleware: [ + jest.fn(() => { + throw new Error('test'); + }), + ], + }); + const notification = { jsonrpc, method: 'test_request' }; + + await expect(engine.handle(notification)).rejects.toThrow( + new Error('test'), + ); + }); + + it('throws if a middleware throws, with multiple middleware', async () => { + const engine = new JsonRpcEngineV2({ + middleware: makeMockMiddleware( + jest.fn(), + jest.fn(() => { + throw new Error('test'); + }), + ), + }); + const notification = { jsonrpc, method: 'test_request' }; + + await expect(engine.handle(notification)).rejects.toThrow( + new Error('test'), + ); + }); + + it('throws if no middleware returns EndNotification', async () => { + const engine = new JsonRpcEngineV2({ + middleware: [jest.fn(), jest.fn()], + }); + const notification = { jsonrpc, method: 'test_request' }; + + await expect(engine.handle(notification)).rejects.toThrow( + new JsonRpcEngineError( + `Nothing ended request: ${stringify(notification)}`, + ), + ); + }); + + it('throws if a middleware returns a return handler', async () => { + const engine = new JsonRpcEngineV2({ + middleware: [jest.fn(() => () => null)], + }); + const notification = { jsonrpc, method: 'test_request' }; + + await expect(engine.handle(notification)).rejects.toThrow( + new JsonRpcEngineError( + `Middleware returned a return handler for notification: ${stringify(notification)}`, + ), + ); + }); + + it('throws if a middleware returns neither EndNotification nor undefined', async () => { + const engine = new JsonRpcEngineV2({ + middleware: [jest.fn(() => null), jest.fn(() => EndNotification)], + }); + const notification = { jsonrpc, method: 'test_request' }; + + await expect(engine.handle(notification)).rejects.toThrow( + new JsonRpcEngineError( + `Notification handled as request: ${stringify(notification)}`, + ), + ); + }); }); - const middleware: JsonRpcMiddleware = ( - req, - context, - ) => {}; - const middleware2: JsonRpcMiddleware< - JsonRpcRequest, - // @ts-expect-error Should be illegal. - typeof EndNotification - > = (req, context) => { - return EndNotification; - }; - type foo = ReturnType; - - const engine2 = new JsonRpcEngineV2({ - middleware: [ - // @ts-expect-error Should be illegal. - (req: JsonRpcRequest, context): void | typeof EndNotification => { - return EndNotification; - }, - ], + describe('requests', () => { + it('returns a result from the middleware', async () => { + const middleware: JsonRpcMiddleware = jest.fn( + () => null, + ); + const engine = new JsonRpcEngineV2({ + middleware: makeMockMiddleware(middleware), + }); + const request = makeRequest(); + + const result = await engine.handle(request); + + expect(result).toBeNull(); + expect(middleware).toHaveBeenCalledTimes(1); + expect(middleware).toHaveBeenCalledWith(request, {}); + }); + + it('returns a result from the middleware, with multiple middleware', async () => { + const middleware1 = jest.fn(); + const middleware2 = jest.fn(() => null); + const engine = new JsonRpcEngineV2({ + middleware: makeMockMiddleware(middleware1, middleware2), + }); + const request = makeRequest(); + + const result = await engine.handle(request); + + expect(result).toBeNull(); + expect(middleware1).toHaveBeenCalledTimes(1); + expect(middleware1).toHaveBeenCalledWith(request, {}); + expect(middleware2).toHaveBeenCalledTimes(1); + expect(middleware2).toHaveBeenCalledWith(request, {}); + }); + + it('throws if a middleware throws', async () => { + const engine = new JsonRpcEngineV2({ + middleware: [ + jest.fn(() => { + throw new Error('test'); + }), + ], + }); + + await expect(engine.handle(makeRequest())).rejects.toThrow( + new Error('test'), + ); + }); + + it('throws if a middleware throws, with multiple middleware', async () => { + const engine = new JsonRpcEngineV2({ + middleware: makeMockMiddleware( + jest.fn(), + jest.fn(() => { + throw new Error('test'); + }), + ), + }); + + await expect(engine.handle(makeRequest())).rejects.toThrow( + new Error('test'), + ); + }); + + it('throws if no middleware returns a result', async () => { + const engine = new JsonRpcEngineV2({ + middleware: [jest.fn(), jest.fn()], + }); + const request = makeRequest(); + + await expect(engine.handle(makeRequest())).rejects.toThrow( + new JsonRpcEngineError( + `Nothing ended request: ${stringify(request)}`, + ), + ); + }); + + it('throws if a middleware returns EndNotification', async () => { + const engine = new JsonRpcEngineV2({ + middleware: [jest.fn(() => EndNotification)], + }); + const request = makeRequest(); + + await expect(engine.handle(request)).rejects.toThrow( + new JsonRpcEngineError( + `Request handled as notification: ${stringify(request)}`, + ), + ); + }); }); - const engine3 = new JsonRpcEngineV2({ - middleware: [ - ((req: JsonRpcRequest, context) => { + describe('request mutation', () => { + it('lets middleware mutate request parameters in place', async () => { + const observedParams: string[] = []; + const middleware1 = jest.fn((req) => { + observedParams.push(req.params[0]); + req.params[0] = '2'; + }); + const middleware2 = jest.fn((req) => { + observedParams.push(req.params[0]); + req.params[0] = '3'; + }); + const middleware3 = jest.fn((req) => { + observedParams.push(req.params[0]); return null; - }) as JsonRpcMiddleware, - ], - }); + }); + const engine = new JsonRpcEngineV2({ + middleware: makeMockMiddleware(middleware1, middleware2, middleware3), + }); + const request = makeRequest({ params: ['1'] }); + + await engine.handle(request); + + expect(middleware1).toHaveBeenCalledTimes(1); + expect(middleware2).toHaveBeenCalledTimes(1); + expect(middleware3).toHaveBeenCalledTimes(1); + expect(observedParams).toStrictEqual(['1', '2', '3']); + }); + + it('lets middleware replace request parameters', async () => { + const observedParams: string[] = []; + const middleware1 = jest.fn((req) => { + observedParams.push(cloneRequest(req).params); + req.params = ['2']; + }); + const middleware2 = jest.fn((req) => { + observedParams.push(cloneRequest(req).params); + req.params = ['3']; + }); + const middleware3 = jest.fn((req) => { + observedParams.push(cloneRequest(req).params); + return null; + }); + const engine = new JsonRpcEngineV2({ + middleware: [middleware1, middleware2, middleware3], + }); + const request = makeRequest({ params: ['1'] }); + + await engine.handle(request); + + expect(middleware1).toHaveBeenCalledTimes(1); + expect(middleware2).toHaveBeenCalledTimes(1); + expect(middleware3).toHaveBeenCalledTimes(1); + expect(observedParams).toStrictEqual([['1'], ['2'], ['3']]); + }); - const engine4 = new JsonRpcEngineV2({ - middleware: [ - ((req: JsonRpcCall, context): null => { + it('lets middleware replace the request method', async () => { + let observedMethod: string | undefined; + const middleware1 = jest.fn((req) => { + req.method = 'test_request_2'; + }); + const middleware2 = jest.fn((req) => { + observedMethod = req.method; return null; - }) as JsonRpcMiddleware, - ], + }); + const engine = new JsonRpcEngineV2({ + middleware: [middleware1, middleware2], + }); + + await engine.handle(makeRequest()); + + expect(middleware1).toHaveBeenCalledTimes(1); + expect(middleware2).toHaveBeenCalledTimes(1); + expect(observedMethod).toBe('test_request_2'); + }); + + it('throws if a middleware attempts to modify the request "id" property', async () => { + const engine = new JsonRpcEngineV2({ + middleware: [ + jest.fn((req) => { + req.id = '2'; + }), + ], + }); + const request = makeRequest(); + + await expect(engine.handle(request)).rejects.toThrow( + new JsonRpcEngineError( + `Middleware attempted to modify readonly property "id" for request: ${stringify(request)}`, + ), + ); + }); + + it('throws if a middleware attempts to modify the request "jsonrpc" property', async () => { + const engine = new JsonRpcEngineV2({ + middleware: [ + jest.fn((req) => { + req.jsonrpc = '3.0'; + }), + ], + }); + const request = makeRequest(); + + await expect(engine.handle(request)).rejects.toThrow( + new JsonRpcEngineError( + `Middleware attempted to modify readonly property "jsonrpc" for request: ${stringify(request)}`, + ), + ); + }); + + it('throws if modifying the request outside of the middleware', async () => { + let retained: JsonRpcCall | undefined; + const middleware = jest.fn((req) => { + retained = req; + return null; + }); + const engine = new JsonRpcEngineV2({ + middleware: [middleware], + }); + const request = makeRequest(); + + await engine.handle(request); + + expect(() => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + retained!.params = ['2']; + }).toThrow( + new TypeError( + `Cannot perform 'set' on a proxy that has been revoked`, + ), + ); + }); }); - const reqRes = await engine4.handle({ - id: '1', - method: 'foo', - jsonrpc: '2.0', - params: [], + describe('return handlers', () => { + it('runs return handlers in reverse order of registration', async () => { + const returnHandlerResults: string[] = []; + const middleware1 = jest.fn(() => () => { + returnHandlerResults.push('1'); + }); + const middleware2 = jest.fn(() => () => { + returnHandlerResults.push('2'); + }); + const middleware3 = jest.fn(() => () => { + returnHandlerResults.push('3'); + }); + const middleware4 = jest.fn(() => null); + const engine = new JsonRpcEngineV2({ + middleware: [middleware1, middleware2, middleware3, middleware4], + }); + + await engine.handle(makeRequest()); + + expect(returnHandlerResults).toStrictEqual(['3', '2', '1']); + }); + + it('lets return handler update the result', async () => { + const middleware1 = jest.fn(() => () => { + return '1'; + }); + const middleware2 = jest.fn(() => null); + const engine = new JsonRpcEngineV2({ + middleware: [middleware1, middleware2], + }); + + const result = await engine.handle(makeRequest()); + + expect(result).toBe('1'); + }); + + it('uses the result of the first return handler registered', async () => { + const middleware1 = jest.fn(() => () => { + return '1' as string; + }); + const middleware2 = jest.fn(() => () => { + return '2' as string; + }); + const middleware3 = jest.fn(() => null); + const engine = new JsonRpcEngineV2({ + middleware: [middleware1, middleware2, middleware3], + }); + + const result = await engine.handle(makeRequest()); + + expect(result).toBe('1'); + }); + + it('throws if a return handler modifies the request', async () => { + const middleware1 = jest.fn((req) => () => { + req.params = ['2']; + return '1' as string; + }); + const middleware2 = jest.fn(() => null); + const engine = new JsonRpcEngineV2({ + middleware: [middleware1, middleware2], + }); + + await expect(engine.handle(makeRequest())).rejects.toThrow( + new TypeError( + `Cannot perform 'set' on a proxy that has been revoked`, + ), + ); + }); }); + }); - const notifRes = await engine4.handle({ - method: 'foo', - jsonrpc: '2.0', - params: [], + describe('handleAny', () => { + it(`proxies to 'handle()'`, async () => { + const engine = new JsonRpcEngineV2({ + middleware: [jest.fn(() => null)], + }); + const handleSpy = jest.spyOn(engine, 'handle'); + const request = makeRequest(); + + const result = await engine.handleAny(request); + + expect(result).toBeNull(); + expect(handleSpy).toHaveBeenCalledTimes(1); + expect(handleSpy).toHaveBeenCalledWith(request); }); + }); - const callRes = await engine4.handleAny({ - id: '1', - method: 'foo', - jsonrpc: '2.0', - params: [], + describe('asMiddleware', () => { + it('returns a middleware function', async () => { + const engine1 = new JsonRpcEngineV2({ + middleware: [jest.fn(() => null)], + }); + const engine2 = new JsonRpcEngineV2({ + middleware: [engine1.asMiddleware()], + }); + + const result = await engine2.handle(makeRequest()); + + expect(result).toBeNull(); }); - const a: JsonRpcRequest = { - id: '1', - method: 'foo', - jsonrpc: '2.0', - params: [], - }; - - const foo: JsonRpcCall = { - id: '1', - method: 'foo', - jsonrpc: '2.0', - params: [], - }; - - const bar: JsonRpcNotification = { - method: 'foo', - jsonrpc: '2.0', - params: [], - }; - - const fizz: JsonRpcNotification = foo; - - const b: JsonRpcRequest = foo; + it.skip('composes request mutation', async () => { + const engine1 = new JsonRpcEngineV2({ + middleware: [ + (req) => { + req.params = [2]; + }, + // @ts-expect-error This will work. + (req) => req.params[0] * 2, + ], + }); + const engine2 = new JsonRpcEngineV2({ + // @ts-expect-error This will work. + middleware: [engine1.asMiddleware(), (req) => req.params[0] * 2], + }); + + const result = await engine2.handle(makeRequest()); + + expect(result).toBe(8); + }); }); }); + +// describe('JsonRpcEngineV2', () => { +// it('should handle a request', async () => { +// const engine = new JsonRpcEngineV2({ +// middleware: [ +// (req: JsonRpcNotification, context): void => {}, +// (req: JsonRpcNotification, context): typeof EndNotification => { +// return EndNotification; +// }, +// (req: JsonRpcCall, context): void | typeof EndNotification => { +// return EndNotification; +// }, +// // (req: JsonRpcRequest, context): void | typeof EndNotification => { +// // return EndNotification; +// // }, +// ], +// }); + +// const middleware: JsonRpcMiddleware = ( +// req, +// context, +// ) => {}; +// const middleware2: JsonRpcMiddleware< +// JsonRpcRequest, +// // @ts-expect-error Should be illegal. +// typeof EndNotification +// > = (req, context) => { +// return EndNotification; +// }; +// type foo = ReturnType; + +// const engine2 = new JsonRpcEngineV2({ +// middleware: [ +// // @ts-expect-error Should be illegal. +// (req: JsonRpcRequest, context): void | typeof EndNotification => { +// return EndNotification; +// }, +// ], +// }); + +// const engine3 = new JsonRpcEngineV2({ +// middleware: [ +// ((req: JsonRpcRequest, context) => { +// return null; +// }) as JsonRpcMiddleware, +// ], +// }); + +// const engine4 = new JsonRpcEngineV2({ +// middleware: [ +// ((req: JsonRpcCall, context): null => { +// return null; +// }) as JsonRpcMiddleware, +// ], +// }); + +// const reqRes = await engine4.handle({ +// id: '1', +// method: 'foo', +// jsonrpc: '2.0', +// params: [], +// }); + +// const notifRes = await engine4.handle({ +// method: 'foo', +// jsonrpc: '2.0', +// params: [], +// }); + +// const callRes = await engine4.handleAny({ +// id: '1', +// method: 'foo', +// jsonrpc: '2.0', +// params: [], +// }); + +// const a: JsonRpcRequest = { +// id: '1', +// method: 'foo', +// jsonrpc: '2.0', +// params: [], +// }; + +// const foo: JsonRpcCall = { +// id: '1', +// method: 'foo', +// jsonrpc: '2.0', +// params: [], +// }; + +// const bar: JsonRpcNotification = { +// method: 'foo', +// jsonrpc: '2.0', +// params: [], +// }; + +// const fizz: JsonRpcNotification = foo; + +// const b: JsonRpcRequest = foo; +// }); +// }); diff --git a/packages/json-rpc-engine/src/JsonRpcEngineV2.ts b/packages/json-rpc-engine/src/JsonRpcEngineV2.ts index 8a1c56c57b..26ba297a25 100644 --- a/packages/json-rpc-engine/src/JsonRpcEngineV2.ts +++ b/packages/json-rpc-engine/src/JsonRpcEngineV2.ts @@ -17,7 +17,7 @@ type ReturnHandler = ( result: Result, ) => void | Result | Promise; -type MiddlewareResultConstraint = +export type MiddlewareResultConstraint = Request extends JsonRpcNotification ? Request extends JsonRpcRequest ? void | Json | ReturnHandler @@ -28,8 +28,9 @@ type HandledResult> = Exclude | void; export type JsonRpcMiddleware< - Request extends JsonRpcCall, - Result extends MiddlewareResultConstraint, + Request extends JsonRpcCall = JsonRpcCall, + Result extends + MiddlewareResultConstraint = MiddlewareResultConstraint, > = (request: Request, context: Context) => Result | Promise; type Options< @@ -96,12 +97,14 @@ export class JsonRpcEngineV2< } async #handle(request: Request, context: Context = {}): Promise { - const { result, returnHandlers, finalRequest } = await this.#runMiddleware( + const { result, returnHandlers } = await this.#runMiddleware( request, context, ); - return await this.#runReturnHandlers(result, returnHandlers, finalRequest); + return returnHandlers.length === 0 + ? result + : await this.#runReturnHandlers(result, returnHandlers); } /** @@ -120,6 +123,7 @@ export class JsonRpcEngineV2< finalRequest: Readonly; }> { const returnHandlers: ReturnHandler[] = []; + const isReq = isRequest(originalRequest); let request = freeze({ ...originalRequest }); let result: Extract | undefined; @@ -131,6 +135,12 @@ export class JsonRpcEngineV2< }); if (typeof currentResult === 'function') { + if (!isReq) { + throw new JsonRpcEngineError( + `Middleware returned a return handler for notification: ${stringify(request)}`, + ); + } + returnHandlers.push(currentResult); } else if (currentResult !== undefined) { // Cast required due to incorrect type narrowing @@ -146,7 +156,7 @@ export class JsonRpcEngineV2< throw new JsonRpcEngineError( `Nothing ended request: ${stringify(request)}`, ); - } else if (isRequest(originalRequest)) { + } else if (isReq) { if (result === EndNotification) { throw new JsonRpcEngineError( `Request handled as notification: ${stringify(request)}`, @@ -170,30 +180,19 @@ export class JsonRpcEngineV2< * * @param initialResult - The initial result from the middleware. * @param returnHandlers - The return handlers to run. - * @param request - The request that caused the result. Only used for logging. - * Will not be passed to the return handlers. * @returns The final result. */ async #runReturnHandlers( initialResult: Extract, returnHandlers: ReturnHandler[], - request: Readonly, ): Promise { - if (returnHandlers.length === 0) { - return initialResult; - } - - if (initialResult === EndNotification) { - throw new JsonRpcEngineError( - `Received return handlers for notification: ${stringify(request)}`, - ); - } - let result = initialResult; - for (const returnHandler of returnHandlers) { - result = await produce(result, async (draft: Json) => { - return await returnHandler(draft); - }); + // ATTN: Run return handlers in reverse order of registration. + for (const returnHandler of returnHandlers.reverse()) { + // Return handlers can either modify the result in place or return a new value. + result = await produce(result, async (draft: Json) => + returnHandler(draft), + ); } return result; @@ -238,6 +237,6 @@ async function updateRequest( // The Jest parser encounters "TS2589: Type instantiation is excessively // deep and possibly infinite." // eslint-disable-next-line @typescript-eslint/no-explicit-any - await recipe(draftProxy as any); + return recipe(draftProxy as any); }); } diff --git a/packages/json-rpc-engine/src/utils.ts b/packages/json-rpc-engine/src/utils.ts index 574702227a..dfe7d0a567 100644 --- a/packages/json-rpc-engine/src/utils.ts +++ b/packages/json-rpc-engine/src/utils.ts @@ -5,6 +5,7 @@ import { type JsonRpcParams, type JsonRpcRequest as BaseJsonRpcRequest, } from '@metamask/utils'; +import { original as getOriginalState } from 'immer'; export type JsonRpcNotification = BaseJsonRpcNotification; @@ -40,3 +41,19 @@ export class JsonRpcEngineError extends Error { this.name = 'JsonRpcEngineError'; } } + +/** + * For cloning a request object _inside_ a middleware. + * + * **Must** be used to continue to access request data after the middleware has + * returned. This is because middleware receive an `immer` draft of the request, + * which is a proxy that becomes revoked after the middleware returns. + * + * @param request - The request to clone. + * @returns The cloned request. + */ +export function cloneRequest( + request: Request, +): Request { + return structuredClone(getOriginalState(request)) as Request; +} diff --git a/yarn.lock b/yarn.lock index b9f25674d0..8506140ca2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3602,6 +3602,7 @@ __metadata: immer: "npm:^9.0.6" jest: "npm:^27.5.1" jest-it-up: "npm:^2.0.2" + lodash: "npm:^4.17.21" ts-jest: "npm:^27.1.4" typedoc: "npm:^0.24.8" typescript: "npm:~5.2.2" From 77f3aa290824a8c674db45ef6c3fd3d976418a1b Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Tue, 15 Jul 2025 12:58:32 -0700 Subject: [PATCH 12/75] test: Add failing return handlers test case --- packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts b/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts index c60bd9c5a5..ca06927079 100644 --- a/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts +++ b/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts @@ -417,6 +417,18 @@ describe('JsonRpcEngineV2', () => { expect(returnHandlerResults).toStrictEqual(['3', '2', '1']); }); + it.skip('returns the expected result after no-op return handler', async () => { + const middleware1 = jest.fn(() => () => undefined); + const middleware2 = jest.fn(() => null); + const engine = new JsonRpcEngineV2({ + middleware: [middleware1, middleware2], + }); + + const result = await engine.handle(makeRequest()); + + expect(result).toBe(null); + }); + it('lets return handler update the result', async () => { const middleware1 = jest.fn(() => () => { return '1'; From 00c6316c7e309fb632c27e76b65188da59893b0a Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Tue, 15 Jul 2025 15:21:24 -0700 Subject: [PATCH 13/75] refactor: Fix asMiddleware composability and add tests --- .../src/JsonRpcEngineV2.test.ts | 94 ++++++++++++-- .../json-rpc-engine/src/JsonRpcEngineV2.ts | 118 ++++++++++++------ 2 files changed, 165 insertions(+), 47 deletions(-) diff --git a/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts b/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts index ca06927079..afd98872c1 100644 --- a/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts +++ b/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts @@ -417,7 +417,7 @@ describe('JsonRpcEngineV2', () => { expect(returnHandlerResults).toStrictEqual(['3', '2', '1']); }); - it.skip('returns the expected result after no-op return handler', async () => { + it('returns the expected result after no-op return handler', async () => { const middleware1 = jest.fn(() => () => undefined); const middleware2 = jest.fn(() => null); const engine = new JsonRpcEngineV2({ @@ -426,7 +426,7 @@ describe('JsonRpcEngineV2', () => { const result = await engine.handle(makeRequest()); - expect(result).toBe(null); + expect(result).toBeNull(); }); it('lets return handler update the result', async () => { @@ -498,7 +498,7 @@ describe('JsonRpcEngineV2', () => { describe('asMiddleware', () => { it('returns a middleware function', async () => { const engine1 = new JsonRpcEngineV2({ - middleware: [jest.fn(() => null)], + middleware: [() => null], }); const engine2 = new JsonRpcEngineV2({ middleware: [engine1.asMiddleware()], @@ -509,24 +509,102 @@ describe('JsonRpcEngineV2', () => { expect(result).toBeNull(); }); - it.skip('composes request mutation', async () => { + it('permits returning undefined if a later middleware ends the request', async () => { + const engine1 = new JsonRpcEngineV2({ + middleware: [() => undefined], + }); + const engine2 = new JsonRpcEngineV2({ + middleware: [engine1.asMiddleware(), () => null], + }); + + const result = await engine2.handle(makeRequest()); + + expect(result).toBeNull(); + }); + + it('composes nested engines', async () => { + const middleware1 = jest.fn(() => undefined); + const middleware2 = jest.fn(() => undefined); + const engine1 = new JsonRpcEngineV2({ + middleware: [middleware1], + }); + const engine2 = new JsonRpcEngineV2({ + middleware: [engine1.asMiddleware(), middleware2], + }); + const engine3 = new JsonRpcEngineV2({ + middleware: [engine2.asMiddleware(), () => null], + }); + + const result = await engine3.handle(makeRequest()); + + expect(result).toBeNull(); + expect(middleware1).toHaveBeenCalledTimes(1); + expect(middleware2).toHaveBeenCalledTimes(1); + }); + + it('propagates request mutation', async () => { const engine1 = new JsonRpcEngineV2({ middleware: [ (req) => { req.params = [2]; }, - // @ts-expect-error This will work. - (req) => req.params[0] * 2, + (req) => { + req.method = 'test_request_2'; + // @ts-expect-error Will obviously work. + req.params[0] *= 2; + }, ], }); + + let observedMethod: string | undefined; const engine2 = new JsonRpcEngineV2({ - // @ts-expect-error This will work. - middleware: [engine1.asMiddleware(), (req) => req.params[0] * 2], + middleware: [ + engine1.asMiddleware(), + (req) => { + observedMethod = req.method; + // @ts-expect-error Will obviously work. + return req.params[0] * 2; + }, + ], }); const result = await engine2.handle(makeRequest()); expect(result).toBe(8); + expect(observedMethod).toBe('test_request_2'); + }); + + it('runs return handlers in expected order', async () => { + const returnHandlerResults: string[] = []; + const engine1 = new JsonRpcEngineV2({ + middleware: [ + () => () => { + returnHandlerResults.push('1:a'); + }, + () => () => { + returnHandlerResults.push('1:b'); + }, + ], + }); + + const engine2 = new JsonRpcEngineV2({ + middleware: [ + engine1.asMiddleware(), + () => () => { + returnHandlerResults.push('2:a'); + }, + () => () => { + returnHandlerResults.push('2:b'); + }, + () => null, + ], + }); + + await engine2.handle(makeRequest()); + + // Order of return handlers is reversed _within_ engines, but not + // _between_ engines. + expect(returnHandlerResults).toStrictEqual(['1:b', '1:a', '2:b', '2:a']); }); }); }); diff --git a/packages/json-rpc-engine/src/JsonRpcEngineV2.ts b/packages/json-rpc-engine/src/JsonRpcEngineV2.ts index 26ba297a25..c6d2c2b0ff 100644 --- a/packages/json-rpc-engine/src/JsonRpcEngineV2.ts +++ b/packages/json-rpc-engine/src/JsonRpcEngineV2.ts @@ -14,7 +14,7 @@ export const EndNotification = Symbol.for('JsonRpcEngine:EndNotification'); type Context = Record; type ReturnHandler = ( - result: Result, + result: Result | undefined, ) => void | Result | Promise; export type MiddlewareResultConstraint = @@ -30,7 +30,8 @@ type HandledResult> = export type JsonRpcMiddleware< Request extends JsonRpcCall = JsonRpcCall, Result extends - MiddlewareResultConstraint = MiddlewareResultConstraint, + | MiddlewareResultConstraint + | undefined = MiddlewareResultConstraint, > = (request: Request, context: Context) => Result | Promise; type Options< @@ -61,7 +62,7 @@ export class JsonRpcEngineV2< } /** - * Handle a JSON-RPC request. A response will be returned. + * Handle a JSON-RPC request. A result will be returned. * * @param request - The JSON-RPC request to handle. * @returns The JSON-RPC response. @@ -71,14 +72,21 @@ export class JsonRpcEngineV2< ): Promise, void>>; /** - * Handle a JSON-RPC notification. No response will be returned. + * Handle a JSON-RPC notification. No result will be returned. * * @param notification - The JSON-RPC notification to handle. */ async handle(notification: Request & JsonRpcNotification): Promise; async handle(request: Request): Promise> { - const result = await this.#handle(request); + const { result } = await this.#handle(freeze({ ...request })); + + if (result === undefined) { + throw new JsonRpcEngineError( + `Nothing ended request: ${stringify(request)}`, + ); + } + return result === EndNotification ? undefined : (result as HandledResult); @@ -89,22 +97,40 @@ export class JsonRpcEngineV2< /** * Handle a JSON-RPC call. A response will be returned if the call is a request. * - * @param call - The JSON-RPC call to handle. + * @param request - The JSON-RPC call to handle. * @returns The JSON-RPC response, if any. */ - async handleAny(call: Request): Promise | void> { - return this.handle(call); + async handleAny(request: Request): Promise | void> { + return this.handle(request); } - async #handle(request: Request, context: Context = {}): Promise { - const { result, returnHandlers } = await this.#runMiddleware( + /** + * Handle a JSON-RPC request. Throws if a middleware or return handler + * performs an invalid operation. Permits returning an `undefined` result. + * + * @param request - The JSON-RPC request to handle. + * @param context - The context to pass to the middleware. + * @returns The result from the middleware. + */ + async #handle( + request: Request, + context: Context = {}, + ): Promise<{ + result: Result | undefined; + finalRequest: Readonly; + }> { + const { result, returnHandlers, finalRequest } = await this.#runMiddleware( request, context, ); - return returnHandlers.length === 0 - ? result - : await this.#runReturnHandlers(result, returnHandlers); + return { + result: + returnHandlers.length === 0 + ? result + : await this.#runReturnHandlers(result, returnHandlers), + finalRequest, + }; } /** @@ -118,14 +144,14 @@ export class JsonRpcEngineV2< originalRequest: Request, context: Context, ): Promise<{ - result: Extract; + result: Extract | undefined; returnHandlers: ReturnHandler[]; finalRequest: Readonly; }> { const returnHandlers: ReturnHandler[] = []; const isReq = isRequest(originalRequest); - let request = freeze({ ...originalRequest }); + let request = originalRequest; let result: Extract | undefined; for (const middleware of this.#middleware) { @@ -143,7 +169,7 @@ export class JsonRpcEngineV2< returnHandlers.push(currentResult); } else if (currentResult !== undefined) { - // Cast required due to incorrect type narrowing + // Cast required due to unexpected type narrowing result = currentResult as Extract< Result, Json | typeof EndNotification @@ -152,20 +178,18 @@ export class JsonRpcEngineV2< } } - if (result === undefined) { - throw new JsonRpcEngineError( - `Nothing ended request: ${stringify(request)}`, - ); - } else if (isReq) { - if (result === EndNotification) { + if (result !== undefined) { + if (isReq) { + if (result === EndNotification) { + throw new JsonRpcEngineError( + `Request handled as notification: ${stringify(request)}`, + ); + } + } else if (result !== EndNotification) { throw new JsonRpcEngineError( - `Request handled as notification: ${stringify(request)}`, + `Notification handled as request: ${stringify(request)}`, ); } - } else if (result !== EndNotification) { - throw new JsonRpcEngineError( - `Notification handled as request: ${stringify(request)}`, - ); } return { @@ -183,16 +207,21 @@ export class JsonRpcEngineV2< * @returns The final result. */ async #runReturnHandlers( - initialResult: Extract, - returnHandlers: ReturnHandler[], - ): Promise { + initialResult: Extract | undefined, + returnHandlers: readonly ReturnHandler[], + ): Promise { let result = initialResult; - // ATTN: Run return handlers in reverse order of registration. - for (const returnHandler of returnHandlers.reverse()) { - // Return handlers can either modify the result in place or return a new value. - result = await produce(result, async (draft: Json) => - returnHandler(draft), - ); + // Run return handlers in reverse order of registration. + for (let i = returnHandlers.length - 1; i >= 0; i--) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const returnHandler = returnHandlers[i]!; + + result = await produce(result, async (draft: Json) => { + // Return handlers can either modify the result in place or return a + // new value. + const newResult = await returnHandler(draft); + return newResult === undefined ? draft : newResult; + }); } return result; @@ -203,8 +232,17 @@ export class JsonRpcEngineV2< * * @returns The JSON-RPC middleware. */ - asMiddleware(): JsonRpcMiddleware { - return async (request, context) => this.#handle(request, context); + asMiddleware(): JsonRpcMiddleware { + return async (request, context) => { + const { result, finalRequest } = await this.#handle(request, context); + // Propagate any changes to the request to the original request. + request.method = finalRequest.method; + // @ts-expect-error TypeScript complains about this for unknown reasons + // (and not because finalRequest is readonly) + request.params = finalRequest.params; + + return result; + }; } } @@ -212,7 +250,9 @@ export class JsonRpcEngineV2< const readonlyProps = ['id', 'jsonrpc'] as const; /** - * Update a request using Immer. + * Update a request using `immer`. Middleware may update the `method` and + * `params` properties, but not the `id` or `jsonrpc` properties. The request + * object must be updated in place. * * @param request - The request to update. * @param recipe - The recipe function. From 05f3ed673aa4f0aa25f60f5835952587b89d731f Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Tue, 15 Jul 2025 15:41:48 -0700 Subject: [PATCH 14/75] refactor: Make context a Map and add tests --- .../src/JsonRpcEngineV2.test.ts | 104 +++++++++++++++++- .../json-rpc-engine/src/JsonRpcEngineV2.ts | 14 +-- 2 files changed, 105 insertions(+), 13 deletions(-) diff --git a/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts b/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts index afd98872c1..6eb0af6a1a 100644 --- a/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts +++ b/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts @@ -1,10 +1,11 @@ -import type { JsonRpcId, NonEmptyArray } from '@metamask/utils'; +import type { NonEmptyArray } from '@metamask/utils'; import { Json } from '@metamask/utils'; import { original as getOriginalState } from 'immer'; import cloneDeep from 'lodash/cloneDeep'; import type { JsonRpcMiddleware, + MiddlewareContext, MiddlewareResultConstraint, } from './JsonRpcEngineV2'; import { JsonRpcEngineV2, EndNotification } from './JsonRpcEngineV2'; @@ -54,7 +55,7 @@ const makeMockMiddleware = < ...rest: JsonRpcMiddleware[] ): NonEmptyArray> => [middleware, ...rest].map( - (fn) => (request: Request, context: Record) => + (fn) => (request: Request, context: MiddlewareContext) => fn(getOriginalState(request) as Request, context), ) as NonEmptyArray>; @@ -73,7 +74,7 @@ describe('JsonRpcEngineV2', () => { await engine.handle(notification); expect(middleware).toHaveBeenCalledTimes(1); - expect(middleware).toHaveBeenCalledWith(notification, {}); + expect(middleware).toHaveBeenCalledWith(notification, expect.any(Map)); }); it('returns no result', async () => { @@ -186,7 +187,7 @@ describe('JsonRpcEngineV2', () => { expect(result).toBeNull(); expect(middleware).toHaveBeenCalledTimes(1); - expect(middleware).toHaveBeenCalledWith(request, {}); + expect(middleware).toHaveBeenCalledWith(request, expect.any(Map)); }); it('returns a result from the middleware, with multiple middleware', async () => { @@ -201,9 +202,9 @@ describe('JsonRpcEngineV2', () => { expect(result).toBeNull(); expect(middleware1).toHaveBeenCalledTimes(1); - expect(middleware1).toHaveBeenCalledWith(request, {}); + expect(middleware1).toHaveBeenCalledWith(request, expect.any(Map)); expect(middleware2).toHaveBeenCalledTimes(1); - expect(middleware2).toHaveBeenCalledWith(request, {}); + expect(middleware2).toHaveBeenCalledWith(request, expect.any(Map)); }); it('throws if a middleware throws', async () => { @@ -477,6 +478,71 @@ describe('JsonRpcEngineV2', () => { ); }); }); + + describe('context', () => { + it('passes the context to the middleware', async () => { + const middleware = jest.fn((_req, context) => { + expect(context).toBeInstanceOf(Map); + return null; + }); + const engine = new JsonRpcEngineV2({ + middleware: [middleware], + }); + + await engine.handle(makeRequest()); + }); + + it('propagates context changes to subsequent middleware', async () => { + const middleware1 = jest.fn((_req, context) => { + context.set('foo', 'bar'); + }); + const middleware2 = jest.fn((_req, context) => { + return context.get('foo') as string; + }); + const engine = new JsonRpcEngineV2({ + middleware: [middleware1, middleware2], + }); + + const result = await engine.handle(makeRequest()); + + expect(result).toBe('bar'); + }); + + it('propagates context changes from middleware to return handlers', async () => { + const middleware1 = jest.fn((_req, context) => () => { + return context.get('foo') as string; + }); + const middleware2 = jest.fn((_req, context) => { + context.set('foo', 'bar'); + }); + const engine = new JsonRpcEngineV2 string) | void>({ + middleware: [middleware1, middleware2], + }); + + const result = await engine.handle(makeRequest()); + + expect(result).toBe('bar'); + }); + + it('propagates context changes from return handlers to return handlers', async () => { + const middleware1 = jest.fn((_req, context) => () => { + return context.get('foo') as string; + }); + const middleware2 = jest.fn((_req, context) => () => { + context.set('foo', 'bar'); + }); + const engine = new JsonRpcEngineV2< + JsonRpcCall, + (() => string) | (() => void) + >({ + middleware: [middleware1, middleware2], + }); + + const result = await engine.handle(makeRequest()); + + expect(result).toBe('bar'); + }); + }); }); describe('handleAny', () => { @@ -574,6 +640,32 @@ describe('JsonRpcEngineV2', () => { expect(observedMethod).toBe('test_request_2'); }); + it('propagates context changes', async () => { + const engine1 = new JsonRpcEngineV2({ + middleware: [ + (_req, context) => { + const num = context.get('foo') as number; + context.set('foo', num * 2); + }, + ], + }); + const engine2 = new JsonRpcEngineV2({ + middleware: [ + (_req, context) => { + context.set('foo', 2); + }, + engine1.asMiddleware(), + (_req, context) => { + return (context.get('foo') as number) * 2; + }, + ], + }); + + const result = await engine2.handle(makeRequest()); + + expect(result).toBe(8); + }); + it('runs return handlers in expected order', async () => { const returnHandlerResults: string[] = []; const engine1 = new JsonRpcEngineV2({ diff --git a/packages/json-rpc-engine/src/JsonRpcEngineV2.ts b/packages/json-rpc-engine/src/JsonRpcEngineV2.ts index c6d2c2b0ff..950a93653d 100644 --- a/packages/json-rpc-engine/src/JsonRpcEngineV2.ts +++ b/packages/json-rpc-engine/src/JsonRpcEngineV2.ts @@ -11,7 +11,7 @@ import type { JsonRpcCall } from './utils'; export const EndNotification = Symbol.for('JsonRpcEngine:EndNotification'); -type Context = Record; +export type MiddlewareContext = Map; type ReturnHandler = ( result: Result | undefined, @@ -24,15 +24,15 @@ export type MiddlewareResultConstraint = : void | typeof EndNotification : void | Json | ReturnHandler; -type HandledResult> = - Exclude | void; - export type JsonRpcMiddleware< Request extends JsonRpcCall = JsonRpcCall, Result extends | MiddlewareResultConstraint | undefined = MiddlewareResultConstraint, -> = (request: Request, context: Context) => Result | Promise; +> = (request: Request, context: MiddlewareContext) => Result | Promise; + +type HandledResult> = + Exclude | void; type Options< Request extends JsonRpcCall, @@ -114,7 +114,7 @@ export class JsonRpcEngineV2< */ async #handle( request: Request, - context: Context = {}, + context: MiddlewareContext = new Map(), ): Promise<{ result: Result | undefined; finalRequest: Readonly; @@ -142,7 +142,7 @@ export class JsonRpcEngineV2< */ async #runMiddleware( originalRequest: Request, - context: Context, + context: MiddlewareContext, ): Promise<{ result: Extract | undefined; returnHandlers: ReturnHandler[]; From 79f162384f939cd0a3c89dc3a9a6558c5cfe6172 Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Thu, 17 Jul 2025 12:04:33 -0700 Subject: [PATCH 15/75] test: Add asynchrony tests --- .../src/JsonRpcEngineV2.test.ts | 191 +++++++----------- .../json-rpc-engine/src/JsonRpcEngineV2.ts | 77 +++---- 2 files changed, 116 insertions(+), 152 deletions(-) diff --git a/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts b/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts index 6eb0af6a1a..12f73ac835 100644 --- a/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts +++ b/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts @@ -1,13 +1,9 @@ import type { NonEmptyArray } from '@metamask/utils'; -import { Json } from '@metamask/utils'; +import type { Json } from '@metamask/utils'; import { original as getOriginalState } from 'immer'; import cloneDeep from 'lodash/cloneDeep'; -import type { - JsonRpcMiddleware, - MiddlewareContext, - MiddlewareResultConstraint, -} from './JsonRpcEngineV2'; +import type { JsonRpcMiddleware, MiddlewareContext } from './JsonRpcEngineV2'; import { JsonRpcEngineV2, EndNotification } from './JsonRpcEngineV2'; import { cloneRequest, @@ -49,7 +45,7 @@ const makeRequest = ( */ const makeMockMiddleware = < Request extends JsonRpcCall, - Result extends MiddlewareResultConstraint, + Result extends Json | void, >( middleware: JsonRpcMiddleware, ...rest: JsonRpcMiddleware[] @@ -63,9 +59,8 @@ describe('JsonRpcEngineV2', () => { describe('handle', () => { describe('notifications', () => { it('passes the notification through middleware', async () => { - const middleware: JsonRpcMiddleware = jest.fn( - () => EndNotification, - ); + const middleware: JsonRpcMiddleware = + jest.fn(() => EndNotification); const engine = new JsonRpcEngineV2({ middleware: makeMockMiddleware(middleware), }); @@ -479,6 +474,72 @@ describe('JsonRpcEngineV2', () => { }); }); + describe('asynchrony', () => { + it('handles asynchronous middleware', async () => { + const middleware = jest.fn(async () => { + return null; + }); + const engine = new JsonRpcEngineV2({ + middleware: [middleware], + }); + + const result = await engine.handle(makeRequest()); + + expect(result).toBeNull(); + }); + + it('handles mixed synchronous and asynchronous middleware', async () => { + const middleware1 = jest.fn((_req, context) => { + context.set('foo', [1]); + }); + const middleware2 = jest.fn(async (_req, context) => { + const nums = context.get('foo') as number[]; + context.set('foo', [...nums, 2]); + }); + const middleware3 = jest.fn(async (_req, context) => { + const nums = context.get('foo') as number[]; + return [...nums, 3]; + }); + const engine = new JsonRpcEngineV2({ + middleware: [middleware1, middleware2, middleware3], + }); + + const result = await engine.handle(makeRequest()); + + expect(result).toStrictEqual([1, 2, 3]); + }); + + it('handles asynchronous return handlers', async () => { + const middleware = jest.fn(() => async () => { + return null; + }); + const engine = new JsonRpcEngineV2({ + middleware: [middleware], + }); + + const result = await engine.handle(makeRequest()); + + expect(result).toBeNull(); + }); + + it('handles mixed synchronous and asynchronous return handlers', async () => { + const middleware1 = jest.fn(() => (result: number | void) => { + // eslint-disable-next-line jest/no-conditional-in-test + return (result ?? 0) * 2; + }); + const middleware2 = jest.fn(() => async () => { + return 2; + }); + const engine = new JsonRpcEngineV2({ + middleware: [middleware1, middleware2], + }); + + const result = await engine.handle(makeRequest()); + + expect(result).toBe(4); + }); + }); + describe('context', () => { it('passes the context to the middleware', async () => { const middleware = jest.fn((_req, context) => { @@ -515,7 +576,7 @@ describe('JsonRpcEngineV2', () => { const middleware2 = jest.fn((_req, context) => { context.set('foo', 'bar'); }); - const engine = new JsonRpcEngineV2 string) | void>({ + const engine = new JsonRpcEngineV2({ middleware: [middleware1, middleware2], }); @@ -531,10 +592,7 @@ describe('JsonRpcEngineV2', () => { const middleware2 = jest.fn((_req, context) => () => { context.set('foo', 'bar'); }); - const engine = new JsonRpcEngineV2< - JsonRpcCall, - (() => string) | (() => void) - >({ + const engine = new JsonRpcEngineV2({ middleware: [middleware1, middleware2], }); @@ -649,7 +707,7 @@ describe('JsonRpcEngineV2', () => { }, ], }); - const engine2 = new JsonRpcEngineV2({ + const engine2 = new JsonRpcEngineV2({ middleware: [ (_req, context) => { context.set('foo', 2); @@ -700,104 +758,3 @@ describe('JsonRpcEngineV2', () => { }); }); }); - -// describe('JsonRpcEngineV2', () => { -// it('should handle a request', async () => { -// const engine = new JsonRpcEngineV2({ -// middleware: [ -// (req: JsonRpcNotification, context): void => {}, -// (req: JsonRpcNotification, context): typeof EndNotification => { -// return EndNotification; -// }, -// (req: JsonRpcCall, context): void | typeof EndNotification => { -// return EndNotification; -// }, -// // (req: JsonRpcRequest, context): void | typeof EndNotification => { -// // return EndNotification; -// // }, -// ], -// }); - -// const middleware: JsonRpcMiddleware = ( -// req, -// context, -// ) => {}; -// const middleware2: JsonRpcMiddleware< -// JsonRpcRequest, -// // @ts-expect-error Should be illegal. -// typeof EndNotification -// > = (req, context) => { -// return EndNotification; -// }; -// type foo = ReturnType; - -// const engine2 = new JsonRpcEngineV2({ -// middleware: [ -// // @ts-expect-error Should be illegal. -// (req: JsonRpcRequest, context): void | typeof EndNotification => { -// return EndNotification; -// }, -// ], -// }); - -// const engine3 = new JsonRpcEngineV2({ -// middleware: [ -// ((req: JsonRpcRequest, context) => { -// return null; -// }) as JsonRpcMiddleware, -// ], -// }); - -// const engine4 = new JsonRpcEngineV2({ -// middleware: [ -// ((req: JsonRpcCall, context): null => { -// return null; -// }) as JsonRpcMiddleware, -// ], -// }); - -// const reqRes = await engine4.handle({ -// id: '1', -// method: 'foo', -// jsonrpc: '2.0', -// params: [], -// }); - -// const notifRes = await engine4.handle({ -// method: 'foo', -// jsonrpc: '2.0', -// params: [], -// }); - -// const callRes = await engine4.handleAny({ -// id: '1', -// method: 'foo', -// jsonrpc: '2.0', -// params: [], -// }); - -// const a: JsonRpcRequest = { -// id: '1', -// method: 'foo', -// jsonrpc: '2.0', -// params: [], -// }; - -// const foo: JsonRpcCall = { -// id: '1', -// method: 'foo', -// jsonrpc: '2.0', -// params: [], -// }; - -// const bar: JsonRpcNotification = { -// method: 'foo', -// jsonrpc: '2.0', -// params: [], -// }; - -// const fizz: JsonRpcNotification = foo; - -// const b: JsonRpcRequest = foo; -// }); -// }); diff --git a/packages/json-rpc-engine/src/JsonRpcEngineV2.ts b/packages/json-rpc-engine/src/JsonRpcEngineV2.ts index 950a93653d..d415c56475 100644 --- a/packages/json-rpc-engine/src/JsonRpcEngineV2.ts +++ b/packages/json-rpc-engine/src/JsonRpcEngineV2.ts @@ -13,31 +13,38 @@ export const EndNotification = Symbol.for('JsonRpcEngine:EndNotification'); export type MiddlewareContext = Map; -type ReturnHandler = ( - result: Result | undefined, -) => void | Result | Promise; - -export type MiddlewareResultConstraint = - Request extends JsonRpcNotification - ? Request extends JsonRpcRequest - ? void | Json | ReturnHandler - : void | typeof EndNotification - : void | Json | ReturnHandler; +export type ReturnHandler = ( + result: Result | void, +) => Result | void | Promise; + +type MiddlewareReturnValue = + | Json + | void + | ReturnHandler + | typeof EndNotification; + +// "EngineResult" being the "Result" generic parameter of the engine +export type MiddlewareResult = + | EngineResult + | ReturnHandler + | typeof EndNotification + | void; export type JsonRpcMiddleware< Request extends JsonRpcCall = JsonRpcCall, - Result extends - | MiddlewareResultConstraint - | undefined = MiddlewareResultConstraint, -> = (request: Request, context: MiddlewareContext) => Result | Promise; + EngineResult extends Json | void = Json | void, +> = ( + request: Request, + context: MiddlewareContext, +) => MiddlewareResult | Promise>; -type HandledResult> = - Exclude | void; +// The return type of `handle()` and `handleAny()` +type HandledResult = Exclude< + Result, + typeof EndNotification | ReturnHandler +> | void; -type Options< - Request extends JsonRpcCall, - Result extends MiddlewareResultConstraint, -> = { +type Options = { middleware: NonEmptyArray>; }; @@ -51,7 +58,7 @@ type Options< */ export class JsonRpcEngineV2< Request extends JsonRpcCall, - Result extends MiddlewareResultConstraint, + Result extends Json | void, > { static readonly EndNotification = EndNotification; @@ -100,7 +107,7 @@ export class JsonRpcEngineV2< * @param request - The JSON-RPC call to handle. * @returns The JSON-RPC response, if any. */ - async handleAny(request: Request): Promise | void> { + async handleAny(request: Request): Promise> { return this.handle(request); } @@ -116,7 +123,7 @@ export class JsonRpcEngineV2< request: Request, context: MiddlewareContext = new Map(), ): Promise<{ - result: Result | undefined; + result: MiddlewareResult | void; finalRequest: Readonly; }> { const { result, returnHandlers, finalRequest } = await this.#runMiddleware( @@ -144,18 +151,18 @@ export class JsonRpcEngineV2< originalRequest: Request, context: MiddlewareContext, ): Promise<{ - result: Extract | undefined; - returnHandlers: ReturnHandler[]; + result: MiddlewareResult; + returnHandlers: ReturnHandler[]; finalRequest: Readonly; }> { - const returnHandlers: ReturnHandler[] = []; + const returnHandlers: ReturnHandler[] = []; const isReq = isRequest(originalRequest); let request = originalRequest; - let result: Extract | undefined; + let result: MiddlewareResult | undefined; for (const middleware of this.#middleware) { - let currentResult: Result | undefined; + let currentResult: MiddlewareResult | undefined; request = await updateRequest(request, async (draft) => { currentResult = await middleware(draft, context); }); @@ -207,16 +214,16 @@ export class JsonRpcEngineV2< * @returns The final result. */ async #runReturnHandlers( - initialResult: Extract | undefined, - returnHandlers: readonly ReturnHandler[], - ): Promise { + initialResult: MiddlewareResult | void, + returnHandlers: readonly ReturnHandler[], + ): Promise { let result = initialResult; // Run return handlers in reverse order of registration. for (let i = returnHandlers.length - 1; i >= 0; i--) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const returnHandler = returnHandlers[i]!; - result = await produce(result, async (draft: Json) => { + result = await produce(result, async (draft: Result) => { // Return handlers can either modify the result in place or return a // new value. const newResult = await returnHandler(draft); @@ -224,7 +231,7 @@ export class JsonRpcEngineV2< }); } - return result; + return result as Result | void; } /** @@ -232,7 +239,7 @@ export class JsonRpcEngineV2< * * @returns The JSON-RPC middleware. */ - asMiddleware(): JsonRpcMiddleware { + asMiddleware(): JsonRpcMiddleware { return async (request, context) => { const { result, finalRequest } = await this.#handle(request, context); // Propagate any changes to the request to the original request. @@ -241,7 +248,7 @@ export class JsonRpcEngineV2< // (and not because finalRequest is readonly) request.params = finalRequest.params; - return result; + return result as MiddlewareResult; }; } } From d5c9037c65cf5ed3803bcfa80508bbb7df6dbfed Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Thu, 17 Jul 2025 12:17:38 -0700 Subject: [PATCH 16/75] test: Add utils tests --- packages/json-rpc-engine/jest.config.js | 2 + .../src/JsonRpcEngineV2.test.ts | 7 -- packages/json-rpc-engine/src/utils.test.ts | 97 +++++++++++++++++++ packages/json-rpc-engine/test/setup.ts | 9 ++ 4 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 packages/json-rpc-engine/src/utils.test.ts create mode 100644 packages/json-rpc-engine/test/setup.ts diff --git a/packages/json-rpc-engine/jest.config.js b/packages/json-rpc-engine/jest.config.js index ca08413339..62e738869b 100644 --- a/packages/json-rpc-engine/jest.config.js +++ b/packages/json-rpc-engine/jest.config.js @@ -14,6 +14,8 @@ module.exports = merge(baseConfig, { // The display name when running multiple projects displayName, + setupFiles: [path.resolve(__dirname, 'test/setup.ts')], + // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { diff --git a/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts b/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts index 12f73ac835..761cf73a71 100644 --- a/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts +++ b/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts @@ -1,7 +1,6 @@ import type { NonEmptyArray } from '@metamask/utils'; import type { Json } from '@metamask/utils'; import { original as getOriginalState } from 'immer'; -import cloneDeep from 'lodash/cloneDeep'; import type { JsonRpcMiddleware, MiddlewareContext } from './JsonRpcEngineV2'; import { JsonRpcEngineV2, EndNotification } from './JsonRpcEngineV2'; @@ -16,12 +15,6 @@ import { const jsonrpc = '2.0' as const; -// Mock structuredClone if it's not available. -globalThis.structuredClone = - typeof globalThis.structuredClone === 'function' - ? globalThis.structuredClone - : cloneDeep; - const makeRequest = ( params: Partial = {}, ): Request => diff --git a/packages/json-rpc-engine/src/utils.test.ts b/packages/json-rpc-engine/src/utils.test.ts new file mode 100644 index 0000000000..4c9c78d655 --- /dev/null +++ b/packages/json-rpc-engine/src/utils.test.ts @@ -0,0 +1,97 @@ +import { produce } from 'immer'; + +import { + isRequest, + isNotification, + stringify, + JsonRpcEngineError, + cloneRequest, +} from './utils'; +import type { JsonRpcCall } from './utils'; + +const jsonrpc = '2.0' as const; + +describe('utils', () => { + describe('isRequest', () => { + it.each([ + [ + { + jsonrpc, + id: 1, + method: 'eth_getBlockByNumber', + params: ['latest'], + }, + true, + ], + [ + { + jsonrpc, + method: 'eth_getBlockByNumber', + params: ['latest'], + }, + false, + ], + ])('should return $expected for $request', (request, expected) => { + expect(isRequest(request)).toBe(expected); + }); + }); + + describe('isNotification', () => { + it.each([ + [{ jsonrpc, method: 'eth_getBlockByNumber', params: ['latest'] }, true], + [ + { id: 1, jsonrpc, method: 'eth_getBlockByNumber', params: ['latest'] }, + false, + ], + ])('should return $expected for $request', (request, expected) => { + expect(isNotification(request)).toBe(expected); + }); + }); + + describe('stringify', () => { + it('should stringify a JSON object', () => { + expect(stringify({ foo: 'bar' })).toMatchInlineSnapshot(` + "{ + \\"foo\\": \\"bar\\" + }" + `); + }); + }); + + describe('JsonRpcEngineError', () => { + it('should create an error with the correct name', () => { + const error = new JsonRpcEngineError('test'); + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe('JsonRpcEngineError'); + expect(error.message).toBe('test'); + }); + }); + + describe('cloneRequest', () => { + it('should clone a request', () => { + let clonedRequest: JsonRpcCall | undefined; + + const producedRequest = produce( + { + jsonrpc, + id: 1, + method: 'eth_getBlockByNumber', + params: ['latest'], + }, + (draft) => { + clonedRequest = cloneRequest(draft); + draft.id = 2; + }, + ); + + expect(clonedRequest).toStrictEqual({ + jsonrpc, + id: 1, + method: 'eth_getBlockByNumber', + params: ['latest'], + }); + expect(producedRequest).not.toBe(clonedRequest); + expect(producedRequest.id).toBe(2); + }); + }); +}); diff --git a/packages/json-rpc-engine/test/setup.ts b/packages/json-rpc-engine/test/setup.ts new file mode 100644 index 0000000000..265ab1a353 --- /dev/null +++ b/packages/json-rpc-engine/test/setup.ts @@ -0,0 +1,9 @@ +import cloneDeep from 'lodash/cloneDeep'; + +import '../../../tests/setup'; + +// Polyfill structuredClone if it's not available. +globalThis.structuredClone = + typeof globalThis.structuredClone === 'function' + ? globalThis.structuredClone + : cloneDeep; From 906730678c09d6f7e27156b14b2e1fa963173d1f Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Thu, 17 Jul 2025 14:54:26 -0700 Subject: [PATCH 17/75] fix: Ensure params deletion propagates in asMiddleware --- .../src/JsonRpcEngineV2.test.ts | 32 ++++++++++++++++++- .../json-rpc-engine/src/JsonRpcEngineV2.ts | 9 ++++-- packages/json-rpc-engine/src/index.test.ts | 1 + packages/json-rpc-engine/src/index.ts | 2 ++ 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts b/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts index 761cf73a71..9237d1c9ea 100644 --- a/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts +++ b/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts @@ -362,7 +362,7 @@ describe('JsonRpcEngineV2', () => { it('throws if modifying the request outside of the middleware', async () => { let retained: JsonRpcCall | undefined; - const middleware = jest.fn((req) => { + const middleware = jest.fn((req: JsonRpcCall) => { retained = req; return null; }); @@ -691,6 +691,36 @@ describe('JsonRpcEngineV2', () => { expect(observedMethod).toBe('test_request_2'); }); + it('propagates request mutation (parameter deletion)', async () => { + const engine1 = new JsonRpcEngineV2({ + middleware: [ + (req) => { + delete req.params; + }, + ], + }); + + let observedRequest: unknown; + const engine2 = new JsonRpcEngineV2({ + middleware: [ + engine1.asMiddleware(), + (req) => { + observedRequest = cloneRequest(req); + return null; + }, + ], + }); + + const result = await engine2.handle(makeRequest()); + + expect(result).toBeNull(); + expect(observedRequest).toStrictEqual({ + jsonrpc: '2.0', + id: '1', + method: 'test_request', + }); + }); + it('propagates context changes', async () => { const engine1 = new JsonRpcEngineV2({ middleware: [ diff --git a/packages/json-rpc-engine/src/JsonRpcEngineV2.ts b/packages/json-rpc-engine/src/JsonRpcEngineV2.ts index d415c56475..0d2adc93a2 100644 --- a/packages/json-rpc-engine/src/JsonRpcEngineV2.ts +++ b/packages/json-rpc-engine/src/JsonRpcEngineV2.ts @@ -242,11 +242,14 @@ export class JsonRpcEngineV2< asMiddleware(): JsonRpcMiddleware { return async (request, context) => { const { result, finalRequest } = await this.#handle(request, context); + // Propagate any changes to the request to the original request. request.method = finalRequest.method; - // @ts-expect-error TypeScript complains about this for unknown reasons - // (and not because finalRequest is readonly) - request.params = finalRequest.params; + if ('params' in finalRequest) { + request.params = finalRequest.params; + } else { + delete request.params; + } return result as MiddlewareResult; }; diff --git a/packages/json-rpc-engine/src/index.test.ts b/packages/json-rpc-engine/src/index.test.ts index 0c2f317b66..8050e82524 100644 --- a/packages/json-rpc-engine/src/index.test.ts +++ b/packages/json-rpc-engine/src/index.test.ts @@ -5,6 +5,7 @@ describe('@metamask/json-rpc-engine', () => { expect(Object.keys(allExports)).toMatchInlineSnapshot(` Array [ "getUniqueId", + "JsonRpcEngineError", "EndNotification", "JsonRpcEngineV2", ] diff --git a/packages/json-rpc-engine/src/index.ts b/packages/json-rpc-engine/src/index.ts index 93859a753a..1257a7ca1e 100644 --- a/packages/json-rpc-engine/src/index.ts +++ b/packages/json-rpc-engine/src/index.ts @@ -1,2 +1,4 @@ export { getUniqueId } from './getUniqueId'; export * from './JsonRpcEngineV2'; +export { JsonRpcEngineError } from './utils'; +export type { JsonRpcCall, JsonRpcNotification, JsonRpcRequest } from './utils'; From 375964872b7796c105e35de60ab3013ce8e1e5fb Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Thu, 17 Jul 2025 15:09:15 -0700 Subject: [PATCH 18/75] feat: Add assertGet utility to context --- .../src/JsonRpcEngineV2.test.ts | 3 ++- .../json-rpc-engine/src/JsonRpcEngineV2.ts | 8 +++--- .../src/MiddlewareContext.test.ts | 26 +++++++++++++++++++ .../json-rpc-engine/src/MiddlewareContext.ts | 23 ++++++++++++++++ packages/json-rpc-engine/src/index.ts | 1 + 5 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 packages/json-rpc-engine/src/MiddlewareContext.test.ts create mode 100644 packages/json-rpc-engine/src/MiddlewareContext.ts diff --git a/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts b/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts index 9237d1c9ea..bbbcc372f7 100644 --- a/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts +++ b/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts @@ -2,8 +2,9 @@ import type { NonEmptyArray } from '@metamask/utils'; import type { Json } from '@metamask/utils'; import { original as getOriginalState } from 'immer'; -import type { JsonRpcMiddleware, MiddlewareContext } from './JsonRpcEngineV2'; +import type { JsonRpcMiddleware } from './JsonRpcEngineV2'; import { JsonRpcEngineV2, EndNotification } from './JsonRpcEngineV2'; +import type { MiddlewareContext } from './MiddlewareContext'; import { cloneRequest, JsonRpcEngineError, diff --git a/packages/json-rpc-engine/src/JsonRpcEngineV2.ts b/packages/json-rpc-engine/src/JsonRpcEngineV2.ts index 0d2adc93a2..a2788ad88f 100644 --- a/packages/json-rpc-engine/src/JsonRpcEngineV2.ts +++ b/packages/json-rpc-engine/src/JsonRpcEngineV2.ts @@ -6,13 +6,15 @@ import type { } from '@metamask/utils'; import { freeze, produce } from 'immer'; +import { + makeMiddlewareContext, + type MiddlewareContext, +} from './MiddlewareContext'; import { isRequest, JsonRpcEngineError, stringify } from './utils'; import type { JsonRpcCall } from './utils'; export const EndNotification = Symbol.for('JsonRpcEngine:EndNotification'); -export type MiddlewareContext = Map; - export type ReturnHandler = ( result: Result | void, ) => Result | void | Promise; @@ -121,7 +123,7 @@ export class JsonRpcEngineV2< */ async #handle( request: Request, - context: MiddlewareContext = new Map(), + context: MiddlewareContext = makeMiddlewareContext(), ): Promise<{ result: MiddlewareResult | void; finalRequest: Readonly; diff --git a/packages/json-rpc-engine/src/MiddlewareContext.test.ts b/packages/json-rpc-engine/src/MiddlewareContext.test.ts new file mode 100644 index 0000000000..ddc874d476 --- /dev/null +++ b/packages/json-rpc-engine/src/MiddlewareContext.test.ts @@ -0,0 +1,26 @@ +import { makeMiddlewareContext } from './MiddlewareContext'; + +describe('MiddlewareContext', () => { + it('is a map', () => { + const context = makeMiddlewareContext(); + expect(context).toBeInstanceOf(Map); + }); + + it('is frozen', () => { + const context = makeMiddlewareContext(); + expect(Object.isFrozen(context)).toBe(true); + }); + + it('assertGet throws if the key is not found', () => { + const context = makeMiddlewareContext(); + expect(() => context.assertGet('test')).toThrow( + `Context key "test" not found`, + ); + }); + + it('assertGet returns the value if the key is found', () => { + const context = makeMiddlewareContext(); + context.set('test', 'value'); + expect(context.assertGet('test')).toBe('value'); + }); +}); diff --git a/packages/json-rpc-engine/src/MiddlewareContext.ts b/packages/json-rpc-engine/src/MiddlewareContext.ts new file mode 100644 index 0000000000..fa89931b90 --- /dev/null +++ b/packages/json-rpc-engine/src/MiddlewareContext.ts @@ -0,0 +1,23 @@ +export type MiddlewareContext = Readonly< + Map & { + assertGet(key: string): Value; + } +>; + +export const makeMiddlewareContext = (): MiddlewareContext => { + const map = new Map(); + const assertGet = (key: string): Value => { + if (!map.has(key)) { + throw new Error(`Context key "${key}" not found`); + } + return map.get(key) as Value; + }; + + Object.defineProperty(map, 'assertGet', { + value: assertGet, + writable: false, + enumerable: true, + configurable: false, + }); + return Object.freeze(map) as MiddlewareContext; +}; diff --git a/packages/json-rpc-engine/src/index.ts b/packages/json-rpc-engine/src/index.ts index 1257a7ca1e..7c9ba07dfc 100644 --- a/packages/json-rpc-engine/src/index.ts +++ b/packages/json-rpc-engine/src/index.ts @@ -1,4 +1,5 @@ export { getUniqueId } from './getUniqueId'; export * from './JsonRpcEngineV2'; +export type { MiddlewareContext } from './MiddlewareContext'; export { JsonRpcEngineError } from './utils'; export type { JsonRpcCall, JsonRpcNotification, JsonRpcRequest } from './utils'; From cc6c732f77ebad4585f8b52f40595ee7a63f38a8 Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Fri, 18 Jul 2025 14:58:12 -0700 Subject: [PATCH 19/75] refactor: Migrate implementation from return handlers to next() --- .../src/JsonRpcEngineV2.test.ts | 11 +- .../json-rpc-engine/src/JsonRpcEngineV2.ts | 211 ++++++------------ packages/json-rpc-engine/src/index.test.ts | 1 - 3 files changed, 73 insertions(+), 150 deletions(-) diff --git a/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts b/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts index bbbcc372f7..496215379a 100644 --- a/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts +++ b/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts @@ -2,8 +2,8 @@ import type { NonEmptyArray } from '@metamask/utils'; import type { Json } from '@metamask/utils'; import { original as getOriginalState } from 'immer'; -import type { JsonRpcMiddleware } from './JsonRpcEngineV2'; -import { JsonRpcEngineV2, EndNotification } from './JsonRpcEngineV2'; +import type { JsonRpcMiddleware, MiddlewareParams } from './JsonRpcEngineV2'; +import { JsonRpcEngineV2 } from './JsonRpcEngineV2'; import type { MiddlewareContext } from './MiddlewareContext'; import { cloneRequest, @@ -45,8 +45,9 @@ const makeMockMiddleware = < ...rest: JsonRpcMiddleware[] ): NonEmptyArray> => [middleware, ...rest].map( - (fn) => (request: Request, context: MiddlewareContext) => - fn(getOriginalState(request) as Request, context), + (fn) => + ({ request, context, next }: MiddlewareParams) => + fn({ request: getOriginalState(request) as Request, context, next }), ) as NonEmptyArray>; describe('JsonRpcEngineV2', () => { @@ -54,7 +55,7 @@ describe('JsonRpcEngineV2', () => { describe('notifications', () => { it('passes the notification through middleware', async () => { const middleware: JsonRpcMiddleware = - jest.fn(() => EndNotification); + jest.fn(() => undefined); const engine = new JsonRpcEngineV2({ middleware: makeMockMiddleware(middleware), }); diff --git a/packages/json-rpc-engine/src/JsonRpcEngineV2.ts b/packages/json-rpc-engine/src/JsonRpcEngineV2.ts index a2788ad88f..a522cbb7a3 100644 --- a/packages/json-rpc-engine/src/JsonRpcEngineV2.ts +++ b/packages/json-rpc-engine/src/JsonRpcEngineV2.ts @@ -10,43 +10,33 @@ import { makeMiddlewareContext, type MiddlewareContext, } from './MiddlewareContext'; -import { isRequest, JsonRpcEngineError, stringify } from './utils'; +import { + isNotification, + isRequest, + JsonRpcEngineError, + stringify, +} from './utils'; import type { JsonRpcCall } from './utils'; -export const EndNotification = Symbol.for('JsonRpcEngine:EndNotification'); - -export type ReturnHandler = ( - result: Result | void, -) => Result | void | Promise; - -type MiddlewareReturnValue = - | Json - | void - | ReturnHandler - | typeof EndNotification; +export type Next = () => Promise; -// "EngineResult" being the "Result" generic parameter of the engine -export type MiddlewareResult = - | EngineResult - | ReturnHandler - | typeof EndNotification - | void; +export type MiddlewareParams< + Request extends JsonRpcCall, + Result extends Json | void, +> = { + request: Request; + context: MiddlewareContext; + next: Next; +}; export type JsonRpcMiddleware< - Request extends JsonRpcCall = JsonRpcCall, - EngineResult extends Json | void = Json | void, + Request extends JsonRpcCall, + Result extends Json | void, > = ( - request: Request, - context: MiddlewareContext, -) => MiddlewareResult | Promise>; - -// The return type of `handle()` and `handleAny()` -type HandledResult = Exclude< - Result, - typeof EndNotification | ReturnHandler -> | void; + params: MiddlewareParams, +) => Result | void | Promise; -type Options = { +type Options = { middleware: NonEmptyArray>; }; @@ -58,13 +48,14 @@ type Options = { * @template Request - The type of request to handle. * @template Result - The type of result to return. */ -export class JsonRpcEngineV2< - Request extends JsonRpcCall, - Result extends Json | void, -> { - static readonly EndNotification = EndNotification; +export class JsonRpcEngineV2 { + readonly #middleware: Readonly< + NonEmptyArray> + >; - readonly #middleware: readonly JsonRpcMiddleware[]; + #makeMiddlewareIterator(): Iterator> { + return this.#middleware[Symbol.iterator](); + } constructor({ middleware }: Options) { this.#middleware = [...middleware]; @@ -76,9 +67,7 @@ export class JsonRpcEngineV2< * @param request - The JSON-RPC request to handle. * @returns The JSON-RPC response. */ - async handle( - request: Request & JsonRpcRequest, - ): Promise, void>>; + async handle(request: Request & JsonRpcRequest): Promise; /** * Handle a JSON-RPC notification. No result will be returned. @@ -87,18 +76,16 @@ export class JsonRpcEngineV2< */ async handle(notification: Request & JsonRpcNotification): Promise; - async handle(request: Request): Promise> { + async handle(request: Request): Promise { + const isReq = isRequest(request); const { result } = await this.#handle(freeze({ ...request })); - if (result === undefined) { + if (isReq && result === undefined) { throw new JsonRpcEngineError( `Nothing ended request: ${stringify(request)}`, ); } - - return result === EndNotification - ? undefined - : (result as HandledResult); + return result; } // This exists because a JsonRpcCall overload of handle() cannot coexist with @@ -109,7 +96,7 @@ export class JsonRpcEngineV2< * @param request - The JSON-RPC call to handle. * @returns The JSON-RPC response, if any. */ - async handleAny(request: Request): Promise> { + async handleAny(request: JsonRpcCall & Request): Promise { return this.handle(request); } @@ -117,132 +104,68 @@ export class JsonRpcEngineV2< * Handle a JSON-RPC request. Throws if a middleware or return handler * performs an invalid operation. Permits returning an `undefined` result. * - * @param request - The JSON-RPC request to handle. + * @param originalRequest - The JSON-RPC request to handle. * @param context - The context to pass to the middleware. * @returns The result from the middleware. */ async #handle( - request: Request, - context: MiddlewareContext = makeMiddlewareContext(), - ): Promise<{ - result: MiddlewareResult | void; - finalRequest: Readonly; - }> { - const { result, returnHandlers, finalRequest } = await this.#runMiddleware( - request, - context, - ); - - return { - result: - returnHandlers.length === 0 - ? result - : await this.#runReturnHandlers(result, returnHandlers), - finalRequest, - }; - } - - /** - * Run the middleware for a request. - * - * @param originalRequest - The request to run the middleware for. - * @param context - The context to pass to the middleware. - * @returns The result from the middleware. - */ - async #runMiddleware( originalRequest: Request, - context: MiddlewareContext, + context: MiddlewareContext = makeMiddlewareContext(), ): Promise<{ - result: MiddlewareResult; - returnHandlers: ReturnHandler[]; + result: Result | void; finalRequest: Readonly; }> { - const returnHandlers: ReturnHandler[] = []; - const isReq = isRequest(originalRequest); + const middlewareIterator = this.#makeMiddlewareIterator(); + const firstMiddleware: JsonRpcMiddleware = + middlewareIterator.next().value; let request = originalRequest; - let result: MiddlewareResult | undefined; - for (const middleware of this.#middleware) { - let currentResult: MiddlewareResult | undefined; + const next: Next = async (): Promise => { + const { value: middleware, done } = middlewareIterator.next(); + // Unavoidable forward reference + // eslint-disable-next-line @typescript-eslint/no-use-before-define + return done ? undefined : await runMiddleware(middleware); + }; + + const runMiddleware = async ( + middleware: JsonRpcMiddleware, + ): Promise => { + let nextResult: Result | void; request = await updateRequest(request, async (draft) => { - currentResult = await middleware(draft, context); + nextResult = await middleware({ + request: draft, + context, + next, + }); }); - if (typeof currentResult === 'function') { - if (!isReq) { - throw new JsonRpcEngineError( - `Middleware returned a return handler for notification: ${stringify(request)}`, - ); - } + // @ts-expect-error - We happen to know that updateRequest awaits the + // callback, so we know that nextResult is not undefined. + return nextResult; + }; - returnHandlers.push(currentResult); - } else if (currentResult !== undefined) { - // Cast required due to unexpected type narrowing - result = currentResult as Extract< - Result, - Json | typeof EndNotification - >; - break; - } - } + const result = await runMiddleware(firstMiddleware); - if (result !== undefined) { - if (isReq) { - if (result === EndNotification) { - throw new JsonRpcEngineError( - `Request handled as notification: ${stringify(request)}`, - ); - } - } else if (result !== EndNotification) { - throw new JsonRpcEngineError( - `Notification handled as request: ${stringify(request)}`, - ); - } + if (isNotification(request) && result !== undefined) { + throw new JsonRpcEngineError( + `Result returned for notification: ${stringify(request)}`, + ); } return { result, - returnHandlers, finalRequest: request, }; } - /** - * Run the return handlers for a result. May or may not return a new result. - * - * @param initialResult - The initial result from the middleware. - * @param returnHandlers - The return handlers to run. - * @returns The final result. - */ - async #runReturnHandlers( - initialResult: MiddlewareResult | void, - returnHandlers: readonly ReturnHandler[], - ): Promise { - let result = initialResult; - // Run return handlers in reverse order of registration. - for (let i = returnHandlers.length - 1; i >= 0; i--) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const returnHandler = returnHandlers[i]!; - - result = await produce(result, async (draft: Result) => { - // Return handlers can either modify the result in place or return a - // new value. - const newResult = await returnHandler(draft); - return newResult === undefined ? draft : newResult; - }); - } - - return result as Result | void; - } - /** * Convert the engine into a JSON-RPC middleware. * * @returns The JSON-RPC middleware. */ - asMiddleware(): JsonRpcMiddleware { - return async (request, context) => { + asMiddleware(): JsonRpcMiddleware { + return async ({ request, context, next }) => { const { result, finalRequest } = await this.#handle(request, context); // Propagate any changes to the request to the original request. @@ -253,7 +176,7 @@ export class JsonRpcEngineV2< delete request.params; } - return result as MiddlewareResult; + return result === undefined ? await next() : result; }; } } diff --git a/packages/json-rpc-engine/src/index.test.ts b/packages/json-rpc-engine/src/index.test.ts index 8050e82524..7911f65a32 100644 --- a/packages/json-rpc-engine/src/index.test.ts +++ b/packages/json-rpc-engine/src/index.test.ts @@ -6,7 +6,6 @@ describe('@metamask/json-rpc-engine', () => { Array [ "getUniqueId", "JsonRpcEngineError", - "EndNotification", "JsonRpcEngineV2", ] `); From 92d3c10a5ccbae9f113f8956a6b3f58aebdb4684 Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Sat, 19 Jul 2025 10:24:56 -0700 Subject: [PATCH 20/75] refactor: Refine next() implementation, begin tests --- packages/json-rpc-engine/package.json | 3 +- .../src/JsonRpcEngineV2.test.ts | 731 ++++++++---------- .../json-rpc-engine/src/JsonRpcEngineV2.ts | 152 ++-- packages/json-rpc-engine/src/utils.test.ts | 32 - packages/json-rpc-engine/src/utils.ts | 17 - yarn.lock | 3 +- 6 files changed, 389 insertions(+), 549 deletions(-) diff --git a/packages/json-rpc-engine/package.json b/packages/json-rpc-engine/package.json index 28c86649e5..165e7982ff 100644 --- a/packages/json-rpc-engine/package.json +++ b/packages/json-rpc-engine/package.json @@ -69,7 +69,8 @@ "@metamask/rpc-errors": "^7.0.2", "@metamask/safe-event-emitter": "^3.0.0", "@metamask/utils": "^11.4.2", - "immer": "^9.0.6" + "@types/deep-freeze-strict": "^1.1.0", + "deep-freeze-strict": "^1.1.1" }, "devDependencies": { "@lavamoat/allow-scripts": "^3.0.4", diff --git a/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts b/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts index 496215379a..a550c5b723 100644 --- a/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts +++ b/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts @@ -1,12 +1,7 @@ -import type { NonEmptyArray } from '@metamask/utils'; -import type { Json } from '@metamask/utils'; -import { original as getOriginalState } from 'immer'; - -import type { JsonRpcMiddleware, MiddlewareParams } from './JsonRpcEngineV2'; +/* eslint-disable n/callback-return */ // next() is not a Node.js callback. +import type { JsonRpcMiddleware } from './JsonRpcEngineV2'; import { JsonRpcEngineV2 } from './JsonRpcEngineV2'; -import type { MiddlewareContext } from './MiddlewareContext'; import { - cloneRequest, JsonRpcEngineError, stringify, type JsonRpcCall, @@ -27,49 +22,29 @@ const makeRequest = ( ...params, }) as Request; -/** - * Wraps a set of mock middleware functions such that they receive the - * original request object as opposed to the immer draft object, which - * is revoked by the time we can observe it. - * - * @param middleware - The first middleware. This param exists to ensure that - * at least one middleware is provided. - * @param rest - The rest of the middleware. - * @returns An array of the wrapped middleware functions. - */ -const makeMockMiddleware = < - Request extends JsonRpcCall, - Result extends Json | void, ->( - middleware: JsonRpcMiddleware, - ...rest: JsonRpcMiddleware[] -): NonEmptyArray> => - [middleware, ...rest].map( - (fn) => - ({ request, context, next }: MiddlewareParams) => - fn({ request: getOriginalState(request) as Request, context, next }), - ) as NonEmptyArray>; - describe('JsonRpcEngineV2', () => { describe('handle', () => { describe('notifications', () => { - it('passes the notification through middleware', async () => { - const middleware: JsonRpcMiddleware = - jest.fn(() => undefined); + it('passes the notification through a middleware', async () => { + const middleware: JsonRpcMiddleware = jest.fn(); const engine = new JsonRpcEngineV2({ - middleware: makeMockMiddleware(middleware), + middleware: [middleware], }); const notification = { jsonrpc, method: 'test_request' }; await engine.handle(notification); expect(middleware).toHaveBeenCalledTimes(1); - expect(middleware).toHaveBeenCalledWith(notification, expect.any(Map)); + expect(middleware).toHaveBeenCalledWith({ + request: notification, + context: expect.any(Map), + next: expect.any(Function), + }); }); it('returns no result', async () => { const engine = new JsonRpcEngineV2({ - middleware: [jest.fn(() => EndNotification)], + middleware: [jest.fn()], }); const notification = { jsonrpc, method: 'test_request' }; @@ -79,17 +54,18 @@ describe('JsonRpcEngineV2', () => { }); it('returns no result, with multiple middleware', async () => { + const middleware1 = jest.fn(({ next }) => next()); + const middleware2 = jest.fn(); const engine = new JsonRpcEngineV2({ - middleware: makeMockMiddleware( - jest.fn(), - jest.fn(() => EndNotification), - ), + middleware: [middleware1, middleware2], }); const notification = { jsonrpc, method: 'test_request' }; const result = await engine.handle(notification); expect(result).toBeUndefined(); + expect(middleware1).toHaveBeenCalledTimes(1); + expect(middleware2).toHaveBeenCalledTimes(1); }); it('throws if a middleware throws', async () => { @@ -109,12 +85,12 @@ describe('JsonRpcEngineV2', () => { it('throws if a middleware throws, with multiple middleware', async () => { const engine = new JsonRpcEngineV2({ - middleware: makeMockMiddleware( - jest.fn(), + middleware: [ + jest.fn(({ next }) => next()), jest.fn(() => { throw new Error('test'); }), - ), + ], }); const notification = { jsonrpc, method: 'test_request' }; @@ -123,41 +99,21 @@ describe('JsonRpcEngineV2', () => { ); }); - it('throws if no middleware returns EndNotification', async () => { - const engine = new JsonRpcEngineV2({ - middleware: [jest.fn(), jest.fn()], - }); - const notification = { jsonrpc, method: 'test_request' }; - - await expect(engine.handle(notification)).rejects.toThrow( - new JsonRpcEngineError( - `Nothing ended request: ${stringify(notification)}`, - ), - ); - }); - - it('throws if a middleware returns a return handler', async () => { + it('throws if a middleware calls next() multiple times', async () => { const engine = new JsonRpcEngineV2({ - middleware: [jest.fn(() => () => null)], - }); - const notification = { jsonrpc, method: 'test_request' }; - - await expect(engine.handle(notification)).rejects.toThrow( - new JsonRpcEngineError( - `Middleware returned a return handler for notification: ${stringify(notification)}`, - ), - ); - }); - - it('throws if a middleware returns neither EndNotification nor undefined', async () => { - const engine = new JsonRpcEngineV2({ - middleware: [jest.fn(() => null), jest.fn(() => EndNotification)], + middleware: [ + jest.fn(async ({ next }) => { + await next(); + await next(); + }), + jest.fn(), + ], }); const notification = { jsonrpc, method: 'test_request' }; await expect(engine.handle(notification)).rejects.toThrow( new JsonRpcEngineError( - `Notification handled as request: ${stringify(notification)}`, + `Middleware attempted to call next() multiple times for request: ${stringify(notification)}`, ), ); }); @@ -169,7 +125,7 @@ describe('JsonRpcEngineV2', () => { () => null, ); const engine = new JsonRpcEngineV2({ - middleware: makeMockMiddleware(middleware), + middleware: [middleware], }); const request = makeRequest(); @@ -177,14 +133,18 @@ describe('JsonRpcEngineV2', () => { expect(result).toBeNull(); expect(middleware).toHaveBeenCalledTimes(1); - expect(middleware).toHaveBeenCalledWith(request, expect.any(Map)); + expect(middleware).toHaveBeenCalledWith({ + request, + context: expect.any(Map), + next: expect.any(Function), + }); }); it('returns a result from the middleware, with multiple middleware', async () => { - const middleware1 = jest.fn(); + const middleware1 = jest.fn(({ next }) => next()); const middleware2 = jest.fn(() => null); const engine = new JsonRpcEngineV2({ - middleware: makeMockMiddleware(middleware1, middleware2), + middleware: [middleware1, middleware2], }); const request = makeRequest(); @@ -192,9 +152,17 @@ describe('JsonRpcEngineV2', () => { expect(result).toBeNull(); expect(middleware1).toHaveBeenCalledTimes(1); - expect(middleware1).toHaveBeenCalledWith(request, expect.any(Map)); + expect(middleware1).toHaveBeenCalledWith({ + request, + context: expect.any(Map), + next: expect.any(Function), + }); expect(middleware2).toHaveBeenCalledTimes(1); - expect(middleware2).toHaveBeenCalledWith(request, expect.any(Map)); + expect(middleware2).toHaveBeenCalledWith({ + request, + context: expect.any(Map), + next: expect.any(Function), + }); }); it('throws if a middleware throws', async () => { @@ -213,12 +181,12 @@ describe('JsonRpcEngineV2', () => { it('throws if a middleware throws, with multiple middleware', async () => { const engine = new JsonRpcEngineV2({ - middleware: makeMockMiddleware( - jest.fn(), + middleware: [ + jest.fn(({ next }) => next()), jest.fn(() => { throw new Error('test'); }), - ), + ], }); await expect(engine.handle(makeRequest())).rejects.toThrow( @@ -228,7 +196,7 @@ describe('JsonRpcEngineV2', () => { it('throws if no middleware returns a result', async () => { const engine = new JsonRpcEngineV2({ - middleware: [jest.fn(), jest.fn()], + middleware: [jest.fn(({ next }) => next()), jest.fn()], }); const request = makeRequest(); @@ -239,305 +207,295 @@ describe('JsonRpcEngineV2', () => { ); }); - it('throws if a middleware returns EndNotification', async () => { + it('throws if a middleware calls next() multiple times', async () => { const engine = new JsonRpcEngineV2({ - middleware: [jest.fn(() => EndNotification)], + middleware: [ + jest.fn(async ({ next }) => { + await next(); + await next(); + }), + jest.fn(), + ], }); const request = makeRequest(); await expect(engine.handle(request)).rejects.toThrow( new JsonRpcEngineError( - `Request handled as notification: ${stringify(request)}`, + `Middleware attempted to call next() multiple times for request: ${stringify(request)}`, ), ); }); }); - describe('request mutation', () => { - it('lets middleware mutate request parameters in place', async () => { - const observedParams: string[] = []; - const middleware1 = jest.fn((req) => { - observedParams.push(req.params[0]); - req.params[0] = '2'; - }); - const middleware2 = jest.fn((req) => { - observedParams.push(req.params[0]); - req.params[0] = '3'; - }); - const middleware3 = jest.fn((req) => { - observedParams.push(req.params[0]); - return null; - }); + describe('asynchrony', () => { + it('handles asynchronous middleware', async () => { + const middleware = jest.fn(async () => null); const engine = new JsonRpcEngineV2({ - middleware: makeMockMiddleware(middleware1, middleware2, middleware3), + middleware: [middleware], }); - const request = makeRequest({ params: ['1'] }); - await engine.handle(request); + const result = await engine.handle(makeRequest()); - expect(middleware1).toHaveBeenCalledTimes(1); - expect(middleware2).toHaveBeenCalledTimes(1); - expect(middleware3).toHaveBeenCalledTimes(1); - expect(observedParams).toStrictEqual(['1', '2', '3']); + expect(result).toBeNull(); }); - it('lets middleware replace request parameters', async () => { - const observedParams: string[] = []; - const middleware1 = jest.fn((req) => { - observedParams.push(cloneRequest(req).params); - req.params = ['2']; - }); - const middleware2 = jest.fn((req) => { - observedParams.push(cloneRequest(req).params); - req.params = ['3']; - }); - const middleware3 = jest.fn((req) => { - observedParams.push(cloneRequest(req).params); - return null; - }); + it('handles mixed synchronous and asynchronous middleware', async () => { + const middleware1: JsonRpcMiddleware> = jest.fn( + async ({ context, next }) => { + context.set('foo', [1]); + return next(); + }, + ); + const middleware2: JsonRpcMiddleware> = jest.fn( + async ({ context, next }) => { + const nums = context.get('foo') as number[]; + context.set('foo', [...nums, 2]); + return next(); + }, + ); + const middleware3: JsonRpcMiddleware> = jest.fn( + async ({ context }) => { + const nums = context.get('foo') as number[]; + return [...nums, 3]; + }, + ); const engine = new JsonRpcEngineV2({ middleware: [middleware1, middleware2, middleware3], }); - const request = makeRequest({ params: ['1'] }); - await engine.handle(request); + const result = await engine.handle(makeRequest()); - expect(middleware1).toHaveBeenCalledTimes(1); - expect(middleware2).toHaveBeenCalledTimes(1); - expect(middleware3).toHaveBeenCalledTimes(1); - expect(observedParams).toStrictEqual([['1'], ['2'], ['3']]); + expect(result).toStrictEqual([1, 2, 3]); }); + }); - it('lets middleware replace the request method', async () => { + describe('request mutation', () => { + it('propagates new requests to subsequent middleware', async () => { + const observedParams: number[] = []; let observedMethod: string | undefined; - const middleware1 = jest.fn((req) => { - req.method = 'test_request_2'; - }); - const middleware2 = jest.fn((req) => { - observedMethod = req.method; + const middleware1 = jest.fn(({ request, next }) => { + observedParams.push(request.params[0]); + return next({ + ...request, + params: [2], + }); + }); + const middleware2 = jest.fn(({ request, next }) => { + observedParams.push(request.params[0]); + return next({ + ...request, + method: 'test_request_2', + params: [3], + }); + }); + const middleware3 = jest.fn(({ request }) => { + observedParams.push(request.params[0]); + observedMethod = request.method; return null; }); const engine = new JsonRpcEngineV2({ - middleware: [middleware1, middleware2], + middleware: [middleware1, middleware2, middleware3], }); + const request = makeRequest({ params: [1] }); - await engine.handle(makeRequest()); + await engine.handle(request); expect(middleware1).toHaveBeenCalledTimes(1); expect(middleware2).toHaveBeenCalledTimes(1); + expect(middleware3).toHaveBeenCalledTimes(1); expect(observedMethod).toBe('test_request_2'); + expect(observedParams).toStrictEqual([1, 2, 3]); }); - it('throws if a middleware attempts to modify the request "id" property', async () => { + it('throws if directly modifying the request', async () => { const engine = new JsonRpcEngineV2({ middleware: [ - jest.fn((req) => { - req.id = '2'; - }), + jest.fn(({ request }) => { + // @ts-expect-error Destructive testing. + request.params = [2]; + }) as JsonRpcMiddleware, ], }); - const request = makeRequest(); - await expect(engine.handle(request)).rejects.toThrow( - new JsonRpcEngineError( - `Middleware attempted to modify readonly property "id" for request: ${stringify(request)}`, + await expect(engine.handle(makeRequest())).rejects.toThrow( + new TypeError( + `Cannot assign to read only property 'params' of object '#'`, ), ); }); - it('throws if a middleware attempts to modify the request "jsonrpc" property', async () => { + it('throws if a middleware attempts to modify the request "id" property', async () => { const engine = new JsonRpcEngineV2({ middleware: [ - jest.fn((req) => { - req.jsonrpc = '3.0'; + jest.fn(async ({ request, next }) => { + return await next({ + ...request, + id: '2', + }); }), + jest.fn(() => null), ], }); const request = makeRequest(); await expect(engine.handle(request)).rejects.toThrow( new JsonRpcEngineError( - `Middleware attempted to modify readonly property "jsonrpc" for request: ${stringify(request)}`, + `Middleware attempted to modify readonly property "id" for request: ${stringify(request)}`, ), ); }); - it('throws if modifying the request outside of the middleware', async () => { - let retained: JsonRpcCall | undefined; - const middleware = jest.fn((req: JsonRpcCall) => { - retained = req; - return null; - }); + it('throws if a middleware attempts to modify the request "jsonrpc" property', async () => { const engine = new JsonRpcEngineV2({ - middleware: [middleware], + middleware: [ + jest.fn(async ({ request, next }) => { + return await next({ + ...request, + jsonrpc: '3.0', + }); + }), + jest.fn(() => null), + ], }); const request = makeRequest(); - await engine.handle(request); - - expect(() => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - retained!.params = ['2']; - }).toThrow( - new TypeError( - `Cannot perform 'set' on a proxy that has been revoked`, - ), - ); - }); - }); - - describe('return handlers', () => { - it('runs return handlers in reverse order of registration', async () => { - const returnHandlerResults: string[] = []; - const middleware1 = jest.fn(() => () => { - returnHandlerResults.push('1'); - }); - const middleware2 = jest.fn(() => () => { - returnHandlerResults.push('2'); - }); - const middleware3 = jest.fn(() => () => { - returnHandlerResults.push('3'); - }); - const middleware4 = jest.fn(() => null); - const engine = new JsonRpcEngineV2({ - middleware: [middleware1, middleware2, middleware3, middleware4], - }); - - await engine.handle(makeRequest()); - - expect(returnHandlerResults).toStrictEqual(['3', '2', '1']); - }); - - it('returns the expected result after no-op return handler', async () => { - const middleware1 = jest.fn(() => () => undefined); - const middleware2 = jest.fn(() => null); - const engine = new JsonRpcEngineV2({ - middleware: [middleware1, middleware2], - }); - - const result = await engine.handle(makeRequest()); - - expect(result).toBeNull(); - }); - - it('lets return handler update the result', async () => { - const middleware1 = jest.fn(() => () => { - return '1'; - }); - const middleware2 = jest.fn(() => null); - const engine = new JsonRpcEngineV2({ - middleware: [middleware1, middleware2], - }); - - const result = await engine.handle(makeRequest()); - - expect(result).toBe('1'); - }); - - it('uses the result of the first return handler registered', async () => { - const middleware1 = jest.fn(() => () => { - return '1' as string; - }); - const middleware2 = jest.fn(() => () => { - return '2' as string; - }); - const middleware3 = jest.fn(() => null); - const engine = new JsonRpcEngineV2({ - middleware: [middleware1, middleware2, middleware3], - }); - - const result = await engine.handle(makeRequest()); - - expect(result).toBe('1'); - }); - - it('throws if a return handler modifies the request', async () => { - const middleware1 = jest.fn((req) => () => { - req.params = ['2']; - return '1' as string; - }); - const middleware2 = jest.fn(() => null); - const engine = new JsonRpcEngineV2({ - middleware: [middleware1, middleware2], - }); - - await expect(engine.handle(makeRequest())).rejects.toThrow( - new TypeError( - `Cannot perform 'set' on a proxy that has been revoked`, + await expect(engine.handle(request)).rejects.toThrow( + new JsonRpcEngineError( + `Middleware attempted to modify readonly property "jsonrpc" for request: ${stringify(request)}`, ), ); }); }); - describe('asynchrony', () => { - it('handles asynchronous middleware', async () => { - const middleware = jest.fn(async () => { - return null; - }); + describe('result handling', () => { + it('updates the result after next() is called', async () => { const engine = new JsonRpcEngineV2({ - middleware: [middleware], - }); - - const result = await engine.handle(makeRequest()); - - expect(result).toBeNull(); - }); - - it('handles mixed synchronous and asynchronous middleware', async () => { - const middleware1 = jest.fn((_req, context) => { - context.set('foo', [1]); - }); - const middleware2 = jest.fn(async (_req, context) => { - const nums = context.get('foo') as number[]; - context.set('foo', [...nums, 2]); - }); - const middleware3 = jest.fn(async (_req, context) => { - const nums = context.get('foo') as number[]; - return [...nums, 3]; - }); - const engine = new JsonRpcEngineV2({ - middleware: [middleware1, middleware2, middleware3], + middleware: [ + jest.fn(async ({ next }) => { + const result = await next(); + return result + 1; + }), + jest.fn(() => 1), + ], }); const result = await engine.handle(makeRequest()); - expect(result).toStrictEqual([1, 2, 3]); + expect(result).toBe(2); }); - it('handles asynchronous return handlers', async () => { - const middleware = jest.fn(() => async () => { - return null; - }); + it('catches errors thrown by later middleware', async () => { + let observedError: Error | undefined; const engine = new JsonRpcEngineV2({ - middleware: [middleware], + middleware: [ + jest.fn(async ({ next }) => { + try { + return await next(); + } catch (error) { + observedError = error as Error; + return null; + } + }), + jest.fn(() => { + throw new Error('test'); + }), + ], }); const result = await engine.handle(makeRequest()); expect(result).toBeNull(); - }); - - it('handles mixed synchronous and asynchronous return handlers', async () => { - const middleware1 = jest.fn(() => (result: number | void) => { - // eslint-disable-next-line jest/no-conditional-in-test - return (result ?? 0) * 2; - }); - const middleware2 = jest.fn(() => async () => { - return 2; - }); - const engine = new JsonRpcEngineV2({ - middleware: [middleware1, middleware2], - }); - - const result = await engine.handle(makeRequest()); - - expect(result).toBe(4); - }); + expect(observedError).toStrictEqual(new Error('test')); + }); + + // it('handles returned results in reverse middleware order', async () => { + // const returnHandlerResults: number[] = []; + // const middleware1 = jest.fn(async ({ next }) => { + // await next(); + // returnHandlerResults.push(1); + // }); + // const middleware2 = jest.fn(async ({ next }) => { + // await next(); + // returnHandlerResults.push(2); + // }); + // const middleware3 = jest.fn(async ({ next }) => { + // await next(); + // returnHandlerResults.push(3); + // }); + // const middleware4 = jest.fn(() => null); + // const engine = new JsonRpcEngineV2({ + // middleware: [middleware1, middleware2, middleware3, middleware4], + // }); + + // await engine.handle(makeRequest()); + + // expect(returnHandlerResults).toStrictEqual([3, 2, 1]); + // }); + + // it('returns the expected result after no-op return handler', async () => { + // const middleware1 = jest.fn(() => () => undefined); + // const middleware2 = jest.fn(() => null); + // const engine = new JsonRpcEngineV2({ + // middleware: [middleware1, middleware2], + // }); + + // const result = await engine.handle(makeRequest()); + + // expect(result).toBeNull(); + // }); + + // it('lets return handler update the result', async () => { + // const middleware1 = jest.fn(() => () => { + // return '1'; + // }); + // const middleware2 = jest.fn(() => null); + // const engine = new JsonRpcEngineV2({ + // middleware: [middleware1, middleware2], + // }); + + // const result = await engine.handle(makeRequest()); + + // expect(result).toBe('1'); + // }); + + // it('uses the result of the first return handler registered', async () => { + // const middleware1 = jest.fn(() => () => { + // return '1' as string; + // }); + // const middleware2 = jest.fn(() => () => { + // return '2' as string; + // }); + // const middleware3 = jest.fn(() => null); + // const engine = new JsonRpcEngineV2({ + // middleware: [middleware1, middleware2, middleware3], + // }); + + // const result = await engine.handle(makeRequest()); + + // expect(result).toBe('1'); + // }); + + // it('throws if a return handler modifies the request', async () => { + // const middleware1 = jest.fn((req) => () => { + // req.params = ['2']; + // return '1' as string; + // }); + // const middleware2 = jest.fn(() => null); + // const engine = new JsonRpcEngineV2({ + // middleware: [middleware1, middleware2], + // }); + + // await expect(engine.handle(makeRequest())).rejects.toThrow( + // new TypeError( + // `Cannot perform 'set' on a proxy that has been revoked`, + // ), + // ); + // }); }); describe('context', () => { it('passes the context to the middleware', async () => { - const middleware = jest.fn((_req, context) => { + const middleware = jest.fn(({ context }) => { expect(context).toBeInstanceOf(Map); return null; }); @@ -549,43 +507,12 @@ describe('JsonRpcEngineV2', () => { }); it('propagates context changes to subsequent middleware', async () => { - const middleware1 = jest.fn((_req, context) => { + const middleware1 = jest.fn(async ({ context, next }) => { context.set('foo', 'bar'); + return next(); }); - const middleware2 = jest.fn((_req, context) => { - return context.get('foo') as string; - }); - const engine = new JsonRpcEngineV2({ - middleware: [middleware1, middleware2], - }); - - const result = await engine.handle(makeRequest()); - - expect(result).toBe('bar'); - }); - - it('propagates context changes from middleware to return handlers', async () => { - const middleware1 = jest.fn((_req, context) => () => { - return context.get('foo') as string; - }); - const middleware2 = jest.fn((_req, context) => { - context.set('foo', 'bar'); - }); - const engine = new JsonRpcEngineV2({ - middleware: [middleware1, middleware2], - }); - - const result = await engine.handle(makeRequest()); - - expect(result).toBe('bar'); - }); - - it('propagates context changes from return handlers to return handlers', async () => { - const middleware1 = jest.fn((_req, context) => () => { - return context.get('foo') as string; - }); - const middleware2 = jest.fn((_req, context) => () => { - context.set('foo', 'bar'); + const middleware2 = jest.fn(({ context }) => { + return context.get('foo') as string | undefined; }); const engine = new JsonRpcEngineV2({ middleware: [middleware1, middleware2], @@ -615,12 +542,13 @@ describe('JsonRpcEngineV2', () => { }); describe('asMiddleware', () => { - it('returns a middleware function', async () => { - const engine1 = new JsonRpcEngineV2({ + it('ends a request if it returns a value', async () => { + // TODO: We may have to do a lot of these casts? + const engine1 = new JsonRpcEngineV2({ middleware: [() => null], }); const engine2 = new JsonRpcEngineV2({ - middleware: [engine1.asMiddleware()], + middleware: [engine1.asMiddleware(), jest.fn(() => 'foo')], }); const result = await engine2.handle(makeRequest()); @@ -642,8 +570,8 @@ describe('JsonRpcEngineV2', () => { }); it('composes nested engines', async () => { - const middleware1 = jest.fn(() => undefined); - const middleware2 = jest.fn(() => undefined); + const middleware1 = jest.fn(async ({ next }) => next()); + const middleware2 = jest.fn(async ({ next }) => next()); const engine1 = new JsonRpcEngineV2({ middleware: [middleware1], }); @@ -662,15 +590,21 @@ describe('JsonRpcEngineV2', () => { }); it('propagates request mutation', async () => { - const engine1 = new JsonRpcEngineV2({ + const engine1 = new JsonRpcEngineV2({ middleware: [ - (req) => { - req.params = [2]; + ({ request, next }) => { + return next({ + ...request, + params: [2], + }); }, - (req) => { - req.method = 'test_request_2'; - // @ts-expect-error Will obviously work. - req.params[0] *= 2; + ({ request, next }) => { + return next({ + ...request, + method: 'test_request_2', + // @ts-expect-error Will obviously work. + params: [request.params[0] * 2], + }); }, ], }); @@ -679,10 +613,10 @@ describe('JsonRpcEngineV2', () => { const engine2 = new JsonRpcEngineV2({ middleware: [ engine1.asMiddleware(), - (req) => { - observedMethod = req.method; + ({ request }) => { + observedMethod = request.method; // @ts-expect-error Will obviously work. - return req.params[0] * 2; + return request.params[0] * 2; }, ], }); @@ -693,52 +627,25 @@ describe('JsonRpcEngineV2', () => { expect(observedMethod).toBe('test_request_2'); }); - it('propagates request mutation (parameter deletion)', async () => { - const engine1 = new JsonRpcEngineV2({ - middleware: [ - (req) => { - delete req.params; - }, - ], - }); - - let observedRequest: unknown; - const engine2 = new JsonRpcEngineV2({ - middleware: [ - engine1.asMiddleware(), - (req) => { - observedRequest = cloneRequest(req); - return null; - }, - ], - }); - - const result = await engine2.handle(makeRequest()); - - expect(result).toBeNull(); - expect(observedRequest).toStrictEqual({ - jsonrpc: '2.0', - id: '1', - method: 'test_request', - }); - }); - it('propagates context changes', async () => { const engine1 = new JsonRpcEngineV2({ middleware: [ - (_req, context) => { + async ({ context, next }) => { const num = context.get('foo') as number; context.set('foo', num * 2); + return next(); }, ], }); + const engine2 = new JsonRpcEngineV2({ middleware: [ - (_req, context) => { + async ({ context, next }) => { context.set('foo', 2); + return next(); }, engine1.asMiddleware(), - (_req, context) => { + async ({ context }) => { return (context.get('foo') as number) * 2; }, ], @@ -749,37 +656,37 @@ describe('JsonRpcEngineV2', () => { expect(result).toBe(8); }); - it('runs return handlers in expected order', async () => { - const returnHandlerResults: string[] = []; - const engine1 = new JsonRpcEngineV2({ - middleware: [ - () => () => { - returnHandlerResults.push('1:a'); - }, - () => () => { - returnHandlerResults.push('1:b'); - }, - ], - }); - - const engine2 = new JsonRpcEngineV2({ - middleware: [ - engine1.asMiddleware(), - () => () => { - returnHandlerResults.push('2:a'); - }, - () => () => { - returnHandlerResults.push('2:b'); - }, - () => null, - ], - }); - - await engine2.handle(makeRequest()); - - // Order of return handlers is reversed _within_ engines, but not - // _between_ engines. - expect(returnHandlerResults).toStrictEqual(['1:b', '1:a', '2:b', '2:a']); - }); + // it('runs return handlers in expected order', async () => { + // const returnHandlerResults: string[] = []; + // const engine1 = new JsonRpcEngineV2({ + // middleware: [ + // () => () => { + // returnHandlerResults.push('1:a'); + // }, + // () => () => { + // returnHandlerResults.push('1:b'); + // }, + // ], + // }); + + // const engine2 = new JsonRpcEngineV2({ + // middleware: [ + // engine1.asMiddleware(), + // () => () => { + // returnHandlerResults.push('2:a'); + // }, + // () => () => { + // returnHandlerResults.push('2:b'); + // }, + // () => null, + // ], + // }); + + // await engine2.handle(makeRequest()); + + // // Order of return handlers is reversed _within_ engines, but not + // // _between_ engines. + // expect(returnHandlerResults).toStrictEqual(['1:b', '1:a', '2:b', '2:a']); + // }); }); }); diff --git a/packages/json-rpc-engine/src/JsonRpcEngineV2.ts b/packages/json-rpc-engine/src/JsonRpcEngineV2.ts index a522cbb7a3..8e83582e35 100644 --- a/packages/json-rpc-engine/src/JsonRpcEngineV2.ts +++ b/packages/json-rpc-engine/src/JsonRpcEngineV2.ts @@ -4,7 +4,7 @@ import type { JsonRpcNotification, NonEmptyArray, } from '@metamask/utils'; -import { freeze, produce } from 'immer'; +import deepFreeze from 'deep-freeze-strict'; import { makeMiddlewareContext, @@ -18,26 +18,28 @@ import { } from './utils'; import type { JsonRpcCall } from './utils'; -export type Next = () => Promise; +export type Next = ( + request?: Readonly, +) => Promise; export type MiddlewareParams< Request extends JsonRpcCall, Result extends Json | void, > = { - request: Request; + request: Readonly; context: MiddlewareContext; - next: Next; + next: Next; }; export type JsonRpcMiddleware< - Request extends JsonRpcCall, - Result extends Json | void, + Request extends JsonRpcCall = JsonRpcCall, + Result extends Json | void = Json | void, > = ( params: MiddlewareParams, ) => Result | void | Promise; type Options = { - middleware: NonEmptyArray>; + middleware: NonEmptyArray>; }; /** @@ -50,12 +52,12 @@ type Options = { */ export class JsonRpcEngineV2 { readonly #middleware: Readonly< - NonEmptyArray> + NonEmptyArray> >; - #makeMiddlewareIterator(): Iterator> { - return this.#middleware[Symbol.iterator](); - } + #makeMiddlewareIterator = (): Iterator< + JsonRpcMiddleware + > => this.#middleware[Symbol.iterator](); constructor({ middleware }: Options) { this.#middleware = [...middleware]; @@ -78,7 +80,7 @@ export class JsonRpcEngineV2 { async handle(request: Request): Promise { const isReq = isRequest(request); - const { result } = await this.#handle(freeze({ ...request })); + const { result } = await this.#handle(request); if (isReq && result === undefined) { throw new JsonRpcEngineError( @@ -101,8 +103,8 @@ export class JsonRpcEngineV2 { } /** - * Handle a JSON-RPC request. Throws if a middleware or return handler - * performs an invalid operation. Permits returning an `undefined` result. + * Handle a JSON-RPC request. Throws if a middleware performs an invalid + * operation. Permits returning an `undefined` result. * * @param originalRequest - The JSON-RPC request to handle. * @param context - The context to pass to the middleware. @@ -115,50 +117,72 @@ export class JsonRpcEngineV2 { result: Result | void; finalRequest: Readonly; }> { + deepFreeze(originalRequest); + + let currentRequest = originalRequest; const middlewareIterator = this.#makeMiddlewareIterator(); - const firstMiddleware: JsonRpcMiddleware = + const firstMiddleware: JsonRpcMiddleware = middlewareIterator.next().value; - let request = originalRequest; + const makeNext = (): Next => { + let wasCalled = false; - const next: Next = async (): Promise => { - const { value: middleware, done } = middlewareIterator.next(); - // Unavoidable forward reference - // eslint-disable-next-line @typescript-eslint/no-use-before-define - return done ? undefined : await runMiddleware(middleware); - }; + const next = async ( + request: Request = currentRequest, + ): Promise => { + if (wasCalled) { + throw new JsonRpcEngineError( + `Middleware attempted to call next() multiple times for request: ${stringify(request)}`, + ); + } + wasCalled = true; - const runMiddleware = async ( - middleware: JsonRpcMiddleware, - ): Promise => { - let nextResult: Result | void; - request = await updateRequest(request, async (draft) => { - nextResult = await middleware({ - request: draft, - context, - next, - }); - }); - - // @ts-expect-error - We happen to know that updateRequest awaits the - // callback, so we know that nextResult is not undefined. - return nextResult; + if (request !== currentRequest) { + this.#assertNextRequestValid(currentRequest, request); + currentRequest = deepFreeze(request); + } + + const { value: middleware, done } = middlewareIterator.next(); + return done + ? undefined + : await middleware({ request, context, next: makeNext() }); + }; + return next; }; - const result = await runMiddleware(firstMiddleware); + const result = await firstMiddleware({ + request: originalRequest, + context, + next: makeNext(), + }); - if (isNotification(request) && result !== undefined) { + if (isNotification(currentRequest) && result !== undefined) { throw new JsonRpcEngineError( - `Result returned for notification: ${stringify(request)}`, + `Result returned for notification: ${stringify(currentRequest)}`, ); } return { result, - finalRequest: request, + finalRequest: currentRequest, }; } + #assertNextRequestValid(currentRequest: Request, nextRequest: Request): void { + if (nextRequest.jsonrpc !== currentRequest.jsonrpc) { + throw new JsonRpcEngineError( + `Middleware attempted to modify readonly property "jsonrpc" for request: ${stringify(currentRequest)}`, + ); + } + // @ts-expect-error - "id" does not exist on notifications, but this will + // produce the desired behavior at runtime. + if (nextRequest.id !== currentRequest.id) { + throw new JsonRpcEngineError( + `Middleware attempted to modify readonly property "id" for request: ${stringify(currentRequest)}`, + ); + } + } + /** * Convert the engine into a JSON-RPC middleware. * @@ -167,51 +191,7 @@ export class JsonRpcEngineV2 { asMiddleware(): JsonRpcMiddleware { return async ({ request, context, next }) => { const { result, finalRequest } = await this.#handle(request, context); - - // Propagate any changes to the request to the original request. - request.method = finalRequest.method; - if ('params' in finalRequest) { - request.params = finalRequest.params; - } else { - delete request.params; - } - - return result === undefined ? await next() : result; + return result === undefined ? await next(finalRequest) : result; }; } } - -// Properties of a request that you're not allowed to modify. -const readonlyProps = ['id', 'jsonrpc'] as const; - -/** - * Update a request using `immer`. Middleware may update the `method` and - * `params` properties, but not the `id` or `jsonrpc` properties. The request - * object must be updated in place. - * - * @param request - The request to update. - * @param recipe - The recipe function. - * @returns The updated request. - */ -async function updateRequest( - request: Request, - recipe: (request: Request) => Promise, -): Promise { - return produce(request, async (draft) => { - const draftProxy = new Proxy(draft, { - set(target, prop, value) { - if (readonlyProps.includes(prop as (typeof readonlyProps)[number])) { - throw new JsonRpcEngineError( - `Middleware attempted to modify readonly property "${String(prop)}" for request: ${stringify(request)}`, - ); - } - return Reflect.set(target, prop, value); - }, - }); - - // The Jest parser encounters "TS2589: Type instantiation is excessively - // deep and possibly infinite." - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return recipe(draftProxy as any); - }); -} diff --git a/packages/json-rpc-engine/src/utils.test.ts b/packages/json-rpc-engine/src/utils.test.ts index 4c9c78d655..7dcc5185eb 100644 --- a/packages/json-rpc-engine/src/utils.test.ts +++ b/packages/json-rpc-engine/src/utils.test.ts @@ -1,13 +1,9 @@ -import { produce } from 'immer'; - import { isRequest, isNotification, stringify, JsonRpcEngineError, - cloneRequest, } from './utils'; -import type { JsonRpcCall } from './utils'; const jsonrpc = '2.0' as const; @@ -66,32 +62,4 @@ describe('utils', () => { expect(error.message).toBe('test'); }); }); - - describe('cloneRequest', () => { - it('should clone a request', () => { - let clonedRequest: JsonRpcCall | undefined; - - const producedRequest = produce( - { - jsonrpc, - id: 1, - method: 'eth_getBlockByNumber', - params: ['latest'], - }, - (draft) => { - clonedRequest = cloneRequest(draft); - draft.id = 2; - }, - ); - - expect(clonedRequest).toStrictEqual({ - jsonrpc, - id: 1, - method: 'eth_getBlockByNumber', - params: ['latest'], - }); - expect(producedRequest).not.toBe(clonedRequest); - expect(producedRequest.id).toBe(2); - }); - }); }); diff --git a/packages/json-rpc-engine/src/utils.ts b/packages/json-rpc-engine/src/utils.ts index dfe7d0a567..574702227a 100644 --- a/packages/json-rpc-engine/src/utils.ts +++ b/packages/json-rpc-engine/src/utils.ts @@ -5,7 +5,6 @@ import { type JsonRpcParams, type JsonRpcRequest as BaseJsonRpcRequest, } from '@metamask/utils'; -import { original as getOriginalState } from 'immer'; export type JsonRpcNotification = BaseJsonRpcNotification; @@ -41,19 +40,3 @@ export class JsonRpcEngineError extends Error { this.name = 'JsonRpcEngineError'; } } - -/** - * For cloning a request object _inside_ a middleware. - * - * **Must** be used to continue to access request data after the middleware has - * returned. This is because middleware receive an `immer` draft of the request, - * which is a proxy that becomes revoked after the middleware returns. - * - * @param request - The request to clone. - * @returns The cloned request. - */ -export function cloneRequest( - request: Request, -): Request { - return structuredClone(getOriginalState(request)) as Request; -} diff --git a/yarn.lock b/yarn.lock index 8506140ca2..53f33b380a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3596,10 +3596,11 @@ __metadata: "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/safe-event-emitter": "npm:^3.0.0" "@metamask/utils": "npm:^11.4.2" + "@types/deep-freeze-strict": "npm:^1.1.0" "@types/jest": "npm:^27.4.1" "@types/lodash": "npm:^4.14.191" + deep-freeze-strict: "npm:^1.1.1" deepmerge: "npm:^4.2.2" - immer: "npm:^9.0.6" jest: "npm:^27.5.1" jest-it-up: "npm:^2.0.2" lodash: "npm:^4.17.21" From ccbdc03d46278772a2bcbe8962fb205281bb156b Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Sat, 19 Jul 2025 10:57:55 -0700 Subject: [PATCH 21/75] test: Complete test suite --- .../src/JsonRpcEngineV2.test.ts | 316 ++++++++++-------- .../json-rpc-engine/src/JsonRpcEngineV2.ts | 47 ++- 2 files changed, 217 insertions(+), 146 deletions(-) diff --git a/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts b/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts index a550c5b723..c0309ed57b 100644 --- a/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts +++ b/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts @@ -99,6 +99,38 @@ describe('JsonRpcEngineV2', () => { ); }); + it('throws if a result is returned, from the first middleware', async () => { + const engine = new JsonRpcEngineV2({ + middleware: [jest.fn(() => 'foo')], + }); + const notification = { jsonrpc, method: 'test_request' }; + + await expect(engine.handle(notification)).rejects.toThrow( + new JsonRpcEngineError( + `Result returned for notification: ${stringify(notification)}`, + ), + ); + }); + + it('throws if a result is returned, from a later middleware', async () => { + const engine = new JsonRpcEngineV2({ + middleware: [ + jest.fn(async ({ next }) => { + await next(); + return undefined; + }), + jest.fn(() => null), + ], + }); + const notification = { jsonrpc, method: 'test_request' }; + + await expect(engine.handle(notification)).rejects.toThrow( + new JsonRpcEngineError( + `Result returned for notification: ${stringify(notification)}`, + ), + ); + }); + it('throws if a middleware calls next() multiple times', async () => { const engine = new JsonRpcEngineV2({ middleware: [ @@ -227,6 +259,51 @@ describe('JsonRpcEngineV2', () => { }); }); + describe('context', () => { + it('passes the context to the middleware', async () => { + const middleware = jest.fn(({ context }) => { + expect(context).toBeInstanceOf(Map); + return null; + }); + const engine = new JsonRpcEngineV2({ + middleware: [middleware], + }); + + await engine.handle(makeRequest()); + }); + + it('propagates context changes to subsequent middleware', async () => { + const middleware1 = jest.fn(async ({ context, next }) => { + context.set('foo', 'bar'); + return next(); + }); + const middleware2 = jest.fn(({ context }) => { + return context.get('foo') as string | undefined; + }); + const engine = new JsonRpcEngineV2({ + middleware: [middleware1, middleware2], + }); + + const result = await engine.handle(makeRequest()); + + expect(result).toBe('bar'); + }); + + it('throws if a middleware attempts to modify properties of the context', async () => { + const engine = new JsonRpcEngineV2({ + middleware: [ + jest.fn(({ context }) => { + context.set = () => undefined; + }), + ], + }); + + await expect(engine.handle(makeRequest())).rejects.toThrow( + new TypeError(`Cannot add property set, object is not extensible`), + ); + }); + }); + describe('asynchrony', () => { it('handles asynchronous middleware', async () => { const middleware = jest.fn(async () => null); @@ -247,7 +324,7 @@ describe('JsonRpcEngineV2', () => { }, ); const middleware2: JsonRpcMiddleware> = jest.fn( - async ({ context, next }) => { + ({ context, next }) => { const nums = context.get('foo') as number[]; context.set('foo', [...nums, 2]); return next(); @@ -384,6 +461,22 @@ describe('JsonRpcEngineV2', () => { expect(result).toBe(2); }); + it('updates an undefined result with a new value', async () => { + const engine = new JsonRpcEngineV2({ + middleware: [ + jest.fn(async ({ next }) => { + await next(); + return null; + }), + jest.fn(() => undefined), + ], + }); + + const result = await engine.handle(makeRequest()); + + expect(result).toBeNull(); + }); + it('catches errors thrown by later middleware', async () => { let observedError: Error | undefined; const engine = new JsonRpcEngineV2({ @@ -408,119 +501,68 @@ describe('JsonRpcEngineV2', () => { expect(observedError).toStrictEqual(new Error('test')); }); - // it('handles returned results in reverse middleware order', async () => { - // const returnHandlerResults: number[] = []; - // const middleware1 = jest.fn(async ({ next }) => { - // await next(); - // returnHandlerResults.push(1); - // }); - // const middleware2 = jest.fn(async ({ next }) => { - // await next(); - // returnHandlerResults.push(2); - // }); - // const middleware3 = jest.fn(async ({ next }) => { - // await next(); - // returnHandlerResults.push(3); - // }); - // const middleware4 = jest.fn(() => null); - // const engine = new JsonRpcEngineV2({ - // middleware: [middleware1, middleware2, middleware3, middleware4], - // }); - - // await engine.handle(makeRequest()); - - // expect(returnHandlerResults).toStrictEqual([3, 2, 1]); - // }); - - // it('returns the expected result after no-op return handler', async () => { - // const middleware1 = jest.fn(() => () => undefined); - // const middleware2 = jest.fn(() => null); - // const engine = new JsonRpcEngineV2({ - // middleware: [middleware1, middleware2], - // }); - - // const result = await engine.handle(makeRequest()); - - // expect(result).toBeNull(); - // }); - - // it('lets return handler update the result', async () => { - // const middleware1 = jest.fn(() => () => { - // return '1'; - // }); - // const middleware2 = jest.fn(() => null); - // const engine = new JsonRpcEngineV2({ - // middleware: [middleware1, middleware2], - // }); - - // const result = await engine.handle(makeRequest()); - - // expect(result).toBe('1'); - // }); - - // it('uses the result of the first return handler registered', async () => { - // const middleware1 = jest.fn(() => () => { - // return '1' as string; - // }); - // const middleware2 = jest.fn(() => () => { - // return '2' as string; - // }); - // const middleware3 = jest.fn(() => null); - // const engine = new JsonRpcEngineV2({ - // middleware: [middleware1, middleware2, middleware3], - // }); - - // const result = await engine.handle(makeRequest()); - - // expect(result).toBe('1'); - // }); - - // it('throws if a return handler modifies the request', async () => { - // const middleware1 = jest.fn((req) => () => { - // req.params = ['2']; - // return '1' as string; - // }); - // const middleware2 = jest.fn(() => null); - // const engine = new JsonRpcEngineV2({ - // middleware: [middleware1, middleware2], - // }); - - // await expect(engine.handle(makeRequest())).rejects.toThrow( - // new TypeError( - // `Cannot perform 'set' on a proxy that has been revoked`, - // ), - // ); - // }); - }); - - describe('context', () => { - it('passes the context to the middleware', async () => { - const middleware = jest.fn(({ context }) => { - expect(context).toBeInstanceOf(Map); - return null; + it('handles returned results in reverse middleware order', async () => { + const returnHandlerResults: number[] = []; + const middleware1 = jest.fn(async ({ next }) => { + const result = await next(); + returnHandlerResults.push(1); + return result; + }); + const middleware2 = jest.fn(async ({ next }) => { + const result = await next(); + returnHandlerResults.push(2); + return result; }); + const middleware3 = jest.fn(async ({ next }) => { + const result = await next(); + returnHandlerResults.push(3); + return result; + }); + const middleware4 = jest.fn(() => null); const engine = new JsonRpcEngineV2({ - middleware: [middleware], + middleware: [middleware1, middleware2, middleware3, middleware4], }); await engine.handle(makeRequest()); + + expect(returnHandlerResults).toStrictEqual([3, 2, 1]); }); - it('propagates context changes to subsequent middleware', async () => { - const middleware1 = jest.fn(async ({ context, next }) => { - context.set('foo', 'bar'); - return next(); + it('throws if ultimately returning undefined', async () => { + const engine = new JsonRpcEngineV2({ + middleware: [ + jest.fn(async ({ next }) => { + // If you forget to return the result from next(), you are setting + // the result to undefined. + await next(); + }), + jest.fn(() => null), + ], }); - const middleware2 = jest.fn(({ context }) => { - return context.get('foo') as string | undefined; + + await expect(engine.handle(makeRequest())).rejects.toThrow( + new JsonRpcEngineError( + `Nothing ended request: ${stringify(makeRequest())}`, + ), + ); + }); + + it('throws if directly modifying the result', async () => { + const middleware1 = jest.fn(async ({ next }) => { + const result = await next(); + result.foo = 'baz'; + return result; }); + const middleware2 = jest.fn(() => ({ foo: 'bar' })); const engine = new JsonRpcEngineV2({ middleware: [middleware1, middleware2], }); - const result = await engine.handle(makeRequest()); - - expect(result).toBe('bar'); + await expect(engine.handle(makeRequest())).rejects.toThrow( + new TypeError( + `Cannot assign to read only property 'foo' of object '#'`, + ), + ); }); }); }); @@ -656,37 +698,45 @@ describe('JsonRpcEngineV2', () => { expect(result).toBe(8); }); - // it('runs return handlers in expected order', async () => { - // const returnHandlerResults: string[] = []; - // const engine1 = new JsonRpcEngineV2({ - // middleware: [ - // () => () => { - // returnHandlerResults.push('1:a'); - // }, - // () => () => { - // returnHandlerResults.push('1:b'); - // }, - // ], - // }); - - // const engine2 = new JsonRpcEngineV2({ - // middleware: [ - // engine1.asMiddleware(), - // () => () => { - // returnHandlerResults.push('2:a'); - // }, - // () => () => { - // returnHandlerResults.push('2:b'); - // }, - // () => null, - // ], - // }); - - // await engine2.handle(makeRequest()); - - // // Order of return handlers is reversed _within_ engines, but not - // // _between_ engines. - // expect(returnHandlerResults).toStrictEqual(['1:b', '1:a', '2:b', '2:a']); - // }); + it('runs return handlers in expected order', async () => { + const returnHandlerResults: string[] = []; + const engine1 = new JsonRpcEngineV2({ + middleware: [ + async ({ next }) => { + const result = await next(); + returnHandlerResults.push('1:a'); + return result; + }, + async ({ next }) => { + const result = await next(); + returnHandlerResults.push('1:b'); + return result; + }, + ], + }); + + const engine2 = new JsonRpcEngineV2({ + middleware: [ + engine1.asMiddleware(), + async ({ next }) => { + const result = await next(); + returnHandlerResults.push('2:a'); + return result; + }, + async ({ next }) => { + const result = await next(); + returnHandlerResults.push('2:b'); + return result; + }, + () => null, + ], + }); + + await engine2.handle(makeRequest()); + + // Order of result handling is reversed _within_ engines, but not + // _between_ engines. + expect(returnHandlerResults).toStrictEqual(['1:b', '1:a', '2:b', '2:a']); + }); }); }); diff --git a/packages/json-rpc-engine/src/JsonRpcEngineV2.ts b/packages/json-rpc-engine/src/JsonRpcEngineV2.ts index 8e83582e35..974ec23a98 100644 --- a/packages/json-rpc-engine/src/JsonRpcEngineV2.ts +++ b/packages/json-rpc-engine/src/JsonRpcEngineV2.ts @@ -20,7 +20,7 @@ import type { JsonRpcCall } from './utils'; export type Next = ( request?: Readonly, -) => Promise; +) => Promise | void>; export type MiddlewareParams< Request extends JsonRpcCall, @@ -36,7 +36,7 @@ export type JsonRpcMiddleware< Result extends Json | void = Json | void, > = ( params: MiddlewareParams, -) => Result | void | Promise; +) => Readonly | void | Promise | void>; type Options = { middleware: NonEmptyArray>; @@ -120,6 +120,8 @@ export class JsonRpcEngineV2 { deepFreeze(originalRequest); let currentRequest = originalRequest; + let currentResult: Result | void; + const isNotif = isNotification(originalRequest); const middlewareIterator = this.#makeMiddlewareIterator(); const firstMiddleware: JsonRpcMiddleware = middlewareIterator.next().value; @@ -138,14 +140,26 @@ export class JsonRpcEngineV2 { wasCalled = true; if (request !== currentRequest) { - this.#assertNextRequestValid(currentRequest, request); + this.#assertValidNextRequest(currentRequest, request); currentRequest = deepFreeze(request); } const { value: middleware, done } = middlewareIterator.next(); - return done - ? undefined - : await middleware({ request, context, next: makeNext() }); + if (done) { + return undefined; + } + + const result = await middleware({ request, context, next: makeNext() }); + this.#assertValidResult(result, currentRequest, isNotif); + + if (result !== currentResult) { + if (typeof result === 'object' && result !== null) { + deepFreeze(result); + } + currentResult = result; + } + + return result; }; return next; }; @@ -155,12 +169,7 @@ export class JsonRpcEngineV2 { context, next: makeNext(), }); - - if (isNotification(currentRequest) && result !== undefined) { - throw new JsonRpcEngineError( - `Result returned for notification: ${stringify(currentRequest)}`, - ); - } + this.#assertValidResult(result, currentRequest, isNotif); return { result, @@ -168,7 +177,19 @@ export class JsonRpcEngineV2 { }; } - #assertNextRequestValid(currentRequest: Request, nextRequest: Request): void { + #assertValidResult( + result: Result | void, + request: Request, + isNotif: boolean, + ): void { + if (isNotif && result !== undefined) { + throw new JsonRpcEngineError( + `Result returned for notification: ${stringify(request)}`, + ); + } + } + + #assertValidNextRequest(currentRequest: Request, nextRequest: Request): void { if (nextRequest.jsonrpc !== currentRequest.jsonrpc) { throw new JsonRpcEngineError( `Middleware attempted to modify readonly property "jsonrpc" for request: ${stringify(currentRequest)}`, From 6544b7c374b1cf1aef4490f8361473bc746832dc Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Sat, 19 Jul 2025 11:29:30 -0700 Subject: [PATCH 22/75] refactor: Do not require middleware to return the result after next() --- .../src/JsonRpcEngineV2.test.ts | 55 +++++++---------- .../json-rpc-engine/src/JsonRpcEngineV2.ts | 60 +++++++++++-------- 2 files changed, 57 insertions(+), 58 deletions(-) diff --git a/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts b/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts index c0309ed57b..c34478be0f 100644 --- a/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts +++ b/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts @@ -477,6 +477,21 @@ describe('JsonRpcEngineV2', () => { expect(result).toBeNull(); }); + it('returning undefined propagates previously defined result', async () => { + const engine = new JsonRpcEngineV2({ + middleware: [ + jest.fn(async ({ next }) => { + await next(); + }), + jest.fn(() => null), + ], + }); + + const result = await engine.handle(makeRequest()); + + expect(result).toBeNull(); + }); + it('catches errors thrown by later middleware', async () => { let observedError: Error | undefined; const engine = new JsonRpcEngineV2({ @@ -504,19 +519,16 @@ describe('JsonRpcEngineV2', () => { it('handles returned results in reverse middleware order', async () => { const returnHandlerResults: number[] = []; const middleware1 = jest.fn(async ({ next }) => { - const result = await next(); + await next(); returnHandlerResults.push(1); - return result; }); const middleware2 = jest.fn(async ({ next }) => { - const result = await next(); + await next(); returnHandlerResults.push(2); - return result; }); const middleware3 = jest.fn(async ({ next }) => { - const result = await next(); + await next(); returnHandlerResults.push(3); - return result; }); const middleware4 = jest.fn(() => null); const engine = new JsonRpcEngineV2({ @@ -528,25 +540,6 @@ describe('JsonRpcEngineV2', () => { expect(returnHandlerResults).toStrictEqual([3, 2, 1]); }); - it('throws if ultimately returning undefined', async () => { - const engine = new JsonRpcEngineV2({ - middleware: [ - jest.fn(async ({ next }) => { - // If you forget to return the result from next(), you are setting - // the result to undefined. - await next(); - }), - jest.fn(() => null), - ], - }); - - await expect(engine.handle(makeRequest())).rejects.toThrow( - new JsonRpcEngineError( - `Nothing ended request: ${stringify(makeRequest())}`, - ), - ); - }); - it('throws if directly modifying the result', async () => { const middleware1 = jest.fn(async ({ next }) => { const result = await next(); @@ -703,14 +696,12 @@ describe('JsonRpcEngineV2', () => { const engine1 = new JsonRpcEngineV2({ middleware: [ async ({ next }) => { - const result = await next(); + await next(); returnHandlerResults.push('1:a'); - return result; }, async ({ next }) => { - const result = await next(); + await next(); returnHandlerResults.push('1:b'); - return result; }, ], }); @@ -719,14 +710,12 @@ describe('JsonRpcEngineV2', () => { middleware: [ engine1.asMiddleware(), async ({ next }) => { - const result = await next(); + await next(); returnHandlerResults.push('2:a'); - return result; }, async ({ next }) => { - const result = await next(); + await next(); returnHandlerResults.push('2:b'); - return result; }, () => null, ], diff --git a/packages/json-rpc-engine/src/JsonRpcEngineV2.ts b/packages/json-rpc-engine/src/JsonRpcEngineV2.ts index 974ec23a98..75a092de2a 100644 --- a/packages/json-rpc-engine/src/JsonRpcEngineV2.ts +++ b/packages/json-rpc-engine/src/JsonRpcEngineV2.ts @@ -1,8 +1,9 @@ -import type { - Json, - JsonRpcRequest, - JsonRpcNotification, - NonEmptyArray, +import { + type Json, + type JsonRpcRequest, + type JsonRpcNotification, + type NonEmptyArray, + hasProperty, } from '@metamask/utils'; import deepFreeze from 'deep-freeze-strict'; @@ -120,8 +121,9 @@ export class JsonRpcEngineV2 { deepFreeze(originalRequest); let currentRequest = originalRequest; - let currentResult: Result | void; - const isNotif = isNotification(originalRequest); + // Either ESLint or TypeScript complains. + // eslint-disable-next-line no-undef-init + let currentResult: Result | void = undefined; const middlewareIterator = this.#makeMiddlewareIterator(); const firstMiddleware: JsonRpcMiddleware = middlewareIterator.next().value; @@ -150,16 +152,13 @@ export class JsonRpcEngineV2 { } const result = await middleware({ request, context, next: makeNext() }); - this.#assertValidResult(result, currentRequest, isNotif); - - if (result !== currentResult) { - if (typeof result === 'object' && result !== null) { - deepFreeze(result); - } - currentResult = result; - } + currentResult = this.#processResult( + result, + currentResult, + currentRequest, + ); - return result; + return currentResult; }; return next; }; @@ -169,24 +168,32 @@ export class JsonRpcEngineV2 { context, next: makeNext(), }); - this.#assertValidResult(result, currentRequest, isNotif); + currentResult = this.#processResult(result, currentResult, currentRequest); return { - result, + result: currentResult, finalRequest: currentRequest, }; } - #assertValidResult( + #processResult( result: Result | void, + currentResult: Result | void, request: Request, - isNotif: boolean, - ): void { - if (isNotif && result !== undefined) { + ): Result | void { + if (isNotification(request) && result !== undefined) { throw new JsonRpcEngineError( `Result returned for notification: ${stringify(request)}`, ); } + + if (result !== undefined && result !== currentResult) { + if (typeof result === 'object' && result !== null) { + deepFreeze(result); + } + return result; + } + return currentResult; } #assertValidNextRequest(currentRequest: Request, nextRequest: Request): void { @@ -195,9 +202,12 @@ export class JsonRpcEngineV2 { `Middleware attempted to modify readonly property "jsonrpc" for request: ${stringify(currentRequest)}`, ); } - // @ts-expect-error - "id" does not exist on notifications, but this will - // produce the desired behavior at runtime. - if (nextRequest.id !== currentRequest.id) { + if ( + hasProperty(nextRequest, 'id') !== hasProperty(currentRequest, 'id') || + // @ts-expect-error - "id" does not exist on notifications, but this will + // produce the desired behavior at runtime. + nextRequest.id !== currentRequest.id + ) { throw new JsonRpcEngineError( `Middleware attempted to modify readonly property "id" for request: ${stringify(currentRequest)}`, ); From 5af8c0231d128c6a96ade686940c98ae40a29d88 Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Sat, 19 Jul 2025 12:00:46 -0700 Subject: [PATCH 23/75] refactor: Export isRequest & isNotification utils --- packages/json-rpc-engine/src/index.test.ts | 2 ++ packages/json-rpc-engine/src/index.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/json-rpc-engine/src/index.test.ts b/packages/json-rpc-engine/src/index.test.ts index 7911f65a32..134173183f 100644 --- a/packages/json-rpc-engine/src/index.test.ts +++ b/packages/json-rpc-engine/src/index.test.ts @@ -5,6 +5,8 @@ describe('@metamask/json-rpc-engine', () => { expect(Object.keys(allExports)).toMatchInlineSnapshot(` Array [ "getUniqueId", + "isNotification", + "isRequest", "JsonRpcEngineError", "JsonRpcEngineV2", ] diff --git a/packages/json-rpc-engine/src/index.ts b/packages/json-rpc-engine/src/index.ts index 7c9ba07dfc..7747940a82 100644 --- a/packages/json-rpc-engine/src/index.ts +++ b/packages/json-rpc-engine/src/index.ts @@ -1,5 +1,5 @@ export { getUniqueId } from './getUniqueId'; export * from './JsonRpcEngineV2'; export type { MiddlewareContext } from './MiddlewareContext'; -export { JsonRpcEngineError } from './utils'; +export { isNotification, isRequest, JsonRpcEngineError } from './utils'; export type { JsonRpcCall, JsonRpcNotification, JsonRpcRequest } from './utils'; From 0a9e8fc69e46e0a537a44c30f47ff0950e8cecca Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Sat, 19 Jul 2025 16:42:17 -0700 Subject: [PATCH 24/75] chore: Remove unused test setup file --- packages/json-rpc-engine/jest.config.js | 2 -- packages/json-rpc-engine/package.json | 2 -- packages/json-rpc-engine/src/JsonRpcEngineV2.ts | 2 +- packages/json-rpc-engine/test/setup.ts | 9 --------- yarn.lock | 2 -- 5 files changed, 1 insertion(+), 16 deletions(-) delete mode 100644 packages/json-rpc-engine/test/setup.ts diff --git a/packages/json-rpc-engine/jest.config.js b/packages/json-rpc-engine/jest.config.js index 62e738869b..ca08413339 100644 --- a/packages/json-rpc-engine/jest.config.js +++ b/packages/json-rpc-engine/jest.config.js @@ -14,8 +14,6 @@ module.exports = merge(baseConfig, { // The display name when running multiple projects displayName, - setupFiles: [path.resolve(__dirname, 'test/setup.ts')], - // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { diff --git a/packages/json-rpc-engine/package.json b/packages/json-rpc-engine/package.json index 165e7982ff..256acb05af 100644 --- a/packages/json-rpc-engine/package.json +++ b/packages/json-rpc-engine/package.json @@ -77,11 +77,9 @@ "@lavamoat/preinstall-always-fail": "^2.1.0", "@metamask/auto-changelog": "^3.4.4", "@types/jest": "^27.4.1", - "@types/lodash": "^4.14.191", "deepmerge": "^4.2.2", "jest": "^27.5.1", "jest-it-up": "^2.0.2", - "lodash": "^4.17.21", "ts-jest": "^27.1.4", "typedoc": "^0.24.8", "typescript": "~5.2.2" diff --git a/packages/json-rpc-engine/src/JsonRpcEngineV2.ts b/packages/json-rpc-engine/src/JsonRpcEngineV2.ts index 75a092de2a..e4211f9178 100644 --- a/packages/json-rpc-engine/src/JsonRpcEngineV2.ts +++ b/packages/json-rpc-engine/src/JsonRpcEngineV2.ts @@ -56,7 +56,7 @@ export class JsonRpcEngineV2 { NonEmptyArray> >; - #makeMiddlewareIterator = (): Iterator< + readonly #makeMiddlewareIterator = (): Iterator< JsonRpcMiddleware > => this.#middleware[Symbol.iterator](); diff --git a/packages/json-rpc-engine/test/setup.ts b/packages/json-rpc-engine/test/setup.ts deleted file mode 100644 index 265ab1a353..0000000000 --- a/packages/json-rpc-engine/test/setup.ts +++ /dev/null @@ -1,9 +0,0 @@ -import cloneDeep from 'lodash/cloneDeep'; - -import '../../../tests/setup'; - -// Polyfill structuredClone if it's not available. -globalThis.structuredClone = - typeof globalThis.structuredClone === 'function' - ? globalThis.structuredClone - : cloneDeep; diff --git a/yarn.lock b/yarn.lock index 53f33b380a..394dd9e56e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3598,12 +3598,10 @@ __metadata: "@metamask/utils": "npm:^11.4.2" "@types/deep-freeze-strict": "npm:^1.1.0" "@types/jest": "npm:^27.4.1" - "@types/lodash": "npm:^4.14.191" deep-freeze-strict: "npm:^1.1.1" deepmerge: "npm:^4.2.2" jest: "npm:^27.5.1" jest-it-up: "npm:^2.0.2" - lodash: "npm:^4.17.21" ts-jest: "npm:^27.1.4" typedoc: "npm:^0.24.8" typescript: "npm:~5.2.2" From 684f0f5ef48b4342d24af7b0af833bf74a16bbfd Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Mon, 21 Jul 2025 12:33:26 -0700 Subject: [PATCH 25/75] docs: Update documentation --- packages/json-rpc-engine/README.md | 485 +++++++++++++----- packages/json-rpc-engine/src/legacy/README.md | 194 +++++++ 2 files changed, 540 insertions(+), 139 deletions(-) create mode 100644 packages/json-rpc-engine/src/legacy/README.md diff --git a/packages/json-rpc-engine/README.md b/packages/json-rpc-engine/README.md index f4ce817972..156f42e043 100644 --- a/packages/json-rpc-engine/README.md +++ b/packages/json-rpc-engine/README.md @@ -12,191 +12,398 @@ or ## Usage -```js -const { JsonRpcEngine } = require('@metamask/json-rpc-engine'); - -const engine = new JsonRpcEngine(); -``` - -Build a stack of JSON-RPC processors by pushing middleware to the engine. - -```js -engine.push(function (req, res, next, end) { - res.result = 42; - end(); +> [!NOTE] +> For the legacy `JsonRpcEngine`, see [its readme](./legacy/README.md). + +```ts +import { JsonRpcEngineV2 } from '@metamask/json-rpc-engine'; + +const engine = new JsonRpcEngineV2({ + // Create a stack of middleware and pass it to the engine: + middleware: [ + ({ request, next }) => { + if (request.method === 'foo') { + return 'bar'; + } + return next(); + }, + () => 42, + ], }); ``` -Requests are handled asynchronously, stepping down the stack until complete. +Requests are handled asynchronously, stepping down the middleware stack until complete. -```js -const request = { id: 1, jsonrpc: '2.0', method: 'hello' }; +```ts +const request = { id: '1', jsonrpc: '2.0', method: 'hello' }; -engine.handle(request, function (err, response) { - // Do something with response.result, or handle response.error -}); - -// There is also a Promise signature -const response = await engine.handle(request); +try { + const result = await engine.handle(request); + // Do something with the result +} catch (error) { + // Handle the error +} ``` -Middleware have direct access to the request and response objects. -They can let processing continue down the stack with `next()`, or complete the request with `end()`. - -```js -engine.push(function (req, res, next, end) { - if (req.skipCache) return next(); - res.result = getResultFromCache(req); - end(); +### Middleware + +Middleware functions can be sync or async. +They receive a `MiddlewareParams` object containing: + +- `request` + - The JSON-RPC request or notification (readonly) +- `context` + - A `Map` for passing data between middleware +- `next` + - Function to call the next middleware in the stack + +Here's a basic example: + +```ts +const engine = new JsonRpcEngineV2({ + middleware: [ + ({ next, context }) => { + context.set('foo', 'bar'); + // Proceed to the next middleware + return next(); + }, + async ({ request, context }) => { + await doSomething(request, context.get('foo')); + // Return a result to end the request + return 42; + }, + ], }); ``` -By passing a _return handler_ to the `next` function, you can get a peek at the result before it returns. +### Requests vs. notifications -```js -engine.push(function (req, res, next, end) { - next(function (cb) { - insertIntoCache(res, cb); - }); -}); -``` +JSON-RPC requests come in two flavors: -If you specify a `notificationHandler` when constructing the engine, JSON-RPC notifications passed to `handle()` will be handed off directly to this function without touching the middleware stack: +- [Requests](https://www.jsonrpc.org/specification#request_object), i.e. request objects with an `id` +- [Notifications](https://www.jsonrpc.org/specification#notification), i.e. request objects _without_ an `id` -```js -const engine = new JsonRpcEngine({ notificationHandler }); +Requests must return a non-`undefined` result, or the engine will error: -// A notification is defined as a JSON-RPC request without an `id` property. -const notification = { jsonrpc: '2.0', method: 'hello' }; - -const response = await engine.handle(notification); -console.log(typeof response); // 'undefined' -``` +```ts +const engine = new JsonRpcEngineV2({ + middleware: [ + () => { + if (Math.random() > 0.5) { + return 42; + } + return undefined; + }, + ], +}); -Engines can be nested by converting them to middleware using `JsonRpcEngine.asMiddleware()`: +const request = { jsonrpc: '2.0', id: '1', method: 'hello' }; -```js -const engine = new JsonRpcEngine(); -const subengine = new JsonRpcEngine(); -engine.push(subengine.asMiddleware()); +try { + const result = await engine.handle(request); + console.log(result); // 42 +} catch (error) { + console.error(error); // Nothing ended request: { ... } +} ``` -### `async` Middleware +Notifications, on the other hand, may only return `undefined`: -If you require your middleware function to be `async`, use `createAsyncMiddleware`: - -```js -const { createAsyncMiddleware } = require('@metamask/json-rpc-engine'); +```ts +const notification = { jsonrpc: '2.0', method: 'hello' }; -let engine = new RpcEngine(); -engine.push( - createAsyncMiddleware(async (req, res, next) => { - res.result = 42; - next(); - }), -); +try { + const result = await engine.handle(notification); + console.log(result); // undefined +} catch (error) { + console.error(error); // Result returned for notification: { ... } +} ``` -`async` middleware do not take an `end` callback. -Instead, the request ends if the middleware returns without calling `next()`: +If your middleware may be passed both requests and notifications, +use the `isRequest` or `isNotification` utilities to determine what to do: + +```ts +import { + isRequest, + isNotification, + JsonRpcEngineV2, +} from '@metamask/json-rpc-engine'; + +const engine = new JsonRpcEngineV2({ + middleware: [ + async ({ request, next }) => { + if (isRequest(request)) { + return 42; + } + return next(); + }, + ({ request }) => { + if (isNotification(request)) { + console.log(`Received notification: ${request.method}`); + return undefined; + } + return 'Hello, World!'; + }, + ], +}); +``` -```js -engine.push( - createAsyncMiddleware(async (req, res, next) => { - res.result = 42; - /* The request will end when this returns */ - }), -); +### Request modification + +The `request` object is immutable. +Attempting to directly modify it will throw an error. +Middleware can modify the `method` and `params` properties +by passing a new request object to `next()`: + +```ts +const engine = new JsonRpcEngineV2({ + middleware: [ + ({ request, next }) => { + // Modify the request for subsequent middleware + // The new request object will be deeply frozen + return next({ + ...request, + method: 'modified_method', + params: [1, 2, 3], + }); + }, + ({ request }) => { + // This middleware receives the modified request + return request.params[0]; + }, + ], +}); ``` -The `next` callback of `async` middleware also don't take return handlers. -Instead, you can `await next()`. -When the execution of the middleware resumes, you can work with the response again. - -```js -engine.push( - createAsyncMiddleware(async (req, res, next) => { - res.result = 42; - await next(); - /* Your return handler logic goes here */ - addToMetrics(res); - }), -); +Modifying the `jsonrpc` or `id` properties is not allowed, and will cause +an error: + +```ts +const engine = new JsonRpcEngineV2({ + middleware: [ + ({ request, next }) => { + // Modifying either proeprty will cause an error + return next({ + ...request, + jsonrpc: '3.0', + id: 'foo', + }); + }, + () => 42, + ], +}); ``` -You can freely mix callback-based and `async` middleware: - -```js -engine.push(function (req, res, next, end) { - if (!isCached(req)) { - return next((cb) => { - insertIntoCache(res, cb); - }); - } - res.result = getResultFromCache(req); - end(); -}); - -engine.push( - createAsyncMiddleware(async (req, res, next) => { - res.result = 42; - await next(); - addToMetrics(res); - }), -); +### Result handling + +Middleware can observe the result by awaiting `next()`: + +```ts +const engine = new JsonRpcEngineV2({ + middleware: [ + async ({ request, next }) => { + const startTime = Date.now(); + const result = await next(); + const duration = Date.now() - startTime; + + // Log the request duration + console.log( + `Request ${request.method} producing ${result} took ${duration}ms`, + ); + + // By returning undefined, the same result will be forwarded to earlier + // middleware awaiting next() + }, + ({ request }) => { + return 'Hello, World!'; + }, + ], +}); ``` -### Teardown +Like the `request`, the `result` is also immutable. +Middleware can update the result by returning a new one. + +```ts +const engine = new JsonRpcEngineV2({ + middleware: [ + async ({ request, next }) => { + const result = await next(); + + // Add metadata to the result + if (result && typeof result === 'object') { + // The new result will also be deeply frozen + return { + ...result, + metadata: { + processedAt: new Date().toISOString(), + requestId: request.id, + }, + }; + } + + return result; + }, + ({ request }) => { + // Initial result + return { message: 'Hello, World!' }; + }, + ], +}); -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. +const result = await engine.handle({ + id: '1', + jsonrpc: '2.0', + method: 'hello', +}); +console.log(result); +// { +// message: 'Hello, World!', +// metadata: { +// processedAt: '2024-01-01T12:00:00.000Z', +// requestId: 1 +// } +// } +``` -```js -const middleware = (req, res, next, end) => { - /* do something */ -}; -middleware.destroy = () => { - /* perform teardown */ -}; +### Context sharing + +Use the `context` to share data between middleware: + +```ts +const engine = new JsonRpcEngineV2({ + middleware: [ + async ({ context, next }) => { + context.set('user', { id: '123', name: 'Alice' }); + return next(); + }, + async ({ context, next }) => { + // context.assertGet() throws if the value does not exist + // Use with caution: it does not otherwise perform any type checks. + const user = context.assertGet<{ id: string; name: string }>('user'); + context.set('permissions', await getUserPermissions(user.id)); + return next(); + }, + ({ context }) => { + const user = context.get('user'); + const permissions = context.get('permissions'); + return { user, permissions }; + }, + ], +}); +``` -const engine = new JsonRpcEngine(); -engine.push(middleware); +### Error handling + +Errors in middleware are propagated up the call stack: + +```ts +const engine = new JsonRpcEngineV2({ + middleware: [ + ({ next }) => { + return next(); + }, + ({ request, next }) => { + if (request.method === 'restricted') { + throw new Error('Method not allowed'); + } + return 'Success'; + }, + ], +}); -/* perform work */ +try { + await engine.handle({ id: '1', jsonrpc: '2.0', method: 'restricted' }); +} catch (error) { + console.error('Request failed:', error.message); +} +``` -// This will call middleware.destroy() and destroy the engine itself. -engine.destroy(); +If your middleware awaits `next()`, it can handle errors using `try`/`catch`: + +```ts +const engine = new JsonRpcEngineV2({ + middleware: [ + ({ request, next }) => { + try { + return await next(); + } catch (error) { + console.error(`Request ${request.method} errored:`, error); + return 42; + } + }, + ({ request }) => { + if (!isValid(request)) { + throw new Error('Invalid request'); + } + }, + ], +}); -// Calling any public method on the middleware other than `destroy()` itself -// will throw an error. -engine.handle(req); +const result = await engine.handle({ + id: '1', + jsonrpc: '2.0', + method: 'hello', +}); +console.log('Result:', result); +// Request hello errored: Error: Invalid request +// Result: 42 ``` -### Gotchas +### Engine composition -Handle errors via `end(err)`, _NOT_ `next(err)`. +Engines can be nested by converting them to middleware using `asMiddleware()`: -```js -/* INCORRECT */ -engine.push(function (req, res, next, end) { - next(new Error()); +```ts +const subEngine = new JsonRpcEngineV2({ + middleware: [ + ({ request }) => { + return 'Sub-engine result'; + }, + ], }); -/* CORRECT */ -engine.push(function (req, res, next, end) { - end(new Error()); +const mainEngine = new JsonRpcEngineV2({ + middleware: [ + subEngine.asMiddleware(), + ({ request, next }) => { + const subResult = await next(); + return `Main engine processed: ${subResult}`; + }, + ], }); ``` -However, `next()` will detect errors on the response object, and cause -`end(res.error)` to be called. +Engines used as middleware may return `undefined` for requests, but only when +used as middleware: -```js -engine.push(function (req, res, next, end) { - res.error = new Error(); - next(); /* This will cause end(res.error) to be called. */ +```ts +const loggingEngine = new JsonRpcEngineV2({ + middleware: [ + ({ request, next }) => { + console.log('Observed request:', request.method); + }, + ], }); + +const mainEngine = new JsonRpcEngineV2({ + middleware: [ + loggingEngine.asMiddleware(), + ({ request }) => { + return 'success'; + }, + ], +}); + +const request = { id: '1', jsonrpc: '2.0', method: 'hello' }; +const result = await mainEngine.handle(request); +console.log('Result:', result); +// Observed request: hello +// Result: success + +// ATTN: This will throw "Nothing ended request" +const result2 = await loggingEngine.handle(request): ``` ## Contributing diff --git a/packages/json-rpc-engine/src/legacy/README.md b/packages/json-rpc-engine/src/legacy/README.md new file mode 100644 index 0000000000..81e96e5113 --- /dev/null +++ b/packages/json-rpc-engine/src/legacy/README.md @@ -0,0 +1,194 @@ +# `@metamask/json-rpc-engine/legacy` + +The deprecated, original `JsonRpcEngine` implementation. + +To be removed once the rest of MetaMask's codebase has been migrated to `JsonRpcEngineV2`. + +## Usage + +```js +const { JsonRpcEngine } = require('@metamask/json-rpc-engine'); + +const engine = new JsonRpcEngine(); +``` + +Build a stack of JSON-RPC processors by pushing middleware to the engine. + +```js +engine.push(function (req, res, next, end) { + res.result = 42; + end(); +}); +``` + +Requests are handled asynchronously, stepping down the stack until complete. + +```js +const request = { id: 1, jsonrpc: '2.0', method: 'hello' }; + +engine.handle(request, function (err, response) { + // Do something with response.result, or handle response.error +}); + +// There is also a Promise signature +const response = await engine.handle(request); +``` + +Middleware have direct access to the request and response objects. +They can let processing continue down the stack with `next()`, or complete the request with `end()`. + +```js +engine.push(function (req, res, next, end) { + if (req.skipCache) return next(); + res.result = getResultFromCache(req); + end(); +}); +``` + +By passing a _return handler_ to the `next` function, you can get a peek at the result before it returns. + +```js +engine.push(function (req, res, next, end) { + next(function (cb) { + insertIntoCache(res, cb); + }); +}); +``` + +If you specify a `notificationHandler` when constructing the engine, JSON-RPC notifications passed to `handle()` will be handed off directly to this function without touching the middleware stack: + +```js +const engine = new JsonRpcEngine({ notificationHandler }); + +// A notification is defined as a JSON-RPC request without an `id` property. +const notification = { jsonrpc: '2.0', method: 'hello' }; + +const response = await engine.handle(notification); +console.log(typeof response); // 'undefined' +``` + +Engines can be nested by converting them to middleware using `JsonRpcEngine.asMiddleware()`: + +```js +const engine = new JsonRpcEngine(); +const subengine = new JsonRpcEngine(); +engine.push(subengine.asMiddleware()); +``` + +### `async` Middleware + +If you require your middleware function to be `async`, use `createAsyncMiddleware`: + +```js +const { createAsyncMiddleware } = require('@metamask/json-rpc-engine'); + +let engine = new RpcEngine(); +engine.push( + createAsyncMiddleware(async (req, res, next) => { + res.result = 42; + next(); + }), +); +``` + +`async` middleware do not take an `end` callback. +Instead, the request ends if the middleware returns without calling `next()`: + +```js +engine.push( + createAsyncMiddleware(async (req, res, next) => { + res.result = 42; + /* The request will end when this returns */ + }), +); +``` + +The `next` callback of `async` middleware also don't take return handlers. +Instead, you can `await next()`. +When the execution of the middleware resumes, you can work with the response again. + +```js +engine.push( + createAsyncMiddleware(async (req, res, next) => { + res.result = 42; + await next(); + /* Your return handler logic goes here */ + addToMetrics(res); + }), +); +``` + +You can freely mix callback-based and `async` middleware: + +```js +engine.push(function (req, res, next, end) { + if (!isCached(req)) { + return next((cb) => { + insertIntoCache(res, cb); + }); + } + res.result = getResultFromCache(req); + end(); +}); + +engine.push( + createAsyncMiddleware(async (req, res, next) => { + res.result = 42; + await next(); + addToMetrics(res); + }), +); +``` + +### 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)`. + +```js +/* INCORRECT */ +engine.push(function (req, res, next, end) { + next(new Error()); +}); + +/* CORRECT */ +engine.push(function (req, res, next, end) { + end(new Error()); +}); +``` + +However, `next()` will detect errors on the response object, and cause +`end(res.error)` to be called. + +```js +engine.push(function (req, res, next, end) { + res.error = new Error(); + next(); /* This will cause end(res.error) to be called. */ +}); +``` From eeeedbd8a3139cf0fc4c7797459c4d9df7bdc0e6 Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Mon, 21 Jul 2025 15:45:38 -0700 Subject: [PATCH 26/75] feat: Add JsonRpcServer abstraction --- .../json-rpc-engine/src/JsonRpcServer.test.ts | 235 ++++++++++++++++++ packages/json-rpc-engine/src/JsonRpcServer.ts | 146 +++++++++++ packages/json-rpc-engine/src/index.test.ts | 1 + packages/json-rpc-engine/src/index.ts | 1 + packages/json-rpc-engine/src/utils.ts | 5 +- 5 files changed, 385 insertions(+), 3 deletions(-) create mode 100644 packages/json-rpc-engine/src/JsonRpcServer.test.ts create mode 100644 packages/json-rpc-engine/src/JsonRpcServer.ts diff --git a/packages/json-rpc-engine/src/JsonRpcServer.test.ts b/packages/json-rpc-engine/src/JsonRpcServer.test.ts new file mode 100644 index 0000000000..2883870460 --- /dev/null +++ b/packages/json-rpc-engine/src/JsonRpcServer.test.ts @@ -0,0 +1,235 @@ +import { rpcErrors } from '@metamask/rpc-errors'; + +import { JsonRpcEngineV2 } from './JsonRpcEngineV2'; +import { JsonRpcServer } from './JsonRpcServer'; +import { isRequest } from './utils'; + +const jsonrpc = '2.0' as const; + +const makeEngine = () => { + return new JsonRpcEngineV2({ + middleware: [ + ({ request }) => { + if (request.method !== 'hello') { + throw new Error('Unknown method'); + } + return isRequest(request) ? (request.params ?? null) : undefined; + }, + ], + }); +}; + +describe('JsonRpcServer', () => { + it('handles a request', async () => { + const server = new JsonRpcServer({ + engine: makeEngine(), + handleError: () => undefined, + }); + + const response = await server.handle({ + jsonrpc, + id: 1, + method: 'hello', + }); + + expect(response).toStrictEqual({ + jsonrpc, + id: 1, + result: null, + }); + }); + + it('handles a request with params', async () => { + const server = new JsonRpcServer({ + engine: makeEngine(), + handleError: () => undefined, + }); + + const response = await server.handle({ + jsonrpc, + id: 1, + method: 'hello', + params: ['world'], + }); + + expect(response).toStrictEqual({ + jsonrpc, + id: 1, + result: ['world'], + }); + }); + + it('handles a notification', async () => { + const server = new JsonRpcServer({ + engine: makeEngine(), + handleError: () => undefined, + }); + + const response = await server.handle({ + jsonrpc, + method: 'hello', + }); + + expect(response).toBeUndefined(); + }); + + it('handles a notification with params', async () => { + const server = new JsonRpcServer({ + engine: makeEngine(), + handleError: () => undefined, + }); + + const response = await server.handle({ + jsonrpc, + method: 'hello', + params: { hello: 'world' }, + }); + + expect(response).toBeUndefined(); + }); + + it('returns an error response for a failed request', async () => { + const server = new JsonRpcServer({ + engine: makeEngine(), + handleError: () => undefined, + }); + + const response = await server.handle({ + jsonrpc, + id: 1, + method: 'unknown', + }); + + expect(response).toStrictEqual({ + jsonrpc, + id: 1, + error: { + code: -32603, + message: 'Unknown method', + data: { cause: expect.any(Object) }, + }, + }); + }); + + it('returns undefined for a failed notification', async () => { + const server = new JsonRpcServer({ + engine: makeEngine(), + handleError: () => undefined, + }); + + const response = await server.handle({ + jsonrpc, + method: 'unknown', + }); + + expect(response).toBeUndefined(); + }); + + it('calls handleError for a failed request', async () => { + const handleError = jest.fn(); + const server = new JsonRpcServer({ + engine: makeEngine(), + handleError, + }); + + await server.handle({ + jsonrpc, + id: 1, + method: 'unknown', + }); + + expect(handleError).toHaveBeenCalledTimes(1); + expect(handleError).toHaveBeenCalledWith(new Error('Unknown method')); + }); + + it('calls handleError for a failed notification', async () => { + const handleError = jest.fn(); + const server = new JsonRpcServer({ + engine: makeEngine(), + handleError, + }); + + await server.handle({ + jsonrpc, + method: 'unknown', + }); + + expect(handleError).toHaveBeenCalledTimes(1); + expect(handleError).toHaveBeenCalledWith(new Error('Unknown method')); + }); + + it('accepts requests with malformed jsonrpc', async () => { + const server = new JsonRpcServer({ + engine: makeEngine(), + handleError: () => undefined, + }); + + const response = await server.handle({ + jsonrpc: '1.0', + id: 1, + method: 'hello', + }); + + expect(response).toStrictEqual({ + jsonrpc, + id: 1, + result: null, + }); + }); + + it.each([undefined, Symbol('test'), null, true, false, {}, []])( + 'accepts requests with malformed ids', + async (id) => { + const server = new JsonRpcServer({ + engine: makeEngine(), + handleError: () => undefined, + }); + + const response = await server.handle({ + jsonrpc, + id, + method: 'hello', + }); + + expect(response).toStrictEqual({ + jsonrpc, + id, + result: null, + }); + }, + ); + + it.each([ + null, + {}, + [], + false, + true, + { method: 'hello', params: 'world' }, + { method: 'hello', params: null }, + { method: 'hello', params: undefined }, + { params: ['world'] }, + { jsonrpc }, + { id: 1 }, + ])( + 'throws if the request is not minimally conformant', + async (malformedRequest) => { + const handleError = jest.fn(); + const server = new JsonRpcServer({ + engine: makeEngine(), + handleError, + }); + + await server.handle(malformedRequest); + + expect(handleError).toHaveBeenCalledTimes(1); + expect(handleError).toHaveBeenCalledWith( + rpcErrors.invalidRequest({ + data: { + request: malformedRequest, + }, + }), + ); + }, + ); +}); diff --git a/packages/json-rpc-engine/src/JsonRpcServer.ts b/packages/json-rpc-engine/src/JsonRpcServer.ts new file mode 100644 index 0000000000..70a6ad25bf --- /dev/null +++ b/packages/json-rpc-engine/src/JsonRpcServer.ts @@ -0,0 +1,146 @@ +import { rpcErrors, serializeError } from '@metamask/rpc-errors'; +import type { + Json, + JsonRpcId, + JsonRpcParams, + JsonRpcRequest, + JsonRpcResponse, +} from '@metamask/utils'; +import { hasProperty, isObject } from '@metamask/utils'; + +import { getUniqueId } from './getUniqueId'; +import type { JsonRpcEngineV2 } from './JsonRpcEngineV2'; +import type { JsonRpcCall } from './utils'; + +type HandleError = (error: unknown) => void | Promise; + +type Options = { + engine: JsonRpcEngineV2; + handleError: HandleError; +}; + +const jsonrpc = '2.0' as const; + +export class JsonRpcServer { + readonly #engine: JsonRpcEngineV2; + + readonly #handleError: HandleError; + + constructor({ engine, handleError }: Options) { + this.#engine = engine; + this.#handleError = handleError; + } + + async handle(rawRequest: unknown): Promise { + const [originalId, isRequest] = getOriginalId(rawRequest); + + try { + const request = this.#coerceRequest(rawRequest, isRequest); + const result = await this.#engine.handleAny(request); + + if (isRequest) { + return { + jsonrpc, + id: originalId as JsonRpcId, + // The result is guaranteed to be Json by the engine. + result: result as Json, + }; + } + } catch (error) { + await this.#handleError(error); + + if (isRequest) { + return { + jsonrpc, + // Remap the original id to the error response, regardless of its + // type, which is not our problem. + id: originalId as JsonRpcId, + error: serializeError(error, { + shouldIncludeStack: false, + shouldPreserveMessage: true, + }), + }; + } + } + return undefined; + } + + #coerceRequest(rawRequest: unknown, isRequest: boolean): JsonRpcCall { + if (!isMinimalRequest(rawRequest)) { + throw rpcErrors.invalidRequest({ + data: { + request: rawRequest, + }, + }); + } + + const request: JsonRpcCall = { + jsonrpc: '2.0' as const, + method: rawRequest.method, + }; + + if (hasProperty(rawRequest, 'params')) { + request.params = rawRequest.params as JsonRpcParams; + } + + if (isRequest) { + (request as JsonRpcRequest).id = getUniqueId(); + } + + return request; + } +} + +/** + * The most minimally conformant request object that we will accept. + */ +type MinimalRequest = { + method: string; + params?: JsonRpcParams; +} & Record; + +/** + * Check if an unvalidated request is a minimal request. + * + * @param rawRequest - The raw request to check. + * @returns `true` if the request is a {@link MinimalRequest}, `false` otherwise. + */ +function isMinimalRequest(rawRequest: unknown): rawRequest is MinimalRequest { + return ( + isObject(rawRequest) && + hasProperty(rawRequest, 'method') && + typeof rawRequest.method === 'string' && + hasValidParams(rawRequest) + ); +} + +/** + * Check if a request has valid params. + * + * @param rawRequest - The request to check. + * @returns `true` if the request has valid params, `false` otherwise. + */ +function hasValidParams( + rawRequest: Record, +): rawRequest is { params?: JsonRpcParams } { + if (hasProperty(rawRequest, 'params')) { + return Array.isArray(rawRequest.params) || isObject(rawRequest.params); + } + + return true; +} + +/** + * Get the original id from a request. + * + * @param rawRequest - The request to get the original id from. + * @returns The original id and a boolean indicating if the request is a request + * (as opposed to a notification). + */ +function getOriginalId(rawRequest: unknown): [unknown, boolean] { + if (isObject(rawRequest) && hasProperty(rawRequest, 'id')) { + return [rawRequest.id, true]; + } + + return [undefined, false]; +} diff --git a/packages/json-rpc-engine/src/index.test.ts b/packages/json-rpc-engine/src/index.test.ts index 134173183f..761d75caa7 100644 --- a/packages/json-rpc-engine/src/index.test.ts +++ b/packages/json-rpc-engine/src/index.test.ts @@ -5,6 +5,7 @@ describe('@metamask/json-rpc-engine', () => { expect(Object.keys(allExports)).toMatchInlineSnapshot(` Array [ "getUniqueId", + "JsonRpcServer", "isNotification", "isRequest", "JsonRpcEngineError", diff --git a/packages/json-rpc-engine/src/index.ts b/packages/json-rpc-engine/src/index.ts index 7747940a82..17d4425bad 100644 --- a/packages/json-rpc-engine/src/index.ts +++ b/packages/json-rpc-engine/src/index.ts @@ -1,5 +1,6 @@ export { getUniqueId } from './getUniqueId'; export * from './JsonRpcEngineV2'; +export { JsonRpcServer } from './JsonRpcServer'; export type { MiddlewareContext } from './MiddlewareContext'; export { isNotification, isRequest, JsonRpcEngineError } from './utils'; export type { JsonRpcCall, JsonRpcNotification, JsonRpcRequest } from './utils'; diff --git a/packages/json-rpc-engine/src/utils.ts b/packages/json-rpc-engine/src/utils.ts index 574702227a..abe761098c 100644 --- a/packages/json-rpc-engine/src/utils.ts +++ b/packages/json-rpc-engine/src/utils.ts @@ -1,4 +1,3 @@ -import type { Json } from '@metamask/utils'; import { hasProperty, type JsonRpcNotification as BaseJsonRpcNotification, @@ -25,12 +24,12 @@ export const isNotification = ( ): msg is JsonRpcNotification => !isRequest(msg); /** - * JSON-stringifies a JSON value. + * JSON-stringifies a value. * * @param value - The value to stringify. * @returns The stringified value. */ -export function stringify(value: Json | Readonly): string { +export function stringify(value: unknown): string { return JSON.stringify(value, null, 2); } From 0bbffcd6bf265967e25875952ead5b9de742dc6f Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Mon, 21 Jul 2025 16:11:15 -0700 Subject: [PATCH 27/75] docs: Document JsonRpcServer --- packages/json-rpc-engine/README.md | 55 +++++++++++++++++++ .../json-rpc-engine/src/JsonRpcEngineV2.ts | 14 +++++ packages/json-rpc-engine/src/JsonRpcServer.ts | 34 ++++++++++++ 3 files changed, 103 insertions(+) diff --git a/packages/json-rpc-engine/README.md b/packages/json-rpc-engine/README.md index 156f42e043..9294bfc312 100644 --- a/packages/json-rpc-engine/README.md +++ b/packages/json-rpc-engine/README.md @@ -45,6 +45,27 @@ try { } ``` +Alternatively, pass the engine to a `JsonRpcServer`, which coerces raw request +objects into well-formed requests, and handles error serialization: + +```ts +const server = new JsonRpcServer({ engine, handleError }); +const request = { id: '1', jsonrpc: '2.0', method: 'hello' }; + +// server.handle() never throws +const response = await server.handle(request); +if ('result' in response) { + // Handle result +} else { + // Handle error +} + +const notification = { id: '1', jsonrpc: '2.0', method: 'hello' }; + +// Always returns undefined for notifications +await server.handle(notification); +``` + ### Middleware Middleware functions can be sync or async. @@ -406,6 +427,40 @@ console.log('Result:', result); const result2 = await loggingEngine.handle(request): ``` +### `JsonRpcServer` + +The `JsonRpcServer` wraps a `JsonRpcEngineV2` to provide JSON-RPC 2.0 compliance and error handling. It coerces raw request objects into well-formed requests and handles error serialization. + +```ts +import { JsonRpcEngineV2, JsonRpcServer } from '@metamask/json-rpc-engine'; + +const engine = new JsonRpcEngine({ middleware }); + +const server = new JsonRpcServer({ + engine, + handleError: (error) => console.error('Server error:', error), +}); + +// server.handle() never throws - all errors are handled by handleError +const response = await server.handle({ id: '1', jsonrpc: '2.0', method: 'hello' }); +if ('result' in response) { + // Handle successful response +} else { + // Handle error response +} + +// Notifications return undefined +const notification = { jsonrpc: '2.0', method: 'hello' }; +await server.handle(notification); // Returns undefined +``` + +The server accepts any object with a `method` property and validates JSON-RPC 2.0 +compliance. +Errors occurring during request validation or processing (by the engine) are passed +to `handleError`. +Response objects are returned for requests but not notifications, and contain +the `result` in case of success and `error` in case of failure. + ## Contributing This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/json-rpc-engine/src/JsonRpcEngineV2.ts b/packages/json-rpc-engine/src/JsonRpcEngineV2.ts index e4211f9178..5a76cdf3be 100644 --- a/packages/json-rpc-engine/src/JsonRpcEngineV2.ts +++ b/packages/json-rpc-engine/src/JsonRpcEngineV2.ts @@ -50,6 +50,20 @@ type Options = { * * @template Request - The type of request to handle. * @template Result - The type of result to return. + * + * @example + * ```ts + * const engine = new JsonRpcEngineV2({ + * middleware, + * }); + * + * try { + * const result = await engine.handle(request); + * // Handle result + * } catch (error) { + * // Handle error + * } + * ``` */ export class JsonRpcEngineV2 { readonly #middleware: Readonly< diff --git a/packages/json-rpc-engine/src/JsonRpcServer.ts b/packages/json-rpc-engine/src/JsonRpcServer.ts index 70a6ad25bf..62f1e66c1b 100644 --- a/packages/json-rpc-engine/src/JsonRpcServer.ts +++ b/packages/json-rpc-engine/src/JsonRpcServer.ts @@ -21,6 +21,27 @@ type Options = { const jsonrpc = '2.0' as const; +/** + * A JSON-RPC server that handles requests and notifications. + * + * Essentially wraps a {@link JsonRpcEngineV2} in order to create a conformant + * yet permissive JSON-RPC 2.0 server. + * + * @example + * ```ts + * const server = new JsonRpcServer({ + * engine, + * handleError, + * }); + * + * const response = await server.handle(request); + * if ('result' in response) { + * // Handle result + * } else { + * // Handle error + * } + * ``` + */ export class JsonRpcServer { readonly #engine: JsonRpcEngineV2; @@ -31,6 +52,19 @@ export class JsonRpcServer { this.#handleError = handleError; } + /** + * Handle an alleged JSON-RPC request. Permits any plain object with a `method` + * property, so long as any other JSON-RPC 2.0 properties are valid. + * + * This method never throws. All errors are handled by the instance's + * `handleError` callback. A response with a `result` or `error` property is + * returned unless the request is a notification, in which case `undefined` + * is returned. + * + * @param rawRequest - The raw request to handle. + * @returns The JSON-RPC response, or `undefined` if the request is a + * notification. + */ async handle(rawRequest: unknown): Promise { const [originalId, isRequest] = getOriginalId(rawRequest); From 7da112c8db8de5d24137a2df5ec5d3433407e9bf Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Tue, 22 Jul 2025 20:55:48 -0700 Subject: [PATCH 28/75] refactor: Convert MiddlewareContext to class --- .../json-rpc-engine/src/JsonRpcEngineV2.ts | 7 ++--- .../src/MiddlewareContext.test.ts | 10 +++---- .../json-rpc-engine/src/MiddlewareContext.ts | 28 ++++++------------- 3 files changed, 16 insertions(+), 29 deletions(-) diff --git a/packages/json-rpc-engine/src/JsonRpcEngineV2.ts b/packages/json-rpc-engine/src/JsonRpcEngineV2.ts index 5a76cdf3be..b1f97c78f3 100644 --- a/packages/json-rpc-engine/src/JsonRpcEngineV2.ts +++ b/packages/json-rpc-engine/src/JsonRpcEngineV2.ts @@ -7,10 +7,7 @@ import { } from '@metamask/utils'; import deepFreeze from 'deep-freeze-strict'; -import { - makeMiddlewareContext, - type MiddlewareContext, -} from './MiddlewareContext'; +import { MiddlewareContext } from './MiddlewareContext'; import { isNotification, isRequest, @@ -127,7 +124,7 @@ export class JsonRpcEngineV2 { */ async #handle( originalRequest: Request, - context: MiddlewareContext = makeMiddlewareContext(), + context: MiddlewareContext = new MiddlewareContext(), ): Promise<{ result: Result | void; finalRequest: Readonly; diff --git a/packages/json-rpc-engine/src/MiddlewareContext.test.ts b/packages/json-rpc-engine/src/MiddlewareContext.test.ts index ddc874d476..aab704fe97 100644 --- a/packages/json-rpc-engine/src/MiddlewareContext.test.ts +++ b/packages/json-rpc-engine/src/MiddlewareContext.test.ts @@ -1,25 +1,25 @@ -import { makeMiddlewareContext } from './MiddlewareContext'; +import { MiddlewareContext } from './MiddlewareContext'; describe('MiddlewareContext', () => { it('is a map', () => { - const context = makeMiddlewareContext(); + const context = new MiddlewareContext(); expect(context).toBeInstanceOf(Map); }); it('is frozen', () => { - const context = makeMiddlewareContext(); + const context = new MiddlewareContext(); expect(Object.isFrozen(context)).toBe(true); }); it('assertGet throws if the key is not found', () => { - const context = makeMiddlewareContext(); + const context = new MiddlewareContext(); expect(() => context.assertGet('test')).toThrow( `Context key "test" not found`, ); }); it('assertGet returns the value if the key is found', () => { - const context = makeMiddlewareContext(); + const context = new MiddlewareContext(); context.set('test', 'value'); expect(context.assertGet('test')).toBe('value'); }); diff --git a/packages/json-rpc-engine/src/MiddlewareContext.ts b/packages/json-rpc-engine/src/MiddlewareContext.ts index fa89931b90..4152e2b5c5 100644 --- a/packages/json-rpc-engine/src/MiddlewareContext.ts +++ b/packages/json-rpc-engine/src/MiddlewareContext.ts @@ -1,23 +1,13 @@ -export type MiddlewareContext = Readonly< - Map & { - assertGet(key: string): Value; +export class MiddlewareContext extends Map { + constructor(entries?: Iterable) { + super(entries); + Object.freeze(this); } ->; -export const makeMiddlewareContext = (): MiddlewareContext => { - const map = new Map(); - const assertGet = (key: string): Value => { - if (!map.has(key)) { + assertGet(key: string): Value { + if (!this.has(key)) { throw new Error(`Context key "${key}" not found`); } - return map.get(key) as Value; - }; - - Object.defineProperty(map, 'assertGet', { - value: assertGet, - writable: false, - enumerable: true, - configurable: false, - }); - return Object.freeze(map) as MiddlewareContext; -}; + return this.get(key) as Value; + } +} From 416ff7e6f6b2669be092b254750e3eab163ac58e Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Tue, 22 Jul 2025 23:31:58 -0700 Subject: [PATCH 29/75] feat: Add backwards compatibility utils --- packages/json-rpc-engine/package.json | 3 +- .../src/JsonRpcEngineV2.test.ts | 12 +- packages/json-rpc-engine/src/JsonRpcServer.ts | 2 +- .../src/asLegacyMiddleware.test.ts | 165 +++++++ .../json-rpc-engine/src/asLegacyMiddleware.ts | 50 ++ .../src/compatibility-utils.test.ts | 467 ++++++++++++++++++ .../src/compatibility-utils.ts | 157 ++++++ packages/json-rpc-engine/src/index.test.ts | 1 + packages/json-rpc-engine/src/index.ts | 1 + .../src/legacy/asV2Middleware.test.ts | 113 +++++ .../src/legacy/asV2Middleware.ts | 73 +++ .../json-rpc-engine/src/legacy/index.test.ts | 1 + packages/json-rpc-engine/src/legacy/index.ts | 1 + packages/json-rpc-engine/tests/utils.ts | 25 + yarn.lock | 3 +- 15 files changed, 1060 insertions(+), 14 deletions(-) create mode 100644 packages/json-rpc-engine/src/asLegacyMiddleware.test.ts create mode 100644 packages/json-rpc-engine/src/asLegacyMiddleware.ts create mode 100644 packages/json-rpc-engine/src/compatibility-utils.test.ts create mode 100644 packages/json-rpc-engine/src/compatibility-utils.ts create mode 100644 packages/json-rpc-engine/src/legacy/asV2Middleware.test.ts create mode 100644 packages/json-rpc-engine/src/legacy/asV2Middleware.ts create mode 100644 packages/json-rpc-engine/tests/utils.ts diff --git a/packages/json-rpc-engine/package.json b/packages/json-rpc-engine/package.json index 256acb05af..290e4068fc 100644 --- a/packages/json-rpc-engine/package.json +++ b/packages/json-rpc-engine/package.json @@ -70,7 +70,8 @@ "@metamask/safe-event-emitter": "^3.0.0", "@metamask/utils": "^11.4.2", "@types/deep-freeze-strict": "^1.1.0", - "deep-freeze-strict": "^1.1.1" + "deep-freeze-strict": "^1.1.1", + "rfdc": "^1.4.1" }, "devDependencies": { "@lavamoat/allow-scripts": "^3.0.4", diff --git a/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts b/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts index c34478be0f..52b9981f14 100644 --- a/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts +++ b/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts @@ -8,20 +8,10 @@ import { type JsonRpcNotification, type JsonRpcRequest, } from './utils'; +import { makeRequest } from '../tests/utils'; const jsonrpc = '2.0' as const; -const makeRequest = ( - params: Partial = {}, -): Request => - ({ - jsonrpc, - id: '1', - method: 'test_request', - params: [] as Request['params'], - ...params, - }) as Request; - describe('JsonRpcEngineV2', () => { describe('handle', () => { describe('notifications', () => { diff --git a/packages/json-rpc-engine/src/JsonRpcServer.ts b/packages/json-rpc-engine/src/JsonRpcServer.ts index 62f1e66c1b..475810aea3 100644 --- a/packages/json-rpc-engine/src/JsonRpcServer.ts +++ b/packages/json-rpc-engine/src/JsonRpcServer.ts @@ -109,7 +109,7 @@ export class JsonRpcServer { } const request: JsonRpcCall = { - jsonrpc: '2.0' as const, + jsonrpc, method: rawRequest.method, }; diff --git a/packages/json-rpc-engine/src/asLegacyMiddleware.test.ts b/packages/json-rpc-engine/src/asLegacyMiddleware.test.ts new file mode 100644 index 0000000000..61d4b0523f --- /dev/null +++ b/packages/json-rpc-engine/src/asLegacyMiddleware.test.ts @@ -0,0 +1,165 @@ +import type { + Json, + JsonRpcFailure, + JsonRpcRequest, + JsonRpcSuccess, +} from '@metamask/utils'; + +import { asLegacyMiddleware } from './asLegacyMiddleware'; +import type { JsonRpcMiddleware } from './JsonRpcEngineV2'; +import { JsonRpcEngineV2 } from './JsonRpcEngineV2'; +import { JsonRpcEngine } from './legacy/JsonRpcEngine'; +import { getExtraneousKeys, makeRequest } from '../tests/utils'; + +describe('asLegacyMiddleware', () => { + it('converts a v2 engine to a legacy middleware', () => { + const engine = new JsonRpcEngineV2({ + middleware: [() => null], + }); + const middleware = asLegacyMiddleware(engine); + expect(typeof middleware).toBe('function'); + }); + + it('forwards a result to the legacy engine', async () => { + const v2Engine = new JsonRpcEngineV2({ + middleware: [() => null], + }); + + const legacyEngine = new JsonRpcEngine(); + legacyEngine.push(asLegacyMiddleware(v2Engine)); + + const response = (await legacyEngine.handle( + makeRequest(), + )) as JsonRpcSuccess; + + expect(response.result).toBeNull(); + }); + + it('forwards an error to the legacy engine', async () => { + const v2Engine = new JsonRpcEngineV2({ + middleware: [ + () => { + throw new Error('test'); + }, + ], + }); + + const legacyEngine = new JsonRpcEngine(); + legacyEngine.push(asLegacyMiddleware(v2Engine)); + + const response = (await legacyEngine.handle( + makeRequest(), + )) as JsonRpcFailure; + + expect(response.error).toStrictEqual({ + message: 'test', + code: -32603, + data: { + cause: { + message: 'test', + stack: expect.any(String), + }, + }, + }); + }); + + it('allows the legacy engine to continue when not ending the request', async () => { + const v2Middleware = jest.fn(({ next }) => next()); + const v2Engine = new JsonRpcEngineV2({ + middleware: [v2Middleware], + }); + + const legacyEngine = new JsonRpcEngine(); + legacyEngine.push(asLegacyMiddleware(v2Engine)); + legacyEngine.push((_req, res, _next, end) => { + res.result = null; + end(); + }); + + const response = (await legacyEngine.handle( + makeRequest(), + )) as JsonRpcSuccess; + expect(response.result).toBeNull(); + expect(v2Middleware).toHaveBeenCalledTimes(1); + }); + + it('allows the legacy engine to continue when not ending the request (passing through the original request)', async () => { + const v2Middleware = jest.fn(({ request, next }) => next(request)); + const v2Engine = new JsonRpcEngineV2({ + middleware: [v2Middleware], + }); + + const legacyEngine = new JsonRpcEngine(); + legacyEngine.push(asLegacyMiddleware(v2Engine)); + legacyEngine.push((_req, res, _next, end) => { + res.result = null; + end(); + }); + + const response = (await legacyEngine.handle( + makeRequest(), + )) as JsonRpcSuccess; + expect(response.result).toBeNull(); + expect(v2Middleware).toHaveBeenCalledTimes(1); + }); + + it('propagates request modifications to the legacy engine', async () => { + const v2Engine = new JsonRpcEngineV2({ + middleware: [ + ({ request, next }) => next({ ...request, method: 'test_request_2' }), + ], + }); + + const legacyEngine = new JsonRpcEngine(); + legacyEngine.push(asLegacyMiddleware(v2Engine)); + legacyEngine.push((req, res, _next, end) => { + res.result = null; + + expect(req.method).toBe('test_request_2'); + + end(); + }); + + const response = (await legacyEngine.handle( + makeRequest(), + )) as JsonRpcSuccess; + expect(response.result).toBeNull(); + }); + + it('propagates additional request properties to the v2 context and back', async () => { + const observedContextValues: number[] = []; + + const v2Middleware = jest.fn((({ context, next }) => { + observedContextValues.push(context.assertGet('value')); + + expect(Array.from(context.keys())).toStrictEqual(['value']); + + context.set('value', 2); + return next(); + }) as JsonRpcMiddleware); + + const v2Engine = new JsonRpcEngineV2({ + middleware: [v2Middleware], + }); + + const legacyEngine = new JsonRpcEngine(); + legacyEngine.push((req, _res, next, _end) => { + (req as Record).value = 1; + return next(); + }); + legacyEngine.push(asLegacyMiddleware(v2Engine)); + legacyEngine.push((req, res, _next, end) => { + observedContextValues.push( + (req as Record).value as number, + ); + + expect(getExtraneousKeys(req)).toStrictEqual(['value']); + + res.result = null; + end(); + }); + + await legacyEngine.handle(makeRequest()); + expect(observedContextValues).toStrictEqual([1, 2]); + }); +}); diff --git a/packages/json-rpc-engine/src/asLegacyMiddleware.ts b/packages/json-rpc-engine/src/asLegacyMiddleware.ts new file mode 100644 index 0000000000..b6430a8225 --- /dev/null +++ b/packages/json-rpc-engine/src/asLegacyMiddleware.ts @@ -0,0 +1,50 @@ +import type { Json, JsonRpcParams, JsonRpcRequest } from '@metamask/utils'; + +import { + deepClone, + fromLegacyRequest, + makeContext, + propagateToRequest, +} from './compatibility-utils'; +import type { JsonRpcEngineV2 } from './JsonRpcEngineV2'; +import { createAsyncMiddleware } from './legacy'; +import type { JsonRpcMiddleware as LegacyMiddleware } from './legacy'; + +/** + * Convert a {@link JsonRpcEngineV2} into a legacy middleware. + * + * @param engine - The engine to convert. + * @returns The legacy middleware. + */ +export function asLegacyMiddleware< + Params extends JsonRpcParams, + Request extends JsonRpcRequest, + Result extends Json, +>(engine: JsonRpcEngineV2): LegacyMiddleware { + const middleware = engine.asMiddleware(); + return createAsyncMiddleware(async (req, res, next) => { + const request = fromLegacyRequest(req as Request); + const context = makeContext(req); + let modifiedRequest: Request | undefined; + + const result = await middleware({ + request, + context, + next: (finalRequest) => { + modifiedRequest = finalRequest; + return Promise.resolve(); + }, + }); + + if (modifiedRequest !== undefined && modifiedRequest !== request) { + Object.assign(req, deepClone(modifiedRequest)); + } + propagateToRequest(req, context); + + if (result !== undefined) { + res.result = result; + return undefined; + } + return next(); + }); +} diff --git a/packages/json-rpc-engine/src/compatibility-utils.test.ts b/packages/json-rpc-engine/src/compatibility-utils.test.ts new file mode 100644 index 0000000000..37137e7503 --- /dev/null +++ b/packages/json-rpc-engine/src/compatibility-utils.test.ts @@ -0,0 +1,467 @@ +import { JsonRpcError } from '@metamask/rpc-errors'; +import type { Json } from '@metamask/utils'; + +import { + deepClone, + fromLegacyRequest, + makeContext, + propagateToContext, + propagateToRequest, + unserializeError, +} from './compatibility-utils'; +import { MiddlewareContext } from './MiddlewareContext'; +import { stringify } from './utils'; + +const jsonrpc = '2.0' as const; + +describe('compatibility-utils', () => { + describe('deepClone', () => { + it('clones an object', () => { + const request = { + jsonrpc, + method: 'test_method', + params: [], + id: 1, + }; + const clonedRequest = deepClone(request); + + expect(clonedRequest).toStrictEqual(request); + expect(clonedRequest).not.toBe(request); + }); + }); + + describe('fromLegacyRequest', () => { + it('converts a request, preserving its properties', () => { + const legacyRequest = { + jsonrpc, + method: 'test_method', + params: [1, 2, 3], + id: 42, + }; + const request = fromLegacyRequest(legacyRequest); + + expect(request).toStrictEqual({ + jsonrpc, + method: 'test_method', + params: [1, 2, 3], + id: 42, + }); + }); + + it('clones params to avoid freezing them as part of the new request object', () => { + const params = [1, { a: 2 }]; + const legacyRequest = { + jsonrpc, + method: 'test_method', + params, + id: 42, + }; + const request = fromLegacyRequest(legacyRequest); + + expect(request.params).toStrictEqual(params); + expect(request.params).not.toBe(params); + expect(request.params?.[1]).not.toBe(params[1]); + }); + + it('handles requests without params', () => { + const legacyRequest = { + jsonrpc, + method: 'test_method', + id: 42, + }; + const request = fromLegacyRequest(legacyRequest); + + expect(request).toStrictEqual({ + jsonrpc, + method: 'test_method', + id: 42, + }); + }); + + it('handles requests with undefined params', () => { + const legacyRequest = { + jsonrpc, + method: 'test_method', + id: 42, + params: undefined, + }; + + // @ts-expect-error - Intentional abuse + const request = fromLegacyRequest(legacyRequest); + + expect(request).toStrictEqual({ + jsonrpc, + method: 'test_method', + id: 42, + }); + }); + + it('handles requests without a jsonrpc property', () => { + const legacyRequest = { + method: 'test_method', + params: [1], + id: 42, + }; + + // @ts-expect-error - Intentional abuse + const request = fromLegacyRequest(legacyRequest); + + expect(request).toStrictEqual({ + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + }); + }); + + it('handles requests with a faulty jsonrpc property', () => { + const legacyRequest = { + jsonrpc: '1.0', + method: 'test_method', + params: [1], + id: 42, + }; + + // @ts-expect-error - Intentional abuse + const request = fromLegacyRequest(legacyRequest); + + expect(request).toStrictEqual({ + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + }); + }); + + it('ignores additional properties on the legacy request', () => { + const legacyRequest = { + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + extraProp: 'value', + anotherProp: { nested: true }, + }; + const request = fromLegacyRequest(legacyRequest); + + expect(request).toStrictEqual({ + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + }); + }); + }); + + describe('makeContext', () => { + it('creates a middleware context from a valid JSON-RPC request', () => { + const request = { + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + }; + const context = makeContext(request); + + expect(context).toBeInstanceOf(MiddlewareContext); + expect(Array.from(context.keys())).toStrictEqual([]); + }); + + it('includes non-JSON-RPC properties from request in context', () => { + const request = { + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + extraProp: 'value', + anotherProp: { nested: true }, + }; + const context = makeContext(request); + + expect(Array.from(context.keys())).toStrictEqual([ + 'extraProp', + 'anotherProp', + ]); + }); + }); + + describe('propagateToContext', () => { + it('copies non-JSON-RPC properties from request to context', () => { + const request = { + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + extraProp: 'value', + anotherProp: { nested: true }, + }; + const context = new MiddlewareContext(); + + propagateToContext(request, context); + + expect(Array.from(context.keys())).toStrictEqual([ + 'extraProp', + 'anotherProp', + ]); + }); + + it('handles requests with no extra properties', () => { + const request = { + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + }; + const context = new MiddlewareContext(); + + propagateToContext(request, context); + + expect(Array.from(context.keys())).toStrictEqual([]); + }); + }); + + describe('propagateToRequest', () => { + it('copies non-JSON-RPC properties from context to request', () => { + const request = { + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + }; + const context = new MiddlewareContext(); + context.set('extraProp', 'value'); + context.set('anotherProp', { nested: true }); + + propagateToRequest(request, context); + + expect(request).toStrictEqual({ + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + extraProp: 'value', + anotherProp: { nested: true }, + }); + }); + + it('excludes JSON-RPC properties from propagation', () => { + const request = { + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + }; + const context = new MiddlewareContext(); + context.set('jsonrpc', '3.0'); + context.set('method', 'other_method'); + context.set('params', [2]); + context.set('id', 99); + context.set('extraProp', 'value'); + + propagateToRequest(request, context); + + expect(request).toStrictEqual({ + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + extraProp: 'value', + }); + }); + + it('overwrites existing request properties', () => { + const request = { + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + existingKey: 'oldValue', + }; + const context = new MiddlewareContext(); + context.set('existingKey', 'newValue'); + + propagateToRequest(request, context); + + expect(request.existingKey).toBe('newValue'); + }); + }); + + describe('unserializeError', () => { + // Requires some special handling due to the possible existence or + // non-existence of Error.isError + describe('Error.isError', () => { + const isErrorExists = 'isError' in Error; + let originalIsError: (value: unknown) => boolean; + let isError: jest.Mock; + + beforeAll(() => { + isError = jest.fn(); + // @ts-expect-error - Error type outdated + originalIsError = Error.isError; + // @ts-expect-error - Error type outdated + Error.isError = isError; + }); + + beforeEach(() => { + isError.mockClear(); + }); + + afterAll(() => { + if (isErrorExists) { + // @ts-expect-error - Error type outdated + Error.isError = originalIsError; + } else { + // @ts-expect-error - Error type outdated + delete Error.isError; + } + }); + + it('returns the thrown value when Error.isError is available and returns true', () => { + isError.mockReturnValueOnce(true); + const originalError = new Error('test error'); + + const result = unserializeError(originalError); + expect(result).toBe(originalError); + }); + + it('returns the thrown value when it is instanceof Error', () => { + isError.mockReturnValueOnce(false); + const originalError = new Error('test error'); + + const result = unserializeError(originalError); + expect(result).toBe(originalError); + }); + }); + + it('creates a new Error when thrown value is a string', () => { + const errorMessage = 'test error message'; + const result = unserializeError(errorMessage); + + expect(result).toBeInstanceOf(Error); + expect(result.message).toBe(errorMessage); + }); + + it.each([42, true, false, null, undefined, Symbol('test')])( + 'creates a new Error with stringified message for non-object values', + (value) => { + const result = unserializeError(value); + + expect(result).toBeInstanceOf(Error); + expect(result.message).toBe(`Unknown error: ${stringify(value)}`); + }, + ); + + it('creates a JsonRpcError when thrown value is an object with valid integer code', () => { + const thrownValue = { + code: 1234, + message: 'test error message', + cause: new Error('cause'), + data: { foo: 'bar' }, + }; + + const result = unserializeError(thrownValue); + + expect(result).toBeInstanceOf(JsonRpcError); + expect(result).toMatchObject({ + message: 'test error message', + code: 1234, + cause: thrownValue.cause, + data: { foo: 'bar' }, + }); + }); + + it('creates a plain Error when thrown value is an object without code property', () => { + const thrownValue = { + message: 'test error message', + cause: new Error('cause'), + data: { foo: 'bar' }, + }; + + const result = unserializeError(thrownValue); + + expect(result).toBeInstanceOf(Error); + expect(result).not.toBeInstanceOf(JsonRpcError); + expect(result).toStrictEqual( + // @ts-expect-error - Error type outdated + new Error('test error message', { cause: thrownValue.cause }), + ); + }); + + it('creates a plain Error when thrown value has non-integer code', () => { + const thrownValue = { + code: 123.45, + message: 'test error message', + }; + + const result = unserializeError(thrownValue); + + expect(result).toBeInstanceOf(Error); + expect(result).not.toBeInstanceOf(JsonRpcError); + expect(result).toStrictEqual(new Error('test error message')); + }); + + it('preserves stack trace when thrown value has stack property', () => { + const stackTrace = 'Error: test\n at test.js:1:1'; + const thrownValue = { + message: 'test error', + stack: stackTrace, + }; + + const result = unserializeError(thrownValue); + + expect(result).toBeInstanceOf(Error); + expect(result.stack).toBe(stackTrace); + }); + + it('preserves cause and data in JsonRpcError', () => { + const cause = new Error('original cause'); + const data = { custom: 'data' }; + const thrownValue = { + code: 1234, + message: 'test error', + cause, + data, + }; + + const result = unserializeError(thrownValue) as JsonRpcError; + + expect(result.cause).toBe(cause); + expect(result.data).toStrictEqual({ + ...data, + cause, + }); + }); + + it('uses default error message when message property is missing and code is unrecognized', () => { + const thrownValue = { + code: 1234, + }; + + const result = unserializeError(thrownValue); + + expect(result.message).toBe('Unknown error'); + }); + + it('uses default error message when message property is not a string and code is unrecognized', () => { + const thrownValue = { + code: 1234, + message: 42, + }; + + const result = unserializeError(thrownValue); + + expect(result.message).toBe('Unknown error'); + }); + + it('uses correct error message when message property is not a string and code is recognized', () => { + const thrownValue = { + code: -32603, + message: 42, + }; + + const result = unserializeError(thrownValue); + + expect(result.message).toBe('Internal JSON-RPC error.'); + }); + }); +}); diff --git a/packages/json-rpc-engine/src/compatibility-utils.ts b/packages/json-rpc-engine/src/compatibility-utils.ts new file mode 100644 index 0000000000..fb0c9bdfc3 --- /dev/null +++ b/packages/json-rpc-engine/src/compatibility-utils.ts @@ -0,0 +1,157 @@ +import { getMessageFromCode, JsonRpcError } from '@metamask/rpc-errors'; +import type { Json } from '@metamask/utils'; +import { hasProperty, isObject } from '@metamask/utils'; +import rfdc from 'rfdc'; + +import { MiddlewareContext } from './MiddlewareContext'; +import { stringify, type JsonRpcRequest } from './utils'; + +// Legacy engine compatibility utils + +/** + * Create a deep clone of a value. Assumes acyclical objects. Ignores the + * prototype chain. + * + * @param value - The value to clone. + * @returns The cloned value. + */ +export const deepClone = rfdc({ + circles: false, + proto: false, +}); + +/** + * Standard JSON-RPC request properties. + */ +const requestProps = ['jsonrpc', 'method', 'params', 'id']; + +/** + * Make a JSON-RPC request from a legacy request. Clones the params to avoid + * freezing them, which could cause errors in an involved legacy engine. + * + * @param req - The legacy request to make a request from. + * @returns The JSON-RPC request. + */ +export function fromLegacyRequest( + req: Request, +): Request { + const request = { + jsonrpc: '2.0' as const, + method: req.method, + } as Partial; + request.id = req.id; + if (hasProperty(req, 'params') && req.params !== undefined) { + request.params = deepClone(req.params); + } + return request as Request; +} + +/** + * Make a middleware context from a legacy request by copying over all non-JSON-RPC + * properties from the request to the context object. + * + * @param req - The legacy request to make a context from. + * @returns The middleware context. + */ +export function makeContext>( + req: Request, +): MiddlewareContext { + const context = new MiddlewareContext(); + propagateToContext(req, context); + return context; +} + +/** + * Copies non-JSON-RPC properties from the request to the context. + * + * For compatibility with our problematic practice of appending non-standard + * fields to requests for inter-middleware communication in the legacy engine. + * + * @param req - The request to propagate the context from. + * @param context - The context to propagate to. + */ +export function propagateToContext( + req: Record, + context: MiddlewareContext, +) { + Object.keys(req) + .filter((key) => !requestProps.includes(key)) + .forEach((key) => { + context.set(key, req[key]); + }); +} + +/** + * Copies non-JSON-RPC properties from the context to the request. + * + * For compatibility with our problematic practice of appending non-standard + * fields to requests for inter-middleware communication in the legacy engine. + * + * @param req - The request to propagate the context to. + * @param context - The context to propagate from. + */ +export function propagateToRequest( + req: Record, + context: MiddlewareContext, +) { + Array.from(context.keys()) + .filter((key) => !requestProps.includes(key)) + .forEach((key) => { + req[key] = context.get(key); + }); +} + +/** + * Unserialize an error from a thrown value. Creates a {@link JsonRpcError} if + * the thrown value is an object with a `code` property. Otherwise, creates a + * plain {@link Error}. + * + * @param thrown - The thrown value to unserialize. + * @returns The unserialized error. + */ +export function unserializeError(thrown: unknown): Error | JsonRpcError { + // @ts-expect-error - New, but preferred if available. + // See: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/isError + if (typeof Error.isError === 'function' && Error.isError(thrown)) { + return thrown as Error; + } + // Unlike Error.isError, instanceof does not work for Errors from other realms. + if (thrown instanceof Error) { + return thrown; + } + if (typeof thrown === 'string') { + return new Error(thrown); + } + if (!isObject(thrown)) { + return new Error(`Unknown error: ${stringify(thrown)}`); + } + + const code = + typeof thrown.code === 'number' && Number.isInteger(thrown.code) + ? thrown.code + : undefined; + + let message = 'Unknown error'; + if (typeof thrown.message === 'string') { + message = thrown.message; + } else if (typeof code === 'number') { + message = getMessageFromCode(code, message); + } + + const { stack, cause, data } = thrown; + + const error = + code === undefined + ? // @ts-expect-error - Error type outdated + new Error(message, { cause }) + : new JsonRpcError(code, message, { + ...(isObject(data) ? data : undefined), + cause, + }); + + if (typeof stack === 'string') { + error.stack = stack; + } + + return error; +} diff --git a/packages/json-rpc-engine/src/index.test.ts b/packages/json-rpc-engine/src/index.test.ts index 761d75caa7..bcdac7b0f3 100644 --- a/packages/json-rpc-engine/src/index.test.ts +++ b/packages/json-rpc-engine/src/index.test.ts @@ -4,6 +4,7 @@ describe('@metamask/json-rpc-engine', () => { it('has expected JavaScript exports', () => { expect(Object.keys(allExports)).toMatchInlineSnapshot(` Array [ + "asLegacyMiddleware", "getUniqueId", "JsonRpcServer", "isNotification", diff --git a/packages/json-rpc-engine/src/index.ts b/packages/json-rpc-engine/src/index.ts index 17d4425bad..db4636bd85 100644 --- a/packages/json-rpc-engine/src/index.ts +++ b/packages/json-rpc-engine/src/index.ts @@ -1,3 +1,4 @@ +export { asLegacyMiddleware } from './asLegacyMiddleware'; export { getUniqueId } from './getUniqueId'; export * from './JsonRpcEngineV2'; export { JsonRpcServer } from './JsonRpcServer'; diff --git a/packages/json-rpc-engine/src/legacy/asV2Middleware.test.ts b/packages/json-rpc-engine/src/legacy/asV2Middleware.test.ts new file mode 100644 index 0000000000..cf59735858 --- /dev/null +++ b/packages/json-rpc-engine/src/legacy/asV2Middleware.test.ts @@ -0,0 +1,113 @@ +import { rpcErrors } from '@metamask/rpc-errors'; +import type { Json, JsonRpcRequest } from '@metamask/utils'; + +import { JsonRpcEngine } from '.'; +import { asV2Middleware } from './asV2Middleware'; +import { getExtraneousKeys, makeRequest } from '../../tests/utils'; +import { JsonRpcEngineV2 } from '../JsonRpcEngineV2'; + +describe('asV2Middleware', () => { + it('converts a legacy engine to a v2 middleware', () => { + const engine = new JsonRpcEngine(); + const middleware = asV2Middleware(engine); + expect(typeof middleware).toBe('function'); + }); + + it('forwards a result to the v2 engine', async () => { + const legacyEngine = new JsonRpcEngine(); + legacyEngine.push((_req, res, _next, end) => { + res.result = null; + end(); + }); + + const v2Engine = new JsonRpcEngineV2({ + middleware: [asV2Middleware(legacyEngine)], + }); + + const result = await v2Engine.handle(makeRequest()); + expect(result).toBeNull(); + }); + + it('forwards an error to the v2 engine', async () => { + const legacyEngine = new JsonRpcEngine(); + legacyEngine.push((_req, res, _next, end) => { + res.error = rpcErrors.internal('test'); + end(); + }); + + const v2Engine = new JsonRpcEngineV2({ + middleware: [asV2Middleware(legacyEngine)], + }); + + await expect(v2Engine.handle(makeRequest())).rejects.toThrow( + rpcErrors.internal('test'), + ); + }); + + it('forwards a serialized error to the v2 engine', async () => { + const legacyEngine = new JsonRpcEngine(); + legacyEngine.push((_req, res, _next, end) => { + res.error = { message: 'test', code: 1000 }; + end(); + }); + + const v2Engine = new JsonRpcEngineV2({ + middleware: [asV2Middleware(legacyEngine)], + }); + + await expect(v2Engine.handle(makeRequest())).rejects.toThrow( + new Error('test'), + ); + }); + + it('allows the v2 engine to continue when not ending the request', async () => { + const legacyEngine = new JsonRpcEngine(); + const legacyMiddleware = jest.fn((_req, _res, next) => { + next(); + }); + legacyEngine.push(legacyMiddleware); + + const v2Engine = new JsonRpcEngineV2({ + middleware: [asV2Middleware(legacyEngine), () => null], + }); + + const result = await v2Engine.handle(makeRequest()); + expect(result).toBeNull(); + expect(legacyMiddleware).toHaveBeenCalledTimes(1); + }); + + it('propagates the context to the legacy request and back', async () => { + const observedContextValues: number[] = []; + + const legacyEngine = new JsonRpcEngine(); + const legacyMiddleware = jest.fn((req, _res, next) => { + observedContextValues.push(req.value); + + expect(getExtraneousKeys(req)).toStrictEqual(['value']); + + req.value = 2; + next(); + }); + legacyEngine.push(legacyMiddleware); + + const v2Engine = new JsonRpcEngineV2({ + middleware: [ + ({ context, next }) => { + context.set('value', 1); + return next(); + }, + asV2Middleware(legacyEngine), + ({ context }) => { + observedContextValues.push(context.assertGet('value')); + + expect(Array.from(context.keys())).toStrictEqual(['value']); + + return null; + }, + ], + }); + + await v2Engine.handle(makeRequest()); + expect(observedContextValues).toStrictEqual([1, 2]); + }); +}); diff --git a/packages/json-rpc-engine/src/legacy/asV2Middleware.ts b/packages/json-rpc-engine/src/legacy/asV2Middleware.ts new file mode 100644 index 0000000000..a8c8c70dae --- /dev/null +++ b/packages/json-rpc-engine/src/legacy/asV2Middleware.ts @@ -0,0 +1,73 @@ +import { serializeError } from '@metamask/rpc-errors'; +import type { JsonRpcFailure, JsonRpcResponse } from '@metamask/utils'; +import { + type Json, + type JsonRpcParams, + type JsonRpcRequest, +} from '@metamask/utils'; + +import type { + JsonRpcEngine, + JsonRpcEngineEndCallback, + JsonRpcEngineNextCallback, +} from './JsonRpcEngine'; +import { + deepClone, + fromLegacyRequest, + propagateToContext, + propagateToRequest, + unserializeError, +} from '../compatibility-utils'; +// JsonRpcEngineV2 is used in docs. +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import type { JsonRpcMiddleware, JsonRpcEngineV2 } from '../JsonRpcEngineV2'; + +/** + * Convert a legacy {@link JsonRpcEngine} into a {@link JsonRpcEngineV2} middleware. + * + * @param engine - The legacy engine to convert. + * @returns The {@link JsonRpcEngineV2} middleware. + */ +export function asV2Middleware< + Params extends JsonRpcParams, + Request extends JsonRpcRequest, + Result extends Json, +>(engine: JsonRpcEngine): JsonRpcMiddleware { + const middleware = engine.asMiddleware(); + return async ({ request, context, next }): Promise => { + const req = deepClone(request) as JsonRpcRequest; + propagateToRequest(req, context); + + const response = await new Promise((resolve) => { + // The result or error property will be set by the legacy engine + // middleware. + const res = { + jsonrpc: '2.0' as const, + id: req.id, + } as JsonRpcResponse; + + const end: JsonRpcEngineEndCallback = (error) => { + if (error !== undefined) { + (res as JsonRpcFailure).error = serializeError(error); + } + resolve(res); + }; + + // We know from the implementation of JsonRpcEngine.asMiddleware() that + // legacyNext will always be passed a callback, so cb can never be + // undefined. + const legacyNext = ((cb: JsonRpcEngineEndCallback) => + cb(end)) as JsonRpcEngineNextCallback; + + middleware(req, res, legacyNext, end); + }); + propagateToContext(req, context); + + if ('error' in response) { + throw unserializeError(response.error); + } else if ('result' in response) { + return response.result as Result; + } + return next(fromLegacyRequest(req as Request)); + }; +} diff --git a/packages/json-rpc-engine/src/legacy/index.test.ts b/packages/json-rpc-engine/src/legacy/index.test.ts index 4f6942ef55..11b63a46b6 100644 --- a/packages/json-rpc-engine/src/legacy/index.test.ts +++ b/packages/json-rpc-engine/src/legacy/index.test.ts @@ -4,6 +4,7 @@ describe('@metamask/json-rpc-engine/legacy', () => { it('has expected JavaScript exports', () => { expect(Object.keys(allExports)).toMatchInlineSnapshot(` Array [ + "asV2Middleware", "createAsyncMiddleware", "createScaffoldMiddleware", "createIdRemapMiddleware", diff --git a/packages/json-rpc-engine/src/legacy/index.ts b/packages/json-rpc-engine/src/legacy/index.ts index 82460dfc15..45ad138495 100644 --- a/packages/json-rpc-engine/src/legacy/index.ts +++ b/packages/json-rpc-engine/src/legacy/index.ts @@ -1,3 +1,4 @@ +export { asV2Middleware } from './asV2Middleware'; export type { AsyncJsonRpcEngineNextCallback, AsyncJsonrpcMiddleware, diff --git a/packages/json-rpc-engine/tests/utils.ts b/packages/json-rpc-engine/tests/utils.ts new file mode 100644 index 0000000000..a42aca9251 --- /dev/null +++ b/packages/json-rpc-engine/tests/utils.ts @@ -0,0 +1,25 @@ +import type { JsonRpcRequest } from '@metamask/utils'; + +export const makeRequest = ( + params: Partial = {}, +): Request => + ({ + jsonrpc: '2.0' as const, + id: '1', + method: 'test_request', + params: [] as Request['params'], + ...params, + }) as Request; + +const requestProps = ['jsonrpc', 'method', 'params', 'id']; + +/** + * Get the keys of a request that are not part of the standard JSON-RPC request + * properties. + * + * @param req - The request to get the extraneous keys from. + * @returns The extraneous keys. + */ +export function getExtraneousKeys(req: Record): string[] { + return Object.keys(req).filter((key) => !requestProps.includes(key)); +} diff --git a/yarn.lock b/yarn.lock index 394dd9e56e..6734f4a9da 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3602,6 +3602,7 @@ __metadata: deepmerge: "npm:^4.2.2" jest: "npm:^27.5.1" jest-it-up: "npm:^2.0.2" + rfdc: "npm:^1.4.1" ts-jest: "npm:^27.1.4" typedoc: "npm:^0.24.8" typescript: "npm:~5.2.2" @@ -13289,7 +13290,7 @@ __metadata: languageName: node linkType: hard -"rfdc@npm:^1.3.0": +"rfdc@npm:^1.3.0, rfdc@npm:^1.4.1": version: 1.4.1 resolution: "rfdc@npm:1.4.1" checksum: 10/2f3d11d3d8929b4bfeefc9acb03aae90f971401de0add5ae6c5e38fec14f0405e6a4aad8fdb76344bfdd20c5193110e3750cbbd28ba86d73729d222b6cf4a729 From d45fcd353dd55418e9042d4158b2b1e3673cd49a Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Wed, 23 Jul 2025 10:41:57 -0700 Subject: [PATCH 30/75] refactor: Refactor #handle() --- .../json-rpc-engine/src/JsonRpcEngineV2.ts | 108 ++++++++++++------ 1 file changed, 70 insertions(+), 38 deletions(-) diff --git a/packages/json-rpc-engine/src/JsonRpcEngineV2.ts b/packages/json-rpc-engine/src/JsonRpcEngineV2.ts index b1f97c78f3..7f74283d12 100644 --- a/packages/json-rpc-engine/src/JsonRpcEngineV2.ts +++ b/packages/json-rpc-engine/src/JsonRpcEngineV2.ts @@ -36,6 +36,11 @@ export type JsonRpcMiddleware< params: MiddlewareParams, ) => Readonly | void | Promise | void>; +type RequestState = { + request: Request; + result: Result | void; +}; + type Options = { middleware: NonEmptyArray>; }; @@ -127,23 +132,50 @@ export class JsonRpcEngineV2 { context: MiddlewareContext = new MiddlewareContext(), ): Promise<{ result: Result | void; - finalRequest: Readonly; + request: Readonly; }> { deepFreeze(originalRequest); - let currentRequest = originalRequest; - // Either ESLint or TypeScript complains. - // eslint-disable-next-line no-undef-init - let currentResult: Result | void = undefined; + const state: RequestState = { + request: originalRequest, + result: undefined, + }; const middlewareIterator = this.#makeMiddlewareIterator(); - const firstMiddleware: JsonRpcMiddleware = - middlewareIterator.next().value; + const firstMiddleware = middlewareIterator.next().value; + + const makeNext = this.#makeNextFactory(middlewareIterator, state, context); + + const result = await firstMiddleware({ + request: originalRequest, + context, + next: makeNext(), + }); + this.#updateResult(result, state); + + return state; + } + /** + * Create a factory of `next()` functions for use with a particular request. + * The factory is recursive, and a new `next()` is created for each middleware + * invocation. + * + * @param middlewareIterator - The iterator of middleware for the current + * request. + * @param state - The current values of the request and result. + * @param context - The context to pass to the middleware. + * @returns The `next()` function factory. + */ + #makeNextFactory( + middlewareIterator: Iterator>, + state: RequestState, + context: MiddlewareContext, + ): () => Next { const makeNext = (): Next => { let wasCalled = false; const next = async ( - request: Request = currentRequest, + request: Request = state.request, ): Promise => { if (wasCalled) { throw new JsonRpcEngineError( @@ -152,9 +184,9 @@ export class JsonRpcEngineV2 { } wasCalled = true; - if (request !== currentRequest) { - this.#assertValidNextRequest(currentRequest, request); - currentRequest = deepFreeze(request); + if (request !== state.request) { + this.#assertValidNextRequest(state.request, request); + state.request = deepFreeze(request); } const { value: middleware, done } = middlewareIterator.next(); @@ -163,50 +195,47 @@ export class JsonRpcEngineV2 { } const result = await middleware({ request, context, next: makeNext() }); - currentResult = this.#processResult( - result, - currentResult, - currentRequest, - ); + this.#updateResult(result, state); - return currentResult; + return state.result; }; return next; }; - const result = await firstMiddleware({ - request: originalRequest, - context, - next: makeNext(), - }); - currentResult = this.#processResult(result, currentResult, currentRequest); - - return { - result: currentResult, - finalRequest: currentRequest, - }; + return makeNext; } - #processResult( + /** + * Validate the result from a middleware and, if it's a new value, update the + * current result. + * + * @param result - The result from the middleware. + * @param state - The current values of the request and result. + */ + #updateResult( result: Result | void, - currentResult: Result | void, - request: Request, - ): Result | void { - if (isNotification(request) && result !== undefined) { + state: RequestState, + ): void { + if (isNotification(state.request) && result !== undefined) { throw new JsonRpcEngineError( - `Result returned for notification: ${stringify(request)}`, + `Result returned for notification: ${stringify(state.request)}`, ); } - if (result !== undefined && result !== currentResult) { + if (result !== undefined && result !== state.result) { if (typeof result === 'object' && result !== null) { deepFreeze(result); } - return result; + state.result = result; } - return currentResult; } + /** + * Assert that a request modified by a middleware is valid. + * + * @param currentRequest - The current request. + * @param nextRequest - The next request. + */ #assertValidNextRequest(currentRequest: Request, nextRequest: Request): void { if (nextRequest.jsonrpc !== currentRequest.jsonrpc) { throw new JsonRpcEngineError( @@ -232,7 +261,10 @@ export class JsonRpcEngineV2 { */ asMiddleware(): JsonRpcMiddleware { return async ({ request, context, next }) => { - const { result, finalRequest } = await this.#handle(request, context); + const { result, request: finalRequest } = await this.#handle( + request, + context, + ); return result === undefined ? await next(finalRequest) : result; }; } From 8a5c07fa56b27a19083f0a8a467326b4b6a76293 Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Wed, 23 Jul 2025 11:01:32 -0700 Subject: [PATCH 31/75] feat: Add destroy() method --- .../src/JsonRpcEngineV2.test.ts | 97 +++++++++++++++++++ .../json-rpc-engine/src/JsonRpcEngineV2.ts | 40 +++++++- 2 files changed, 136 insertions(+), 1 deletion(-) diff --git a/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts b/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts index 52b9981f14..7209e891c8 100644 --- a/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts +++ b/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts @@ -1,4 +1,6 @@ /* eslint-disable n/callback-return */ // next() is not a Node.js callback. +import type { NonEmptyArray } from '@metamask/utils'; + import type { JsonRpcMiddleware } from './JsonRpcEngineV2'; import { JsonRpcEngineV2 } from './JsonRpcEngineV2'; import { @@ -718,4 +720,99 @@ describe('JsonRpcEngineV2', () => { expect(returnHandlerResults).toStrictEqual(['1:b', '1:a', '2:b', '2:a']); }); }); + + describe('destroy', () => { + it('calls the destroy method of any middleware that has one', async () => { + const middleware = { + destroy: jest.fn(), + }; + const engine = new JsonRpcEngineV2({ + middleware: [middleware as unknown as JsonRpcMiddleware], + }); + + engine.destroy(); + + expect(middleware.destroy).toHaveBeenCalledTimes(1); + }); + + it('is idempotent', () => { + const middleware = { + destroy: jest.fn(), + }; + + const engine = new JsonRpcEngineV2({ + middleware: [middleware as unknown as JsonRpcMiddleware], + }); + + engine.destroy(); + engine.destroy(); + + expect(middleware.destroy).toHaveBeenCalledTimes(1); + }); + + it('causes handle() to throw after destroying the engine', async () => { + const engine = new JsonRpcEngineV2({ + middleware: [() => null], + }); + + engine.destroy(); + + await expect(engine.handle(makeRequest())).rejects.toThrow( + new JsonRpcEngineError('Engine is destroyed'), + ); + }); + + it('causes asMiddleware() to throw after destroying the engine', async () => { + const engine = new JsonRpcEngineV2({ + middleware: [() => null], + }); + engine.destroy(); + + expect(() => engine.asMiddleware()).toThrow( + new JsonRpcEngineError('Engine is destroyed'), + ); + }); + + it('logs an error if a middleware throws when destroying', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + const middleware = { + destroy: jest.fn(() => { + throw new Error('test'); + }), + }; + const engine = new JsonRpcEngineV2({ + middleware: [middleware as unknown as JsonRpcMiddleware], + }); + + engine.destroy(); + await new Promise((resolve) => setImmediate(resolve)); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error destroying middleware:', + new Error('test'), + ); + }); + + it('calls the destroy() method of each middleware even if one throws', async () => { + const middleware1 = { + destroy: jest.fn(() => { + throw new Error('test'); + }), + }; + const middleware2 = { + destroy: jest.fn(), + }; + const engine = new JsonRpcEngineV2({ + middleware: [ + middleware1, + middleware2, + ] as unknown as NonEmptyArray, + }); + + engine.destroy(); + + expect(middleware1.destroy).toHaveBeenCalledTimes(1); + expect(middleware2.destroy).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/packages/json-rpc-engine/src/JsonRpcEngineV2.ts b/packages/json-rpc-engine/src/JsonRpcEngineV2.ts index 7f74283d12..4deeceaa96 100644 --- a/packages/json-rpc-engine/src/JsonRpcEngineV2.ts +++ b/packages/json-rpc-engine/src/JsonRpcEngineV2.ts @@ -68,7 +68,7 @@ type Options = { * ``` */ export class JsonRpcEngineV2 { - readonly #middleware: Readonly< + #middleware: Readonly< NonEmptyArray> >; @@ -76,6 +76,8 @@ export class JsonRpcEngineV2 { JsonRpcMiddleware > => this.#middleware[Symbol.iterator](); + #isDestroyed = false; + constructor({ middleware }: Options) { this.#middleware = [...middleware]; } @@ -134,6 +136,8 @@ export class JsonRpcEngineV2 { result: Result | void; request: Readonly; }> { + this.#assertIsNotDestroyed(); + deepFreeze(originalRequest); const state: RequestState = { @@ -260,6 +264,8 @@ export class JsonRpcEngineV2 { * @returns The JSON-RPC middleware. */ asMiddleware(): JsonRpcMiddleware { + this.#assertIsNotDestroyed(); + return async ({ request, context, next }) => { const { result, request: finalRequest } = await this.#handle( request, @@ -268,4 +274,36 @@ export class JsonRpcEngineV2 { return result === undefined ? await next(finalRequest) : result; }; } + + /** + * Destroy the engine. Calls the `destroy()` method of any middleware that has + * one. Attempting to use the engine after destroying it will throw an error. + */ + destroy(): void { + if (this.#isDestroyed) { + return; + } + + this.#isDestroyed = true; + Promise.all( + this.#middleware.map(async (middleware) => { + if ( + 'destroy' in middleware && + typeof middleware.destroy === 'function' + ) { + return middleware.destroy(); + } + return undefined; + }), + ).catch((error) => { + console.error('Error destroying middleware:', error); + }); + this.#middleware = [] as never; + } + + #assertIsNotDestroyed(): void { + if (this.#isDestroyed) { + throw new JsonRpcEngineError('Engine is destroyed'); + } + } } From ef48814e7eafd32d7c6527a1e336fd39865c7134 Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Wed, 23 Jul 2025 15:51:07 -0700 Subject: [PATCH 32/75] refactor: Revert legacy export path, add v2 export path --- eslint-warning-thresholds.json | 4 +-- .../src/BaseController.test.ts | 2 +- .../src/ComposableController.test.ts | 2 +- .../src/wallet-getPermissions.ts | 2 +- .../src/wallet-requestPermissions.ts | 2 +- .../src/wallet-revokePermissions.ts | 2 +- .../src/provider-from-engine.test.ts | 2 +- .../src/provider-from-engine.ts | 2 +- .../src/provider-from-middleware.test.ts | 2 +- .../src/provider-from-middleware.ts | 4 +-- .../src/safe-event-emitter-provider.test.ts | 2 +- .../src/safe-event-emitter-provider.ts | 2 +- packages/json-rpc-engine/README.md | 8 +++--- packages/json-rpc-engine/package.json | 10 ++++---- .../src/{legacy => }/JsonRpcEngine.test.ts | 0 .../src/{legacy => }/JsonRpcEngine.ts | 2 +- .../src/{legacy => }/README.md | 2 +- .../src/{legacy => }/asMiddleware.test.ts | 0 .../src/{legacy => }/asV2Middleware.test.ts | 4 +-- .../src/{legacy => }/asV2Middleware.ts | 4 +-- .../createAsyncMiddleware.test.ts | 0 .../src/{legacy => }/createAsyncMiddleware.ts | 0 .../createScaffoldMiddleware.test.ts | 0 .../{legacy => }/createScaffoldMiddleware.ts | 0 .../{legacy => }/idRemapMiddleware.test.ts | 0 .../src/{legacy => }/idRemapMiddleware.ts | 2 +- packages/json-rpc-engine/src/index.test.ts | 13 +++++----- packages/json-rpc-engine/src/index.ts | 25 +++++++++++++------ .../json-rpc-engine/src/legacy/index.test.ts | 16 ------------ packages/json-rpc-engine/src/legacy/index.ts | 18 ------------- .../src/{legacy => }/mergeMiddleware.test.ts | 0 .../src/{legacy => }/mergeMiddleware.ts | 0 .../src/{ => v2}/JsonRpcEngineV2.test.ts | 2 +- .../src/{ => v2}/JsonRpcEngineV2.ts | 0 .../src/{ => v2}/JsonRpcServer.test.ts | 0 .../src/{ => v2}/JsonRpcServer.ts | 2 +- .../src/{ => v2}/MiddlewareContext.test.ts | 0 .../src/{ => v2}/MiddlewareContext.ts | 0 packages/json-rpc-engine/src/v2/README.md | 3 +++ .../src/{ => v2}/asLegacyMiddleware.test.ts | 4 +-- .../src/{ => v2}/asLegacyMiddleware.ts | 4 +-- .../src/{ => v2}/compatibility-utils.test.ts | 0 .../src/{ => v2}/compatibility-utils.ts | 0 packages/json-rpc-engine/src/v2/index.test.ts | 17 +++++++++++++ packages/json-rpc-engine/src/v2/index.ts | 7 ++++++ .../src/{ => v2}/utils.test.ts | 0 .../json-rpc-engine/src/{ => v2}/utils.ts | 0 packages/json-rpc-engine/v2.js | 3 +++ .../src/createEngineStream.ts | 2 +- .../src/createStreamMiddleware.ts | 2 +- .../src/index.test.ts | 2 +- .../src/handlers/wallet-createSession.ts | 2 +- .../src/handlers/wallet-revokeSession.ts | 2 +- .../MultichainMiddlewareManager.ts | 2 +- ...multichainMethodCallValidatorMiddleware.ts | 2 +- .../src/create-network-client.ts | 4 +-- .../src/PermissionController.test.ts | 2 +- .../src/permission-middleware.ts | 4 +-- .../src/rpc-methods/getPermissions.test.ts | 2 +- .../src/rpc-methods/getPermissions.ts | 2 +- .../rpc-methods/requestPermissions.test.ts | 2 +- .../src/rpc-methods/requestPermissions.ts | 2 +- .../src/rpc-methods/revokePermissions.test.ts | 2 +- .../src/rpc-methods/revokePermissions.ts | 2 +- packages/permission-controller/src/utils.ts | 2 +- .../src/PermissionLogController.ts | 2 +- .../tests/PermissionLogController.test.ts | 2 +- .../src/SelectedNetworkMiddleware.ts | 2 +- .../tests/SelectedNetworkMiddleware.test.ts | 2 +- tests/fake-provider.ts | 2 +- 70 files changed, 114 insertions(+), 108 deletions(-) rename packages/json-rpc-engine/src/{legacy => }/JsonRpcEngine.test.ts (100%) rename packages/json-rpc-engine/src/{legacy => }/JsonRpcEngine.ts (99%) rename packages/json-rpc-engine/src/{legacy => }/README.md (99%) rename packages/json-rpc-engine/src/{legacy => }/asMiddleware.test.ts (100%) rename packages/json-rpc-engine/src/{legacy => }/asV2Middleware.test.ts (96%) rename packages/json-rpc-engine/src/{legacy => }/asV2Middleware.ts (95%) rename packages/json-rpc-engine/src/{legacy => }/createAsyncMiddleware.test.ts (100%) rename packages/json-rpc-engine/src/{legacy => }/createAsyncMiddleware.ts (100%) rename packages/json-rpc-engine/src/{legacy => }/createScaffoldMiddleware.test.ts (100%) rename packages/json-rpc-engine/src/{legacy => }/createScaffoldMiddleware.ts (100%) rename packages/json-rpc-engine/src/{legacy => }/idRemapMiddleware.test.ts (100%) rename packages/json-rpc-engine/src/{legacy => }/idRemapMiddleware.ts (94%) delete mode 100644 packages/json-rpc-engine/src/legacy/index.test.ts delete mode 100644 packages/json-rpc-engine/src/legacy/index.ts rename packages/json-rpc-engine/src/{legacy => }/mergeMiddleware.test.ts (100%) rename packages/json-rpc-engine/src/{legacy => }/mergeMiddleware.ts (100%) rename packages/json-rpc-engine/src/{ => v2}/JsonRpcEngineV2.test.ts (99%) rename packages/json-rpc-engine/src/{ => v2}/JsonRpcEngineV2.ts (100%) rename packages/json-rpc-engine/src/{ => v2}/JsonRpcServer.test.ts (100%) rename packages/json-rpc-engine/src/{ => v2}/JsonRpcServer.ts (99%) rename packages/json-rpc-engine/src/{ => v2}/MiddlewareContext.test.ts (100%) rename packages/json-rpc-engine/src/{ => v2}/MiddlewareContext.ts (100%) create mode 100644 packages/json-rpc-engine/src/v2/README.md rename packages/json-rpc-engine/src/{ => v2}/asLegacyMiddleware.test.ts (97%) rename packages/json-rpc-engine/src/{ => v2}/asLegacyMiddleware.ts (91%) rename packages/json-rpc-engine/src/{ => v2}/compatibility-utils.test.ts (100%) rename packages/json-rpc-engine/src/{ => v2}/compatibility-utils.ts (100%) create mode 100644 packages/json-rpc-engine/src/v2/index.test.ts create mode 100644 packages/json-rpc-engine/src/v2/index.ts rename packages/json-rpc-engine/src/{ => v2}/utils.test.ts (100%) rename packages/json-rpc-engine/src/{ => v2}/utils.ts (100%) create mode 100644 packages/json-rpc-engine/v2.js diff --git a/eslint-warning-thresholds.json b/eslint-warning-thresholds.json index 02188aa566..0114cb500b 100644 --- a/eslint-warning-thresholds.json +++ b/eslint-warning-thresholds.json @@ -191,10 +191,10 @@ "packages/gas-fee-controller/src/determineGasFeeCalculations.ts": { "jsdoc/tag-lines": 4 }, - "packages/json-rpc-engine/src/legacy/JsonRpcEngine.test.ts": { + "packages/json-rpc-engine/src/JsonRpcEngine.test.ts": { "jest/no-conditional-in-test": 2 }, - "packages/json-rpc-engine/src/legacy/JsonRpcEngine.ts": { + "packages/json-rpc-engine/src/JsonRpcEngine.ts": { "@typescript-eslint/prefer-promise-reject-errors": 2 }, "packages/json-rpc-middleware-stream/src/index.test.ts": { diff --git a/packages/base-controller/src/BaseController.test.ts b/packages/base-controller/src/BaseController.test.ts index 0eb36fe4c9..9fe417892c 100644 --- a/packages/base-controller/src/BaseController.test.ts +++ b/packages/base-controller/src/BaseController.test.ts @@ -14,7 +14,7 @@ import { } from './BaseController'; import { Messenger } from './Messenger'; import type { RestrictedMessenger } from './RestrictedMessenger'; -import { JsonRpcEngine } from '../../json-rpc-engine/src/legacy'; +import { JsonRpcEngine } from '../../json-rpc-engine/src'; export const countControllerName = 'CountController'; diff --git a/packages/composable-controller/src/ComposableController.test.ts b/packages/composable-controller/src/ComposableController.test.ts index d9fcabe4bc..c5c8bce434 100644 --- a/packages/composable-controller/src/ComposableController.test.ts +++ b/packages/composable-controller/src/ComposableController.test.ts @@ -1,6 +1,6 @@ import type { RestrictedMessenger } from '@metamask/base-controller'; import { BaseController, Messenger } from '@metamask/base-controller'; -import { JsonRpcEngine } from '@metamask/json-rpc-engine/legacy'; +import { JsonRpcEngine } from '@metamask/json-rpc-engine'; import type { Patch } from 'immer'; import * as sinon from 'sinon'; diff --git a/packages/eip1193-permission-middleware/src/wallet-getPermissions.ts b/packages/eip1193-permission-middleware/src/wallet-getPermissions.ts index eec622f98c..e6fc15be93 100644 --- a/packages/eip1193-permission-middleware/src/wallet-getPermissions.ts +++ b/packages/eip1193-permission-middleware/src/wallet-getPermissions.ts @@ -7,7 +7,7 @@ import { import type { AsyncJsonRpcEngineNextCallback, JsonRpcEngineEndCallback, -} from '@metamask/json-rpc-engine/legacy'; +} from '@metamask/json-rpc-engine'; import { type CaveatSpecificationConstraint, MethodNames, diff --git a/packages/eip1193-permission-middleware/src/wallet-requestPermissions.ts b/packages/eip1193-permission-middleware/src/wallet-requestPermissions.ts index 3e63576e68..06fd2b983d 100644 --- a/packages/eip1193-permission-middleware/src/wallet-requestPermissions.ts +++ b/packages/eip1193-permission-middleware/src/wallet-requestPermissions.ts @@ -8,7 +8,7 @@ import { isPlainObject } from '@metamask/controller-utils'; import type { AsyncJsonRpcEngineNextCallback, JsonRpcEngineEndCallback, -} from '@metamask/json-rpc-engine/legacy'; +} from '@metamask/json-rpc-engine'; import { type Caveat, type CaveatSpecificationConstraint, diff --git a/packages/eip1193-permission-middleware/src/wallet-revokePermissions.ts b/packages/eip1193-permission-middleware/src/wallet-revokePermissions.ts index fa5454aa4c..af9b2ccf2b 100644 --- a/packages/eip1193-permission-middleware/src/wallet-revokePermissions.ts +++ b/packages/eip1193-permission-middleware/src/wallet-revokePermissions.ts @@ -2,7 +2,7 @@ import { Caip25EndowmentPermissionName } from '@metamask/chain-agnostic-permissi import type { AsyncJsonRpcEngineNextCallback, JsonRpcEngineEndCallback, -} from '@metamask/json-rpc-engine/legacy'; +} from '@metamask/json-rpc-engine'; import { invalidParams, MethodNames } from '@metamask/permission-controller'; import { isNonEmptyArray, diff --git a/packages/eth-json-rpc-provider/src/provider-from-engine.test.ts b/packages/eth-json-rpc-provider/src/provider-from-engine.test.ts index be5e51c9b5..ca90a1deac 100644 --- a/packages/eth-json-rpc-provider/src/provider-from-engine.test.ts +++ b/packages/eth-json-rpc-provider/src/provider-from-engine.test.ts @@ -1,4 +1,4 @@ -import { JsonRpcEngine } from '@metamask/json-rpc-engine/legacy'; +import { JsonRpcEngine } from '@metamask/json-rpc-engine'; import { providerErrors } from '@metamask/rpc-errors'; import { providerFromEngine } from './provider-from-engine'; diff --git a/packages/eth-json-rpc-provider/src/provider-from-engine.ts b/packages/eth-json-rpc-provider/src/provider-from-engine.ts index 18ba797aef..00c62bd543 100644 --- a/packages/eth-json-rpc-provider/src/provider-from-engine.ts +++ b/packages/eth-json-rpc-provider/src/provider-from-engine.ts @@ -1,4 +1,4 @@ -import type { JsonRpcEngine } from '@metamask/json-rpc-engine/legacy'; +import type { JsonRpcEngine } from '@metamask/json-rpc-engine'; import { SafeEventEmitterProvider } from './safe-event-emitter-provider'; diff --git a/packages/eth-json-rpc-provider/src/provider-from-middleware.test.ts b/packages/eth-json-rpc-provider/src/provider-from-middleware.test.ts index 881b54c8f6..c3e73d3153 100644 --- a/packages/eth-json-rpc-provider/src/provider-from-middleware.test.ts +++ b/packages/eth-json-rpc-provider/src/provider-from-middleware.test.ts @@ -1,4 +1,4 @@ -import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine/legacy'; +import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine'; import { providerErrors } from '@metamask/rpc-errors'; import { providerFromMiddleware } from './provider-from-middleware'; diff --git a/packages/eth-json-rpc-provider/src/provider-from-middleware.ts b/packages/eth-json-rpc-provider/src/provider-from-middleware.ts index 0dd1f014d9..461af24788 100644 --- a/packages/eth-json-rpc-provider/src/provider-from-middleware.ts +++ b/packages/eth-json-rpc-provider/src/provider-from-middleware.ts @@ -1,5 +1,5 @@ -import { JsonRpcEngine } from '@metamask/json-rpc-engine/legacy'; -import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine/legacy'; +import { JsonRpcEngine } from '@metamask/json-rpc-engine'; +import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine'; import type { Json, JsonRpcParams } from '@metamask/utils'; import { providerFromEngine } from './provider-from-engine'; diff --git a/packages/eth-json-rpc-provider/src/safe-event-emitter-provider.test.ts b/packages/eth-json-rpc-provider/src/safe-event-emitter-provider.test.ts index 046d447728..9bd35b38ef 100644 --- a/packages/eth-json-rpc-provider/src/safe-event-emitter-provider.test.ts +++ b/packages/eth-json-rpc-provider/src/safe-event-emitter-provider.test.ts @@ -1,7 +1,7 @@ import { Web3Provider } from '@ethersproject/providers'; import EthQuery from '@metamask/eth-query'; import EthJsQuery from '@metamask/ethjs-query'; -import { JsonRpcEngine } from '@metamask/json-rpc-engine/legacy'; +import { JsonRpcEngine } from '@metamask/json-rpc-engine'; import { providerErrors } from '@metamask/rpc-errors'; import { type JsonRpcRequest, type Json } from '@metamask/utils'; import { BrowserProvider } from 'ethers'; diff --git a/packages/eth-json-rpc-provider/src/safe-event-emitter-provider.ts b/packages/eth-json-rpc-provider/src/safe-event-emitter-provider.ts index 971a4e7d42..69ed56eee7 100644 --- a/packages/eth-json-rpc-provider/src/safe-event-emitter-provider.ts +++ b/packages/eth-json-rpc-provider/src/safe-event-emitter-provider.ts @@ -1,4 +1,4 @@ -import type { JsonRpcEngine } from '@metamask/json-rpc-engine/legacy'; +import type { JsonRpcEngine } from '@metamask/json-rpc-engine'; import { JsonRpcError } from '@metamask/rpc-errors'; import SafeEventEmitter from '@metamask/safe-event-emitter'; import type { diff --git a/packages/json-rpc-engine/README.md b/packages/json-rpc-engine/README.md index 9294bfc312..30124a78ca 100644 --- a/packages/json-rpc-engine/README.md +++ b/packages/json-rpc-engine/README.md @@ -13,10 +13,10 @@ or ## Usage > [!NOTE] -> For the legacy `JsonRpcEngine`, see [its readme](./legacy/README.md). +> For the legacy `JsonRpcEngine`, see [its readme](./src/README.md). ```ts -import { JsonRpcEngineV2 } from '@metamask/json-rpc-engine'; +import { JsonRpcEngineV2 } from '@metamask/json-rpc-engine/v2'; const engine = new JsonRpcEngineV2({ // Create a stack of middleware and pass it to the engine: @@ -149,7 +149,7 @@ import { isRequest, isNotification, JsonRpcEngineV2, -} from '@metamask/json-rpc-engine'; +} from '@metamask/json-rpc-engine/v2'; const engine = new JsonRpcEngineV2({ middleware: [ @@ -432,7 +432,7 @@ const result2 = await loggingEngine.handle(request): The `JsonRpcServer` wraps a `JsonRpcEngineV2` to provide JSON-RPC 2.0 compliance and error handling. It coerces raw request objects into well-formed requests and handles error serialization. ```ts -import { JsonRpcEngineV2, JsonRpcServer } from '@metamask/json-rpc-engine'; +import { JsonRpcEngineV2, JsonRpcServer } from '@metamask/json-rpc-engine/v2'; const engine = new JsonRpcEngine({ middleware }); diff --git a/packages/json-rpc-engine/package.json b/packages/json-rpc-engine/package.json index 290e4068fc..8b0889c55b 100644 --- a/packages/json-rpc-engine/package.json +++ b/packages/json-rpc-engine/package.json @@ -27,14 +27,14 @@ "default": "./dist/index.cjs" } }, - "./legacy": { + "./v2": { "import": { - "types": "./dist/legacy/index.d.mts", - "default": "./dist/legacy/index.mjs" + "types": "./dist/v2/index.d.mts", + "default": "./dist/v2/index.mjs" }, "require": { - "types": "./dist/legacy/index.d.cts", - "default": "./dist/legacy/index.cjs" + "types": "./dist/v2/index.d.cts", + "default": "./dist/v2/index.cjs" } }, "./package.json": "./package.json" diff --git a/packages/json-rpc-engine/src/legacy/JsonRpcEngine.test.ts b/packages/json-rpc-engine/src/JsonRpcEngine.test.ts similarity index 100% rename from packages/json-rpc-engine/src/legacy/JsonRpcEngine.test.ts rename to packages/json-rpc-engine/src/JsonRpcEngine.test.ts diff --git a/packages/json-rpc-engine/src/legacy/JsonRpcEngine.ts b/packages/json-rpc-engine/src/JsonRpcEngine.ts similarity index 99% rename from packages/json-rpc-engine/src/legacy/JsonRpcEngine.ts rename to packages/json-rpc-engine/src/JsonRpcEngine.ts index 7824a3fcee..2375211cf9 100644 --- a/packages/json-rpc-engine/src/legacy/JsonRpcEngine.ts +++ b/packages/json-rpc-engine/src/JsonRpcEngine.ts @@ -15,7 +15,7 @@ import { isJsonRpcRequest, } from '@metamask/utils'; -import { stringify } from '../utils'; +import { stringify } from './v2/utils'; export type JsonRpcEngineCallbackError = Error | SerializedJsonRpcError | null; diff --git a/packages/json-rpc-engine/src/legacy/README.md b/packages/json-rpc-engine/src/README.md similarity index 99% rename from packages/json-rpc-engine/src/legacy/README.md rename to packages/json-rpc-engine/src/README.md index 81e96e5113..0ce6bf1711 100644 --- a/packages/json-rpc-engine/src/legacy/README.md +++ b/packages/json-rpc-engine/src/README.md @@ -1,4 +1,4 @@ -# `@metamask/json-rpc-engine/legacy` +# `JsonRpcEngine` (deprecated) The deprecated, original `JsonRpcEngine` implementation. diff --git a/packages/json-rpc-engine/src/legacy/asMiddleware.test.ts b/packages/json-rpc-engine/src/asMiddleware.test.ts similarity index 100% rename from packages/json-rpc-engine/src/legacy/asMiddleware.test.ts rename to packages/json-rpc-engine/src/asMiddleware.test.ts diff --git a/packages/json-rpc-engine/src/legacy/asV2Middleware.test.ts b/packages/json-rpc-engine/src/asV2Middleware.test.ts similarity index 96% rename from packages/json-rpc-engine/src/legacy/asV2Middleware.test.ts rename to packages/json-rpc-engine/src/asV2Middleware.test.ts index cf59735858..9457c52784 100644 --- a/packages/json-rpc-engine/src/legacy/asV2Middleware.test.ts +++ b/packages/json-rpc-engine/src/asV2Middleware.test.ts @@ -3,8 +3,8 @@ import type { Json, JsonRpcRequest } from '@metamask/utils'; import { JsonRpcEngine } from '.'; import { asV2Middleware } from './asV2Middleware'; -import { getExtraneousKeys, makeRequest } from '../../tests/utils'; -import { JsonRpcEngineV2 } from '../JsonRpcEngineV2'; +import { JsonRpcEngineV2 } from './v2/JsonRpcEngineV2'; +import { getExtraneousKeys, makeRequest } from '../tests/utils'; describe('asV2Middleware', () => { it('converts a legacy engine to a v2 middleware', () => { diff --git a/packages/json-rpc-engine/src/legacy/asV2Middleware.ts b/packages/json-rpc-engine/src/asV2Middleware.ts similarity index 95% rename from packages/json-rpc-engine/src/legacy/asV2Middleware.ts rename to packages/json-rpc-engine/src/asV2Middleware.ts index a8c8c70dae..9ffa8334e0 100644 --- a/packages/json-rpc-engine/src/legacy/asV2Middleware.ts +++ b/packages/json-rpc-engine/src/asV2Middleware.ts @@ -17,10 +17,10 @@ import { propagateToContext, propagateToRequest, unserializeError, -} from '../compatibility-utils'; +} from './v2/compatibility-utils'; // JsonRpcEngineV2 is used in docs. // eslint-disable-next-line @typescript-eslint/no-unused-vars -import type { JsonRpcMiddleware, JsonRpcEngineV2 } from '../JsonRpcEngineV2'; +import type { JsonRpcMiddleware, JsonRpcEngineV2 } from './v2/JsonRpcEngineV2'; /** * Convert a legacy {@link JsonRpcEngine} into a {@link JsonRpcEngineV2} middleware. diff --git a/packages/json-rpc-engine/src/legacy/createAsyncMiddleware.test.ts b/packages/json-rpc-engine/src/createAsyncMiddleware.test.ts similarity index 100% rename from packages/json-rpc-engine/src/legacy/createAsyncMiddleware.test.ts rename to packages/json-rpc-engine/src/createAsyncMiddleware.test.ts diff --git a/packages/json-rpc-engine/src/legacy/createAsyncMiddleware.ts b/packages/json-rpc-engine/src/createAsyncMiddleware.ts similarity index 100% rename from packages/json-rpc-engine/src/legacy/createAsyncMiddleware.ts rename to packages/json-rpc-engine/src/createAsyncMiddleware.ts diff --git a/packages/json-rpc-engine/src/legacy/createScaffoldMiddleware.test.ts b/packages/json-rpc-engine/src/createScaffoldMiddleware.test.ts similarity index 100% rename from packages/json-rpc-engine/src/legacy/createScaffoldMiddleware.test.ts rename to packages/json-rpc-engine/src/createScaffoldMiddleware.test.ts diff --git a/packages/json-rpc-engine/src/legacy/createScaffoldMiddleware.ts b/packages/json-rpc-engine/src/createScaffoldMiddleware.ts similarity index 100% rename from packages/json-rpc-engine/src/legacy/createScaffoldMiddleware.ts rename to packages/json-rpc-engine/src/createScaffoldMiddleware.ts diff --git a/packages/json-rpc-engine/src/legacy/idRemapMiddleware.test.ts b/packages/json-rpc-engine/src/idRemapMiddleware.test.ts similarity index 100% rename from packages/json-rpc-engine/src/legacy/idRemapMiddleware.test.ts rename to packages/json-rpc-engine/src/idRemapMiddleware.test.ts diff --git a/packages/json-rpc-engine/src/legacy/idRemapMiddleware.ts b/packages/json-rpc-engine/src/idRemapMiddleware.ts similarity index 94% rename from packages/json-rpc-engine/src/legacy/idRemapMiddleware.ts rename to packages/json-rpc-engine/src/idRemapMiddleware.ts index ab33f74a2a..6ec22b81d9 100644 --- a/packages/json-rpc-engine/src/legacy/idRemapMiddleware.ts +++ b/packages/json-rpc-engine/src/idRemapMiddleware.ts @@ -1,7 +1,7 @@ import type { Json, JsonRpcParams } from '@metamask/utils'; +import { getUniqueId } from './getUniqueId'; import type { JsonRpcMiddleware } from './JsonRpcEngine'; -import { getUniqueId } from '../getUniqueId'; /** * Returns a middleware function that overwrites the `id` property of each diff --git a/packages/json-rpc-engine/src/index.test.ts b/packages/json-rpc-engine/src/index.test.ts index bcdac7b0f3..93d9d9c312 100644 --- a/packages/json-rpc-engine/src/index.test.ts +++ b/packages/json-rpc-engine/src/index.test.ts @@ -4,13 +4,12 @@ describe('@metamask/json-rpc-engine', () => { it('has expected JavaScript exports', () => { expect(Object.keys(allExports)).toMatchInlineSnapshot(` Array [ - "asLegacyMiddleware", - "getUniqueId", - "JsonRpcServer", - "isNotification", - "isRequest", - "JsonRpcEngineError", - "JsonRpcEngineV2", + "asV2Middleware", + "createAsyncMiddleware", + "createScaffoldMiddleware", + "createIdRemapMiddleware", + "JsonRpcEngine", + "mergeMiddleware", ] `); }); diff --git a/packages/json-rpc-engine/src/index.ts b/packages/json-rpc-engine/src/index.ts index db4636bd85..45ad138495 100644 --- a/packages/json-rpc-engine/src/index.ts +++ b/packages/json-rpc-engine/src/index.ts @@ -1,7 +1,18 @@ -export { asLegacyMiddleware } from './asLegacyMiddleware'; -export { getUniqueId } from './getUniqueId'; -export * from './JsonRpcEngineV2'; -export { JsonRpcServer } from './JsonRpcServer'; -export type { MiddlewareContext } from './MiddlewareContext'; -export { isNotification, isRequest, JsonRpcEngineError } from './utils'; -export type { JsonRpcCall, JsonRpcNotification, JsonRpcRequest } from './utils'; +export { asV2Middleware } from './asV2Middleware'; +export type { + AsyncJsonRpcEngineNextCallback, + AsyncJsonrpcMiddleware, +} from './createAsyncMiddleware'; +export { createAsyncMiddleware } from './createAsyncMiddleware'; +export { createScaffoldMiddleware } from './createScaffoldMiddleware'; +export { createIdRemapMiddleware } from './idRemapMiddleware'; +export type { + JsonRpcEngineCallbackError, + JsonRpcEngineReturnHandler, + JsonRpcEngineNextCallback, + JsonRpcEngineEndCallback, + JsonRpcMiddleware, + JsonRpcNotificationHandler, +} from './JsonRpcEngine'; +export { JsonRpcEngine } from './JsonRpcEngine'; +export { mergeMiddleware } from './mergeMiddleware'; diff --git a/packages/json-rpc-engine/src/legacy/index.test.ts b/packages/json-rpc-engine/src/legacy/index.test.ts deleted file mode 100644 index 11b63a46b6..0000000000 --- a/packages/json-rpc-engine/src/legacy/index.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import * as allExports from '.'; - -describe('@metamask/json-rpc-engine/legacy', () => { - it('has expected JavaScript exports', () => { - expect(Object.keys(allExports)).toMatchInlineSnapshot(` - Array [ - "asV2Middleware", - "createAsyncMiddleware", - "createScaffoldMiddleware", - "createIdRemapMiddleware", - "JsonRpcEngine", - "mergeMiddleware", - ] - `); - }); -}); diff --git a/packages/json-rpc-engine/src/legacy/index.ts b/packages/json-rpc-engine/src/legacy/index.ts deleted file mode 100644 index 45ad138495..0000000000 --- a/packages/json-rpc-engine/src/legacy/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -export { asV2Middleware } from './asV2Middleware'; -export type { - AsyncJsonRpcEngineNextCallback, - AsyncJsonrpcMiddleware, -} from './createAsyncMiddleware'; -export { createAsyncMiddleware } from './createAsyncMiddleware'; -export { createScaffoldMiddleware } from './createScaffoldMiddleware'; -export { createIdRemapMiddleware } from './idRemapMiddleware'; -export type { - JsonRpcEngineCallbackError, - JsonRpcEngineReturnHandler, - JsonRpcEngineNextCallback, - JsonRpcEngineEndCallback, - JsonRpcMiddleware, - JsonRpcNotificationHandler, -} from './JsonRpcEngine'; -export { JsonRpcEngine } from './JsonRpcEngine'; -export { mergeMiddleware } from './mergeMiddleware'; diff --git a/packages/json-rpc-engine/src/legacy/mergeMiddleware.test.ts b/packages/json-rpc-engine/src/mergeMiddleware.test.ts similarity index 100% rename from packages/json-rpc-engine/src/legacy/mergeMiddleware.test.ts rename to packages/json-rpc-engine/src/mergeMiddleware.test.ts diff --git a/packages/json-rpc-engine/src/legacy/mergeMiddleware.ts b/packages/json-rpc-engine/src/mergeMiddleware.ts similarity index 100% rename from packages/json-rpc-engine/src/legacy/mergeMiddleware.ts rename to packages/json-rpc-engine/src/mergeMiddleware.ts diff --git a/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts similarity index 99% rename from packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts rename to packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts index 7209e891c8..ec36741a45 100644 --- a/packages/json-rpc-engine/src/JsonRpcEngineV2.test.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts @@ -10,7 +10,7 @@ import { type JsonRpcNotification, type JsonRpcRequest, } from './utils'; -import { makeRequest } from '../tests/utils'; +import { makeRequest } from '../../tests/utils'; const jsonrpc = '2.0' as const; diff --git a/packages/json-rpc-engine/src/JsonRpcEngineV2.ts b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts similarity index 100% rename from packages/json-rpc-engine/src/JsonRpcEngineV2.ts rename to packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts diff --git a/packages/json-rpc-engine/src/JsonRpcServer.test.ts b/packages/json-rpc-engine/src/v2/JsonRpcServer.test.ts similarity index 100% rename from packages/json-rpc-engine/src/JsonRpcServer.test.ts rename to packages/json-rpc-engine/src/v2/JsonRpcServer.test.ts diff --git a/packages/json-rpc-engine/src/JsonRpcServer.ts b/packages/json-rpc-engine/src/v2/JsonRpcServer.ts similarity index 99% rename from packages/json-rpc-engine/src/JsonRpcServer.ts rename to packages/json-rpc-engine/src/v2/JsonRpcServer.ts index 475810aea3..ae3941e3ac 100644 --- a/packages/json-rpc-engine/src/JsonRpcServer.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcServer.ts @@ -8,9 +8,9 @@ import type { } from '@metamask/utils'; import { hasProperty, isObject } from '@metamask/utils'; -import { getUniqueId } from './getUniqueId'; import type { JsonRpcEngineV2 } from './JsonRpcEngineV2'; import type { JsonRpcCall } from './utils'; +import { getUniqueId } from '../getUniqueId'; type HandleError = (error: unknown) => void | Promise; diff --git a/packages/json-rpc-engine/src/MiddlewareContext.test.ts b/packages/json-rpc-engine/src/v2/MiddlewareContext.test.ts similarity index 100% rename from packages/json-rpc-engine/src/MiddlewareContext.test.ts rename to packages/json-rpc-engine/src/v2/MiddlewareContext.test.ts diff --git a/packages/json-rpc-engine/src/MiddlewareContext.ts b/packages/json-rpc-engine/src/v2/MiddlewareContext.ts similarity index 100% rename from packages/json-rpc-engine/src/MiddlewareContext.ts rename to packages/json-rpc-engine/src/v2/MiddlewareContext.ts diff --git a/packages/json-rpc-engine/src/v2/README.md b/packages/json-rpc-engine/src/v2/README.md new file mode 100644 index 0000000000..ac52988c30 --- /dev/null +++ b/packages/json-rpc-engine/src/v2/README.md @@ -0,0 +1,3 @@ +# `@metamask/json-rpc-engine/v2` + +See the [root readme](../../README.md). diff --git a/packages/json-rpc-engine/src/asLegacyMiddleware.test.ts b/packages/json-rpc-engine/src/v2/asLegacyMiddleware.test.ts similarity index 97% rename from packages/json-rpc-engine/src/asLegacyMiddleware.test.ts rename to packages/json-rpc-engine/src/v2/asLegacyMiddleware.test.ts index 61d4b0523f..fb04986a5e 100644 --- a/packages/json-rpc-engine/src/asLegacyMiddleware.test.ts +++ b/packages/json-rpc-engine/src/v2/asLegacyMiddleware.test.ts @@ -8,8 +8,8 @@ import type { import { asLegacyMiddleware } from './asLegacyMiddleware'; import type { JsonRpcMiddleware } from './JsonRpcEngineV2'; import { JsonRpcEngineV2 } from './JsonRpcEngineV2'; -import { JsonRpcEngine } from './legacy/JsonRpcEngine'; -import { getExtraneousKeys, makeRequest } from '../tests/utils'; +import { getExtraneousKeys, makeRequest } from '../../tests/utils'; +import { JsonRpcEngine } from '../JsonRpcEngine'; describe('asLegacyMiddleware', () => { it('converts a v2 engine to a legacy middleware', () => { diff --git a/packages/json-rpc-engine/src/asLegacyMiddleware.ts b/packages/json-rpc-engine/src/v2/asLegacyMiddleware.ts similarity index 91% rename from packages/json-rpc-engine/src/asLegacyMiddleware.ts rename to packages/json-rpc-engine/src/v2/asLegacyMiddleware.ts index b6430a8225..8184d86faf 100644 --- a/packages/json-rpc-engine/src/asLegacyMiddleware.ts +++ b/packages/json-rpc-engine/src/v2/asLegacyMiddleware.ts @@ -7,8 +7,8 @@ import { propagateToRequest, } from './compatibility-utils'; import type { JsonRpcEngineV2 } from './JsonRpcEngineV2'; -import { createAsyncMiddleware } from './legacy'; -import type { JsonRpcMiddleware as LegacyMiddleware } from './legacy'; +import { createAsyncMiddleware } from '..'; +import type { JsonRpcMiddleware as LegacyMiddleware } from '..'; /** * Convert a {@link JsonRpcEngineV2} into a legacy middleware. diff --git a/packages/json-rpc-engine/src/compatibility-utils.test.ts b/packages/json-rpc-engine/src/v2/compatibility-utils.test.ts similarity index 100% rename from packages/json-rpc-engine/src/compatibility-utils.test.ts rename to packages/json-rpc-engine/src/v2/compatibility-utils.test.ts diff --git a/packages/json-rpc-engine/src/compatibility-utils.ts b/packages/json-rpc-engine/src/v2/compatibility-utils.ts similarity index 100% rename from packages/json-rpc-engine/src/compatibility-utils.ts rename to packages/json-rpc-engine/src/v2/compatibility-utils.ts diff --git a/packages/json-rpc-engine/src/v2/index.test.ts b/packages/json-rpc-engine/src/v2/index.test.ts new file mode 100644 index 0000000000..a08a89058b --- /dev/null +++ b/packages/json-rpc-engine/src/v2/index.test.ts @@ -0,0 +1,17 @@ +import * as allExports from '.'; + +describe('@metamask/json-rpc-engine/v2', () => { + it('has expected JavaScript exports', () => { + expect(Object.keys(allExports)).toMatchInlineSnapshot(` + Array [ + "asLegacyMiddleware", + "getUniqueId", + "JsonRpcServer", + "isNotification", + "isRequest", + "JsonRpcEngineError", + "JsonRpcEngineV2", + ] + `); + }); +}); diff --git a/packages/json-rpc-engine/src/v2/index.ts b/packages/json-rpc-engine/src/v2/index.ts new file mode 100644 index 0000000000..e9298e2ae6 --- /dev/null +++ b/packages/json-rpc-engine/src/v2/index.ts @@ -0,0 +1,7 @@ +export { asLegacyMiddleware } from './asLegacyMiddleware'; +export { getUniqueId } from '../getUniqueId'; +export * from './JsonRpcEngineV2'; +export { JsonRpcServer } from './JsonRpcServer'; +export type { MiddlewareContext } from './MiddlewareContext'; +export { isNotification, isRequest, JsonRpcEngineError } from './utils'; +export type { JsonRpcCall, JsonRpcNotification, JsonRpcRequest } from './utils'; diff --git a/packages/json-rpc-engine/src/utils.test.ts b/packages/json-rpc-engine/src/v2/utils.test.ts similarity index 100% rename from packages/json-rpc-engine/src/utils.test.ts rename to packages/json-rpc-engine/src/v2/utils.test.ts diff --git a/packages/json-rpc-engine/src/utils.ts b/packages/json-rpc-engine/src/v2/utils.ts similarity index 100% rename from packages/json-rpc-engine/src/utils.ts rename to packages/json-rpc-engine/src/v2/utils.ts diff --git a/packages/json-rpc-engine/v2.js b/packages/json-rpc-engine/v2.js new file mode 100644 index 0000000000..faf7abd236 --- /dev/null +++ b/packages/json-rpc-engine/v2.js @@ -0,0 +1,3 @@ +// Re-exported for compatibility with Browserify. +// eslint-disable-next-line +module.exports = require('./dist/v2/index.cjs'); diff --git a/packages/json-rpc-middleware-stream/src/createEngineStream.ts b/packages/json-rpc-middleware-stream/src/createEngineStream.ts index 169d7ee489..f2dc51f9d5 100644 --- a/packages/json-rpc-middleware-stream/src/createEngineStream.ts +++ b/packages/json-rpc-middleware-stream/src/createEngineStream.ts @@ -1,4 +1,4 @@ -import type { JsonRpcEngine } from '@metamask/json-rpc-engine/legacy'; +import type { JsonRpcEngine } from '@metamask/json-rpc-engine'; import type { JsonRpcRequest } from '@metamask/utils'; import { Duplex } from 'readable-stream'; diff --git a/packages/json-rpc-middleware-stream/src/createStreamMiddleware.ts b/packages/json-rpc-middleware-stream/src/createStreamMiddleware.ts index af91600dcc..3cbf8a048e 100644 --- a/packages/json-rpc-middleware-stream/src/createStreamMiddleware.ts +++ b/packages/json-rpc-middleware-stream/src/createStreamMiddleware.ts @@ -2,7 +2,7 @@ import type { JsonRpcEngineNextCallback, JsonRpcEngineEndCallback, JsonRpcMiddleware, -} from '@metamask/json-rpc-engine/legacy'; +} from '@metamask/json-rpc-engine'; import SafeEventEmitter from '@metamask/safe-event-emitter'; import { hasProperty, diff --git a/packages/json-rpc-middleware-stream/src/index.test.ts b/packages/json-rpc-middleware-stream/src/index.test.ts index d62cc8b6f3..6395c629a7 100644 --- a/packages/json-rpc-middleware-stream/src/index.test.ts +++ b/packages/json-rpc-middleware-stream/src/index.test.ts @@ -1,4 +1,4 @@ -import { JsonRpcEngine } from '@metamask/json-rpc-engine/legacy'; +import { JsonRpcEngine } from '@metamask/json-rpc-engine'; import PortStream from 'extension-port-stream'; import type { Duplex } from 'stream'; import type { Runtime } from 'webextension-polyfill-ts'; diff --git a/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts b/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts index ed84072ae4..bad5605633 100644 --- a/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts +++ b/packages/multichain-api-middleware/src/handlers/wallet-createSession.ts @@ -19,7 +19,7 @@ import { isEqualCaseInsensitive } from '@metamask/controller-utils'; import type { JsonRpcEngineEndCallback, JsonRpcEngineNextCallback, -} from '@metamask/json-rpc-engine/legacy'; +} from '@metamask/json-rpc-engine'; import type { NetworkController } from '@metamask/network-controller'; import { invalidParams, diff --git a/packages/multichain-api-middleware/src/handlers/wallet-revokeSession.ts b/packages/multichain-api-middleware/src/handlers/wallet-revokeSession.ts index 6b81efcb4b..59fced841d 100644 --- a/packages/multichain-api-middleware/src/handlers/wallet-revokeSession.ts +++ b/packages/multichain-api-middleware/src/handlers/wallet-revokeSession.ts @@ -2,7 +2,7 @@ import { Caip25EndowmentPermissionName } from '@metamask/chain-agnostic-permissi import type { JsonRpcEngineNextCallback, JsonRpcEngineEndCallback, -} from '@metamask/json-rpc-engine/legacy'; +} from '@metamask/json-rpc-engine'; import { PermissionDoesNotExistError, UnrecognizedSubjectError, diff --git a/packages/multichain-api-middleware/src/middlewares/MultichainMiddlewareManager.ts b/packages/multichain-api-middleware/src/middlewares/MultichainMiddlewareManager.ts index 9119646ac5..cb996646c0 100644 --- a/packages/multichain-api-middleware/src/middlewares/MultichainMiddlewareManager.ts +++ b/packages/multichain-api-middleware/src/middlewares/MultichainMiddlewareManager.ts @@ -2,7 +2,7 @@ import type { ExternalScopeString } from '@metamask/chain-agnostic-permission'; import type { JsonRpcEngineEndCallback, JsonRpcEngineNextCallback, -} from '@metamask/json-rpc-engine/legacy'; +} from '@metamask/json-rpc-engine'; import { rpcErrors } from '@metamask/rpc-errors'; import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; diff --git a/packages/multichain-api-middleware/src/middlewares/multichainMethodCallValidatorMiddleware.ts b/packages/multichain-api-middleware/src/middlewares/multichainMethodCallValidatorMiddleware.ts index 6a0e8a43d1..7797793084 100644 --- a/packages/multichain-api-middleware/src/middlewares/multichainMethodCallValidatorMiddleware.ts +++ b/packages/multichain-api-middleware/src/middlewares/multichainMethodCallValidatorMiddleware.ts @@ -1,5 +1,5 @@ import { MultiChainOpenRPCDocument } from '@metamask/api-specs'; -import { createAsyncMiddleware } from '@metamask/json-rpc-engine/legacy'; +import { createAsyncMiddleware } from '@metamask/json-rpc-engine'; import { rpcErrors } from '@metamask/rpc-errors'; import { isObject } from '@metamask/utils'; import type { JsonRpcError, JsonRpcParams } from '@metamask/utils'; diff --git a/packages/network-controller/src/create-network-client.ts b/packages/network-controller/src/create-network-client.ts index 34fe1eb0db..c6388ae3c1 100644 --- a/packages/network-controller/src/create-network-client.ts +++ b/packages/network-controller/src/create-network-client.ts @@ -22,8 +22,8 @@ import { createScaffoldMiddleware, JsonRpcEngine, mergeMiddleware, -} from '@metamask/json-rpc-engine/legacy'; -import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine/legacy'; +} from '@metamask/json-rpc-engine'; +import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine'; import type { Hex, Json, JsonRpcParams } from '@metamask/utils'; import type { NetworkControllerMessenger } from './NetworkController'; diff --git a/packages/permission-controller/src/PermissionController.test.ts b/packages/permission-controller/src/PermissionController.test.ts index e6e6466907..1d9ae75786 100644 --- a/packages/permission-controller/src/PermissionController.test.ts +++ b/packages/permission-controller/src/PermissionController.test.ts @@ -6,7 +6,7 @@ import type { } from '@metamask/approval-controller'; import { Messenger } from '@metamask/base-controller'; import { isPlainObject } from '@metamask/controller-utils'; -import { JsonRpcEngine } from '@metamask/json-rpc-engine/legacy'; +import { JsonRpcEngine } from '@metamask/json-rpc-engine'; import type { Json, JsonRpcRequest } from '@metamask/utils'; import { assertIsJsonRpcFailure, diff --git a/packages/permission-controller/src/permission-middleware.ts b/packages/permission-controller/src/permission-middleware.ts index dc4cf0272e..e661464211 100644 --- a/packages/permission-controller/src/permission-middleware.ts +++ b/packages/permission-controller/src/permission-middleware.ts @@ -1,10 +1,10 @@ -import { createAsyncMiddleware } from '@metamask/json-rpc-engine/legacy'; +import { createAsyncMiddleware } from '@metamask/json-rpc-engine'; import type { // eslint-disable-next-line @typescript-eslint/no-unused-vars JsonRpcEngine, JsonRpcMiddleware, AsyncJsonRpcEngineNextCallback, -} from '@metamask/json-rpc-engine/legacy'; +} from '@metamask/json-rpc-engine'; import type { Json, PendingJsonRpcResponse, diff --git a/packages/permission-controller/src/rpc-methods/getPermissions.test.ts b/packages/permission-controller/src/rpc-methods/getPermissions.test.ts index 8a11273141..f7724f4617 100644 --- a/packages/permission-controller/src/rpc-methods/getPermissions.test.ts +++ b/packages/permission-controller/src/rpc-methods/getPermissions.test.ts @@ -1,4 +1,4 @@ -import { JsonRpcEngine } from '@metamask/json-rpc-engine/legacy'; +import { JsonRpcEngine } from '@metamask/json-rpc-engine'; import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; import { assertIsJsonRpcSuccess } from '@metamask/utils'; diff --git a/packages/permission-controller/src/rpc-methods/getPermissions.ts b/packages/permission-controller/src/rpc-methods/getPermissions.ts index 5d181927e3..b711997322 100644 --- a/packages/permission-controller/src/rpc-methods/getPermissions.ts +++ b/packages/permission-controller/src/rpc-methods/getPermissions.ts @@ -1,4 +1,4 @@ -import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine/legacy'; +import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; import type { PendingJsonRpcResponse } from '@metamask/utils'; import type { PermissionConstraint } from '../Permission'; diff --git a/packages/permission-controller/src/rpc-methods/requestPermissions.test.ts b/packages/permission-controller/src/rpc-methods/requestPermissions.test.ts index df033e92a4..77dfc93ae5 100644 --- a/packages/permission-controller/src/rpc-methods/requestPermissions.test.ts +++ b/packages/permission-controller/src/rpc-methods/requestPermissions.test.ts @@ -1,7 +1,7 @@ import { JsonRpcEngine, createAsyncMiddleware, -} from '@metamask/json-rpc-engine/legacy'; +} from '@metamask/json-rpc-engine'; import { rpcErrors, serializeError } from '@metamask/rpc-errors'; import { assertIsJsonRpcFailure, diff --git a/packages/permission-controller/src/rpc-methods/requestPermissions.ts b/packages/permission-controller/src/rpc-methods/requestPermissions.ts index c615db8af9..a74ba98696 100644 --- a/packages/permission-controller/src/rpc-methods/requestPermissions.ts +++ b/packages/permission-controller/src/rpc-methods/requestPermissions.ts @@ -1,5 +1,5 @@ import { isPlainObject } from '@metamask/controller-utils'; -import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine/legacy'; +import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; import type { JsonRpcRequest, PendingJsonRpcResponse } from '@metamask/utils'; import { invalidParams } from '../errors'; diff --git a/packages/permission-controller/src/rpc-methods/revokePermissions.test.ts b/packages/permission-controller/src/rpc-methods/revokePermissions.test.ts index 2ebba18940..5d9a9fdc21 100644 --- a/packages/permission-controller/src/rpc-methods/revokePermissions.test.ts +++ b/packages/permission-controller/src/rpc-methods/revokePermissions.test.ts @@ -1,4 +1,4 @@ -import { JsonRpcEngine } from '@metamask/json-rpc-engine/legacy'; +import { JsonRpcEngine } from '@metamask/json-rpc-engine'; import { rpcErrors } from '@metamask/rpc-errors'; import type { Json, JsonRpcRequest } from '@metamask/utils'; import { diff --git a/packages/permission-controller/src/rpc-methods/revokePermissions.ts b/packages/permission-controller/src/rpc-methods/revokePermissions.ts index 186bd880b1..9823b072c6 100644 --- a/packages/permission-controller/src/rpc-methods/revokePermissions.ts +++ b/packages/permission-controller/src/rpc-methods/revokePermissions.ts @@ -1,4 +1,4 @@ -import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine/legacy'; +import type { JsonRpcEngineEndCallback } from '@metamask/json-rpc-engine'; import { isNonEmptyArray, type Json, diff --git a/packages/permission-controller/src/utils.ts b/packages/permission-controller/src/utils.ts index 2f798207df..d29c2db358 100644 --- a/packages/permission-controller/src/utils.ts +++ b/packages/permission-controller/src/utils.ts @@ -1,7 +1,7 @@ import type { JsonRpcEngineEndCallback, JsonRpcEngineNextCallback, -} from '@metamask/json-rpc-engine/legacy'; +} from '@metamask/json-rpc-engine'; import type { Json, JsonRpcParams, diff --git a/packages/permission-log-controller/src/PermissionLogController.ts b/packages/permission-log-controller/src/PermissionLogController.ts index 4773613e51..a75cb9aad9 100644 --- a/packages/permission-log-controller/src/PermissionLogController.ts +++ b/packages/permission-log-controller/src/PermissionLogController.ts @@ -2,7 +2,7 @@ import { BaseController, type RestrictedMessenger, } from '@metamask/base-controller'; -import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine/legacy'; +import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine'; import { type Json, type JsonRpcRequest, diff --git a/packages/permission-log-controller/tests/PermissionLogController.test.ts b/packages/permission-log-controller/tests/PermissionLogController.test.ts index e47296c8a0..695b3887cf 100644 --- a/packages/permission-log-controller/tests/PermissionLogController.test.ts +++ b/packages/permission-log-controller/tests/PermissionLogController.test.ts @@ -2,7 +2,7 @@ import { Messenger } from '@metamask/base-controller'; import type { JsonRpcEngineReturnHandler, JsonRpcEngineNextCallback, -} from '@metamask/json-rpc-engine/legacy'; +} from '@metamask/json-rpc-engine'; import { type PendingJsonRpcResponse, type JsonRpcRequest, diff --git a/packages/selected-network-controller/src/SelectedNetworkMiddleware.ts b/packages/selected-network-controller/src/SelectedNetworkMiddleware.ts index 94c47212d0..eb84a503e9 100644 --- a/packages/selected-network-controller/src/SelectedNetworkMiddleware.ts +++ b/packages/selected-network-controller/src/SelectedNetworkMiddleware.ts @@ -1,4 +1,4 @@ -import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine/legacy'; +import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine'; import type { NetworkClientId } from '@metamask/network-controller'; import type { Json, JsonRpcParams, JsonRpcRequest } from '@metamask/utils'; diff --git a/packages/selected-network-controller/tests/SelectedNetworkMiddleware.test.ts b/packages/selected-network-controller/tests/SelectedNetworkMiddleware.test.ts index 4e78f969ee..13d66bcacd 100644 --- a/packages/selected-network-controller/tests/SelectedNetworkMiddleware.test.ts +++ b/packages/selected-network-controller/tests/SelectedNetworkMiddleware.test.ts @@ -1,5 +1,5 @@ import { Messenger } from '@metamask/base-controller'; -import { JsonRpcEngine } from '@metamask/json-rpc-engine/legacy'; +import { JsonRpcEngine } from '@metamask/json-rpc-engine'; import type { JsonRpcResponse } from '@metamask/utils'; import { SelectedNetworkControllerActionTypes } from '../src/SelectedNetworkController'; diff --git a/tests/fake-provider.ts b/tests/fake-provider.ts index b7e9e6a29a..4e76a4136f 100644 --- a/tests/fake-provider.ts +++ b/tests/fake-provider.ts @@ -1,5 +1,5 @@ import { SafeEventEmitterProvider } from '@metamask/eth-json-rpc-provider'; -import { JsonRpcEngine } from '@metamask/json-rpc-engine/legacy'; +import { JsonRpcEngine } from '@metamask/json-rpc-engine'; import type { Json, JsonRpcId, From 0226ba539881e9f875359b2d27bbdae0b6cdfb38 Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Wed, 23 Jul 2025 16:01:29 -0700 Subject: [PATCH 33/75] chore: Lint --- eslint-warning-thresholds.json | 6 ------ packages/json-rpc-engine/README.md | 6 +++++- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/eslint-warning-thresholds.json b/eslint-warning-thresholds.json index 0114cb500b..1080f928c0 100644 --- a/eslint-warning-thresholds.json +++ b/eslint-warning-thresholds.json @@ -191,12 +191,6 @@ "packages/gas-fee-controller/src/determineGasFeeCalculations.ts": { "jsdoc/tag-lines": 4 }, - "packages/json-rpc-engine/src/JsonRpcEngine.test.ts": { - "jest/no-conditional-in-test": 2 - }, - "packages/json-rpc-engine/src/JsonRpcEngine.ts": { - "@typescript-eslint/prefer-promise-reject-errors": 2 - }, "packages/json-rpc-middleware-stream/src/index.test.ts": { "@typescript-eslint/prefer-promise-reject-errors": 3, "no-empty-function": 1 diff --git a/packages/json-rpc-engine/README.md b/packages/json-rpc-engine/README.md index 30124a78ca..a21ee806af 100644 --- a/packages/json-rpc-engine/README.md +++ b/packages/json-rpc-engine/README.md @@ -442,7 +442,11 @@ const server = new JsonRpcServer({ }); // server.handle() never throws - all errors are handled by handleError -const response = await server.handle({ id: '1', jsonrpc: '2.0', method: 'hello' }); +const response = await server.handle({ + id: '1', + jsonrpc: '2.0', + method: 'hello', +}); if ('result' in response) { // Handle successful response } else { From 71315f49395d2a47aba18018d8d1ad0141c7ff18 Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Wed, 23 Jul 2025 16:10:22 -0700 Subject: [PATCH 34/75] docs: Update CHANGELOG.md --- packages/json-rpc-engine/CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/json-rpc-engine/CHANGELOG.md b/packages/json-rpc-engine/CHANGELOG.md index 0065a98ee1..32627220b9 100644 --- a/packages/json-rpc-engine/CHANGELOG.md +++ b/packages/json-rpc-engine/CHANGELOG.md @@ -7,10 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `JsonRpcEngineV2` ([#6176](https://github.com/MetaMask/core/pull/6176)) + - This is a complete rewrite of `JsonRpcEngine`. See the readme for details. + ### Changed - Bump `@metamask/utils` from `^11.2.0` to `^11.4.2` ([#6054](https://github.com/MetaMask/core/pull/6054)) +### Deprecated + +- `JsonRpcEngine` and related types ([#6176](https://github.com/MetaMask/core/pull/6176)) + - To be replaced by `JsonRpcEngineV2`. + ## [10.0.3] ### Changed From df353cffbebab3fe492a2203206e84d3ee1de9e9 Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Wed, 23 Jul 2025 16:18:06 -0700 Subject: [PATCH 35/75] docs: Document legacy / v2 compatibility --- packages/json-rpc-engine/README.md | 23 +++++++++++++++++++++++ packages/json-rpc-engine/src/README.md | 17 +++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/packages/json-rpc-engine/README.md b/packages/json-rpc-engine/README.md index a21ee806af..37918a5981 100644 --- a/packages/json-rpc-engine/README.md +++ b/packages/json-rpc-engine/README.md @@ -66,6 +66,29 @@ const notification = { id: '1', jsonrpc: '2.0', method: 'hello' }; await server.handle(notification); ``` +### Legacy compatibility + +Use the `asLegacyMiddleware` function to use a `JsonRpcEngineV2` as a +middleware in a legacy `JsonRpcEngine`: + +```ts +import { + asLegacyMiddleware, + JsonRpcEngineV2, +} from '@metamask/json-rpc-engine/v2'; +import { JsonRpcEngine } from '@metamask/json-rpc-engine'; + +const legacyEngine = new JsonRpcEngine(); + +const v2Engine = new JsonRpcEngineV2({ + middleware: [ + // ... + ], +}); + +legacyEngine.push(asLegacyMiddleware(v2Engine)); +``` + ### Middleware Middleware functions can be sync or async. diff --git a/packages/json-rpc-engine/src/README.md b/packages/json-rpc-engine/src/README.md index 0ce6bf1711..e197bde8af 100644 --- a/packages/json-rpc-engine/src/README.md +++ b/packages/json-rpc-engine/src/README.md @@ -21,6 +21,23 @@ engine.push(function (req, res, next, end) { }); ``` +### V2 compatibility + +Use the `asV2Middleware` function to use a `JsonRpcEngine` as a middleware in a +`JsonRpcEngineV2`: + +```ts +import { JsonRpcEngineV2 } from '@metamask/json-rpc-engine/v2'; +import { asV2Middleware, JsonRpcEngine } from '@metamask/json-rpc-engine'; + +const legacyEngine = new JsonRpcEngine(); +legacyEngine.push(/* ... */); + +const v2Engine = new JsonRpcEngineV2({ + middleware: [asV2Middleware(legacyEngine)], +}); +``` + Requests are handled asynchronously, stepping down the stack until complete. ```js From 3927882dafb0843dff047f813169753efa477acc Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 24 Jul 2025 16:19:23 -0700 Subject: [PATCH 36/75] refactor: Make handleError sync-only This function should never throw; any failures should be the exclusive concern of the error handling layer. Dispatching this error to a remote service also shouldn't hold up request handling. --- packages/json-rpc-engine/src/v2/JsonRpcServer.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/json-rpc-engine/src/v2/JsonRpcServer.ts b/packages/json-rpc-engine/src/v2/JsonRpcServer.ts index ae3941e3ac..ea6465185d 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcServer.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcServer.ts @@ -12,7 +12,7 @@ import type { JsonRpcEngineV2 } from './JsonRpcEngineV2'; import type { JsonRpcCall } from './utils'; import { getUniqueId } from '../getUniqueId'; -type HandleError = (error: unknown) => void | Promise; +type HandleError = (error: unknown) => void; type Options = { engine: JsonRpcEngineV2; @@ -81,7 +81,7 @@ export class JsonRpcServer { }; } } catch (error) { - await this.#handleError(error); + this.#handleError(error); if (isRequest) { return { From 09594778855c579a458bbdc9b15431678ec7b86d Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 24 Jul 2025 17:11:57 -0700 Subject: [PATCH 37/75] docs: Remove id from notification example --- packages/json-rpc-engine/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/json-rpc-engine/README.md b/packages/json-rpc-engine/README.md index 37918a5981..65f72bbd1b 100644 --- a/packages/json-rpc-engine/README.md +++ b/packages/json-rpc-engine/README.md @@ -27,7 +27,7 @@ const engine = new JsonRpcEngineV2({ } return next(); }, - () => 42, + () => 'world', ], }); ``` @@ -60,7 +60,7 @@ if ('result' in response) { // Handle error } -const notification = { id: '1', jsonrpc: '2.0', method: 'hello' }; +const notification = { jsonrpc: '2.0', method: 'hello' }; // Always returns undefined for notifications await server.handle(notification); From 944acae559e1b24fc5b7b7f1333dcfd7e00884de Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 24 Jul 2025 22:48:47 -0700 Subject: [PATCH 38/75] feat: Permit constructing JsonRpcServer with middleware --- .../src/v2/JsonRpcServer.test.ts | 18 +++++++++++++ .../json-rpc-engine/src/v2/JsonRpcServer.ts | 25 ++++++++++++++----- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/packages/json-rpc-engine/src/v2/JsonRpcServer.test.ts b/packages/json-rpc-engine/src/v2/JsonRpcServer.test.ts index 2883870460..a0bfd71122 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcServer.test.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcServer.test.ts @@ -20,6 +20,24 @@ const makeEngine = () => { }; describe('JsonRpcServer', () => { + it('can be constructed with an engine', () => { + const server = new JsonRpcServer({ + engine: makeEngine(), + handleError: () => undefined, + }); + + expect(server).toBeDefined(); + }); + + it('can be constructed with middleware', () => { + const server = new JsonRpcServer({ + middleware: [() => null], + handleError: () => undefined, + }); + + expect(server).toBeDefined(); + }); + it('handles a request', async () => { const server = new JsonRpcServer({ engine: makeEngine(), diff --git a/packages/json-rpc-engine/src/v2/JsonRpcServer.ts b/packages/json-rpc-engine/src/v2/JsonRpcServer.ts index ea6465185d..6282a13b51 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcServer.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcServer.ts @@ -5,19 +5,27 @@ import type { JsonRpcParams, JsonRpcRequest, JsonRpcResponse, + NonEmptyArray, } from '@metamask/utils'; import { hasProperty, isObject } from '@metamask/utils'; -import type { JsonRpcEngineV2 } from './JsonRpcEngineV2'; +import type { JsonRpcMiddleware } from './JsonRpcEngineV2'; +import { JsonRpcEngineV2 } from './JsonRpcEngineV2'; import type { JsonRpcCall } from './utils'; import { getUniqueId } from '../getUniqueId'; type HandleError = (error: unknown) => void; type Options = { - engine: JsonRpcEngineV2; handleError: HandleError; -}; +} & ( + | { + engine: JsonRpcEngineV2; + } + | { + middleware: NonEmptyArray>; + } +); const jsonrpc = '2.0' as const; @@ -47,9 +55,14 @@ export class JsonRpcServer { readonly #handleError: HandleError; - constructor({ engine, handleError }: Options) { - this.#engine = engine; - this.#handleError = handleError; + constructor(options: Options) { + this.#handleError = options.handleError; + + if ('engine' in options) { + this.#engine = options.engine; + } else { + this.#engine = new JsonRpcEngineV2({ middleware: options.middleware }); + } } /** From 8e9a9fb80295e174e7bf7223de483802d93437b4 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Fri, 25 Jul 2025 10:07:36 -0700 Subject: [PATCH 39/75] docs: Update readme --- packages/json-rpc-engine/README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/json-rpc-engine/README.md b/packages/json-rpc-engine/README.md index 65f72bbd1b..9576079d19 100644 --- a/packages/json-rpc-engine/README.md +++ b/packages/json-rpc-engine/README.md @@ -21,13 +21,14 @@ import { JsonRpcEngineV2 } from '@metamask/json-rpc-engine/v2'; const engine = new JsonRpcEngineV2({ // Create a stack of middleware and pass it to the engine: middleware: [ - ({ request, next }) => { - if (request.method === 'foo') { - return 'bar'; + ({ request, next, context }) => { + if (request.method === 'hello') { + context.set('hello', 'world'); + return next(); } - return next(); + return null; }, - () => 'world', + ({ context }) => context.get('hello'), ], }); ``` From edde41cf1cfcd9d1d2c574292c3bb04dcc71e5c5 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Fri, 25 Jul 2025 13:35:51 -0700 Subject: [PATCH 40/75] feat: Permit symbol keys in middleware context --- packages/json-rpc-engine/README.md | 4 ++++ packages/json-rpc-engine/src/README.md | 5 ++++ .../src/v2/MiddlewareContext.test.ts | 19 ++++++++++++++- .../src/v2/MiddlewareContext.ts | 8 +++---- .../src/v2/compatibility-utils.test.ts | 24 ++++++++++++++++++- .../src/v2/compatibility-utils.ts | 16 +++++++++---- 6 files changed, 66 insertions(+), 10 deletions(-) diff --git a/packages/json-rpc-engine/README.md b/packages/json-rpc-engine/README.md index 9576079d19..760871e417 100644 --- a/packages/json-rpc-engine/README.md +++ b/packages/json-rpc-engine/README.md @@ -90,6 +90,10 @@ const v2Engine = new JsonRpcEngineV2({ legacyEngine.push(asLegacyMiddleware(v2Engine)); ``` +In keeping with the conventions of the legacy engine, non-JSON-RPC string properties of the `context` will be +copied over to the request once the V2 engine is done with the request. _Note that any symbol keys of the `context` +will **not** be copied over._ + ### Middleware Middleware functions can be sync or async. diff --git a/packages/json-rpc-engine/src/README.md b/packages/json-rpc-engine/src/README.md index e197bde8af..612b34d8e2 100644 --- a/packages/json-rpc-engine/src/README.md +++ b/packages/json-rpc-engine/src/README.md @@ -38,6 +38,11 @@ const v2Engine = new JsonRpcEngineV2({ }); ``` +Any non-JSON-RPC string properties on the request object will be copied over to the V2 engine's `context` object +once the legacy engine is done with the request. + +### Middleware + Requests are handled asynchronously, stepping down the stack until complete. ```js diff --git a/packages/json-rpc-engine/src/v2/MiddlewareContext.test.ts b/packages/json-rpc-engine/src/v2/MiddlewareContext.test.ts index aab704fe97..d3e56f09f6 100644 --- a/packages/json-rpc-engine/src/v2/MiddlewareContext.test.ts +++ b/packages/json-rpc-engine/src/v2/MiddlewareContext.test.ts @@ -6,6 +6,16 @@ describe('MiddlewareContext', () => { expect(context).toBeInstanceOf(Map); }); + it('can be constructed with entries', () => { + const symbol = Symbol('test'); + const context = new MiddlewareContext([ + ['test', 'value'], + [symbol, 'value'], + ]); + expect(context.get('test')).toBe('value'); + expect(context.get(symbol)).toBe('value'); + }); + it('is frozen', () => { const context = new MiddlewareContext(); expect(Object.isFrozen(context)).toBe(true); @@ -18,9 +28,16 @@ describe('MiddlewareContext', () => { ); }); - it('assertGet returns the value if the key is found', () => { + it('assertGet returns the value if the key is found (string)', () => { const context = new MiddlewareContext(); context.set('test', 'value'); expect(context.assertGet('test')).toBe('value'); }); + + it('assertGet returns the value if the key is found (symbol)', () => { + const context = new MiddlewareContext(); + const symbol = Symbol('test'); + context.set(symbol, 'value'); + expect(context.assertGet(symbol)).toBe('value'); + }); }); diff --git a/packages/json-rpc-engine/src/v2/MiddlewareContext.ts b/packages/json-rpc-engine/src/v2/MiddlewareContext.ts index 4152e2b5c5..c2595968de 100644 --- a/packages/json-rpc-engine/src/v2/MiddlewareContext.ts +++ b/packages/json-rpc-engine/src/v2/MiddlewareContext.ts @@ -1,12 +1,12 @@ -export class MiddlewareContext extends Map { - constructor(entries?: Iterable) { +export class MiddlewareContext extends Map { + constructor(entries?: Iterable) { super(entries); Object.freeze(this); } - assertGet(key: string): Value { + assertGet(key: string | symbol): Value { if (!this.has(key)) { - throw new Error(`Context key "${key}" not found`); + throw new Error(`Context key "${String(key)}" not found`); } return this.get(key) as Value; } diff --git a/packages/json-rpc-engine/src/v2/compatibility-utils.test.ts b/packages/json-rpc-engine/src/v2/compatibility-utils.test.ts index 37137e7503..4f8350b9bf 100644 --- a/packages/json-rpc-engine/src/v2/compatibility-utils.test.ts +++ b/packages/json-rpc-engine/src/v2/compatibility-utils.test.ts @@ -221,7 +221,7 @@ describe('compatibility-utils', () => { }); describe('propagateToRequest', () => { - it('copies non-JSON-RPC properties from context to request', () => { + it('copies non-JSON-RPC string properties from context to request', () => { const request = { jsonrpc, method: 'test_method', @@ -244,6 +244,28 @@ describe('compatibility-utils', () => { }); }); + it('does not copy non-string properties from context to request', () => { + const request = { + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + }; + const context = new MiddlewareContext(); + context.set('extraProp', 'value'); + context.set(Symbol('anotherProp'), { nested: true }); + + propagateToRequest(request, context); + + expect(request).toStrictEqual({ + jsonrpc, + method: 'test_method', + params: [1], + id: 42, + extraProp: 'value', + }); + }); + it('excludes JSON-RPC properties from propagation', () => { const request = { jsonrpc, diff --git a/packages/json-rpc-engine/src/v2/compatibility-utils.ts b/packages/json-rpc-engine/src/v2/compatibility-utils.ts index fb0c9bdfc3..76c3425b50 100644 --- a/packages/json-rpc-engine/src/v2/compatibility-utils.ts +++ b/packages/json-rpc-engine/src/v2/compatibility-utils.ts @@ -53,7 +53,7 @@ export function fromLegacyRequest( * @param req - The legacy request to make a context from. * @returns The middleware context. */ -export function makeContext>( +export function makeContext>( req: Request, ): MiddlewareContext { const context = new MiddlewareContext(); @@ -62,11 +62,13 @@ export function makeContext>( } /** - * Copies non-JSON-RPC properties from the request to the context. + * Copies non-JSON-RPC string properties from the request to the context. * * For compatibility with our problematic practice of appending non-standard * fields to requests for inter-middleware communication in the legacy engine. * + * **ATTN:** Only string properties are copied. + * * @param req - The request to propagate the context from. * @param context - The context to propagate to. */ @@ -82,11 +84,13 @@ export function propagateToContext( } /** - * Copies non-JSON-RPC properties from the context to the request. + * Copies non-JSON-RPC string properties from the context to the request. * * For compatibility with our problematic practice of appending non-standard * fields to requests for inter-middleware communication in the legacy engine. * + * **ATTN:** Only string properties are copied. + * * @param req - The request to propagate the context to. * @param context - The context to propagate from. */ @@ -95,7 +99,11 @@ export function propagateToRequest( context: MiddlewareContext, ) { Array.from(context.keys()) - .filter((key) => !requestProps.includes(key)) + .filter( + ((key) => typeof key === 'string' && !requestProps.includes(key)) as ( + value: unknown, + ) => value is string, + ) .forEach((key) => { req[key] = context.get(key); }); From 42264e24010b24548c3b39be3990a9bc9b1b11eb Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Fri, 25 Jul 2025 17:40:11 -0700 Subject: [PATCH 41/75] refactor: Make the context append-only --- packages/json-rpc-engine/README.md | 23 ++++++++++++++++++- packages/json-rpc-engine/src/README.md | 5 ++-- .../src/asV2Middleware.test.ts | 9 +++++--- .../src/v2/JsonRpcEngineV2.test.ts | 14 ++++++----- .../src/v2/MiddlewareContext.test.ts | 8 +++++++ .../src/v2/MiddlewareContext.ts | 16 +++++++++++++ .../src/v2/asLegacyMiddleware.test.ts | 6 ++--- .../src/v2/compatibility-utils.ts | 10 ++++++-- 8 files changed, 74 insertions(+), 17 deletions(-) diff --git a/packages/json-rpc-engine/README.md b/packages/json-rpc-engine/README.md index 760871e417..53fe2d3d61 100644 --- a/packages/json-rpc-engine/README.md +++ b/packages/json-rpc-engine/README.md @@ -102,7 +102,7 @@ They receive a `MiddlewareParams` object containing: - `request` - The JSON-RPC request or notification (readonly) - `context` - - A `Map` for passing data between middleware + - An append-only `Map` for passing data between middleware - `next` - Function to call the next middleware in the stack @@ -343,6 +343,27 @@ const engine = new JsonRpcEngineV2({ }); ``` +The `context` accepts symbol and string keys. To prevent accidental naming collisions, +it is append-only with deletions. +If you need to modify a context value over multiple middleware, use an array or object: + +```ts +const engine = new JsonRpcEngineV2({ + middleware: [ + async ({ context, next }) => { + context.set('user', { id: '123', name: 'Alice' }); + return next(); + }, + async ({ context, next }) => { + const user = context.assertGet<{ id: string; name: string }>('user'); + user.name = 'Bob'; + return next(); + }, + // ... + ], +}); +``` + ### Error handling Errors in middleware are propagated up the call stack: diff --git a/packages/json-rpc-engine/src/README.md b/packages/json-rpc-engine/src/README.md index 612b34d8e2..61fe01051f 100644 --- a/packages/json-rpc-engine/src/README.md +++ b/packages/json-rpc-engine/src/README.md @@ -38,8 +38,9 @@ const v2Engine = new JsonRpcEngineV2({ }); ``` -Any non-JSON-RPC string properties on the request object will be copied over to the V2 engine's `context` object -once the legacy engine is done with the request. +Non-JSON-RPC string properties on the request object will be copied over to the V2 engine's `context` object +once the legacy engine is done with the request, _unless_ they already exist on the `context`, in which case +they will be ignored. ### Middleware diff --git a/packages/json-rpc-engine/src/asV2Middleware.test.ts b/packages/json-rpc-engine/src/asV2Middleware.test.ts index 9457c52784..58262b1104 100644 --- a/packages/json-rpc-engine/src/asV2Middleware.test.ts +++ b/packages/json-rpc-engine/src/asV2Middleware.test.ts @@ -85,7 +85,7 @@ describe('asV2Middleware', () => { expect(getExtraneousKeys(req)).toStrictEqual(['value']); - req.value = 2; + req.newValue = 2; next(); }); legacyEngine.push(legacyMiddleware); @@ -98,9 +98,12 @@ describe('asV2Middleware', () => { }, asV2Middleware(legacyEngine), ({ context }) => { - observedContextValues.push(context.assertGet('value')); + observedContextValues.push(context.assertGet('newValue')); - expect(Array.from(context.keys())).toStrictEqual(['value']); + expect(Array.from(context.keys())).toStrictEqual([ + 'value', + 'newValue', + ]); return null; }, diff --git a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts index ec36741a45..607d81612a 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts @@ -317,8 +317,8 @@ describe('JsonRpcEngineV2', () => { ); const middleware2: JsonRpcMiddleware> = jest.fn( ({ context, next }) => { - const nums = context.get('foo') as number[]; - context.set('foo', [...nums, 2]); + const nums = context.assertGet('foo'); + nums.push(2); return next(); }, ); @@ -658,8 +658,9 @@ describe('JsonRpcEngineV2', () => { const engine1 = new JsonRpcEngineV2({ middleware: [ async ({ context, next }) => { - const num = context.get('foo') as number; - context.set('foo', num * 2); + const nums = context.assertGet('foo'); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + nums[0]! *= 2; return next(); }, ], @@ -668,12 +669,13 @@ describe('JsonRpcEngineV2', () => { const engine2 = new JsonRpcEngineV2({ middleware: [ async ({ context, next }) => { - context.set('foo', 2); + context.set('foo', [2]); return next(); }, engine1.asMiddleware(), async ({ context }) => { - return (context.get('foo') as number) * 2; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return context.assertGet('foo')[0]! * 2; }, ], }); diff --git a/packages/json-rpc-engine/src/v2/MiddlewareContext.test.ts b/packages/json-rpc-engine/src/v2/MiddlewareContext.test.ts index d3e56f09f6..afec7d8112 100644 --- a/packages/json-rpc-engine/src/v2/MiddlewareContext.test.ts +++ b/packages/json-rpc-engine/src/v2/MiddlewareContext.test.ts @@ -40,4 +40,12 @@ describe('MiddlewareContext', () => { context.set(symbol, 'value'); expect(context.assertGet(symbol)).toBe('value'); }); + + it('throws if setting an already set key', () => { + const context = new MiddlewareContext(); + context.set('test', 'value'); + expect(() => context.set('test', 'value')).toThrow( + `MiddlewareContext key "test" already exists`, + ); + }); }); diff --git a/packages/json-rpc-engine/src/v2/MiddlewareContext.ts b/packages/json-rpc-engine/src/v2/MiddlewareContext.ts index c2595968de..5bfa1ac198 100644 --- a/packages/json-rpc-engine/src/v2/MiddlewareContext.ts +++ b/packages/json-rpc-engine/src/v2/MiddlewareContext.ts @@ -1,3 +1,12 @@ +/** + * A append-only context object for middleware. Its interface is frozen. + * + * Map keys may still be deleted. The append-only behavior is mostly intended + * to prevent accidental naming collisions. + * + * The append-only behavior is overriden when using e.g. `Reflect.set`, + * so don't do that. + */ export class MiddlewareContext extends Map { constructor(entries?: Iterable) { super(entries); @@ -10,4 +19,11 @@ export class MiddlewareContext extends Map { } return this.get(key) as Value; } + + set(key: string | symbol, value: Value): this { + if (this.has(key)) { + throw new Error(`MiddlewareContext key "${String(key)}" already exists`); + } + return super.set(key, value) as this; + } } diff --git a/packages/json-rpc-engine/src/v2/asLegacyMiddleware.test.ts b/packages/json-rpc-engine/src/v2/asLegacyMiddleware.test.ts index fb04986a5e..268372b0a0 100644 --- a/packages/json-rpc-engine/src/v2/asLegacyMiddleware.test.ts +++ b/packages/json-rpc-engine/src/v2/asLegacyMiddleware.test.ts @@ -134,7 +134,7 @@ describe('asLegacyMiddleware', () => { expect(Array.from(context.keys())).toStrictEqual(['value']); - context.set('value', 2); + context.set('newValue', 2); return next(); }) as JsonRpcMiddleware); @@ -150,10 +150,10 @@ describe('asLegacyMiddleware', () => { legacyEngine.push(asLegacyMiddleware(v2Engine)); legacyEngine.push((req, res, _next, end) => { observedContextValues.push( - (req as Record).value as number, + (req as Record).newValue as number, ); - expect(getExtraneousKeys(req)).toStrictEqual(['value']); + expect(getExtraneousKeys(req)).toStrictEqual(['value', 'newValue']); res.result = null; end(); diff --git a/packages/json-rpc-engine/src/v2/compatibility-utils.ts b/packages/json-rpc-engine/src/v2/compatibility-utils.ts index 76c3425b50..c84b0b081b 100644 --- a/packages/json-rpc-engine/src/v2/compatibility-utils.ts +++ b/packages/json-rpc-engine/src/v2/compatibility-utils.ts @@ -67,7 +67,8 @@ export function makeContext>( * For compatibility with our problematic practice of appending non-standard * fields to requests for inter-middleware communication in the legacy engine. * - * **ATTN:** Only string properties are copied. + * **ATTN:** Only string properties that do not already exist in the context + * are copied. * * @param req - The request to propagate the context from. * @param context - The context to propagate to. @@ -77,7 +78,12 @@ export function propagateToContext( context: MiddlewareContext, ) { Object.keys(req) - .filter((key) => !requestProps.includes(key)) + .filter( + (key) => + typeof key === 'string' && + !requestProps.includes(key) && + !context.has(key), + ) .forEach((key) => { context.set(key, req[key]); }); From 43f2c949c22956eb36b21c248f31d99d21dc7f98 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 28 Jul 2025 13:14:23 -0700 Subject: [PATCH 42/75] refactor: Make handleError optional for JsonRpcServer --- .../src/v2/JsonRpcServer.test.ts | 22 +++++++++++++++++++ .../json-rpc-engine/src/v2/JsonRpcServer.ts | 18 ++++++++++++--- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/packages/json-rpc-engine/src/v2/JsonRpcServer.test.ts b/packages/json-rpc-engine/src/v2/JsonRpcServer.test.ts index a0bfd71122..e4115209b9 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcServer.test.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcServer.test.ts @@ -160,6 +160,28 @@ describe('JsonRpcServer', () => { expect(handleError).toHaveBeenCalledWith(new Error('Unknown method')); }); + it('returns a failed request when handleError is not provided', async () => { + const server = new JsonRpcServer({ + engine: makeEngine(), + }); + + const response = await server.handle({ + jsonrpc, + id: 1, + method: 'unknown', + }); + + expect(response).toStrictEqual({ + jsonrpc, + id: 1, + error: { + code: -32603, + message: 'Unknown method', + data: { cause: expect.any(Object) }, + }, + }); + }); + it('calls handleError for a failed notification', async () => { const handleError = jest.fn(); const server = new JsonRpcServer({ diff --git a/packages/json-rpc-engine/src/v2/JsonRpcServer.ts b/packages/json-rpc-engine/src/v2/JsonRpcServer.ts index 6282a13b51..6d356b926b 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcServer.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcServer.ts @@ -17,7 +17,7 @@ import { getUniqueId } from '../getUniqueId'; type HandleError = (error: unknown) => void; type Options = { - handleError: HandleError; + handleError?: HandleError; } & ( | { engine: JsonRpcEngineV2; @@ -53,8 +53,20 @@ const jsonrpc = '2.0' as const; export class JsonRpcServer { readonly #engine: JsonRpcEngineV2; - readonly #handleError: HandleError; + readonly #handleError?: HandleError | undefined; + /** + * Construct a new JSON-RPC server. + * + * @param options - The options for the server. + * @param options.handleError - The callback to handle errors thrown by the + * engine. Errors always result in a failed response object, containing a + * JSON-RPC 2.0 serialized version of the original error. + * @param options.engine - The engine to use. Mutually exclusive with + * `middleware`. + * @param options.middleware - The middleware to use. Mutually exclusive with + * `engine`. + */ constructor(options: Options) { this.#handleError = options.handleError; @@ -94,7 +106,7 @@ export class JsonRpcServer { }; } } catch (error) { - this.#handleError(error); + this.#handleError?.(error); if (isRequest) { return { From 2e5b14c9d42db5352abbdc3aabcaf3e73ba5c5ce Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 28 Jul 2025 13:24:34 -0700 Subject: [PATCH 43/75] feat: Accept initial context via handle()/handleAny() --- .../src/v2/JsonRpcEngineV2.test.ts | 37 ++++++++++++++++++- .../json-rpc-engine/src/v2/JsonRpcEngineV2.ts | 36 ++++++++++++++---- 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts index 607d81612a..313f9d9614 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts @@ -3,6 +3,7 @@ import type { NonEmptyArray } from '@metamask/utils'; import type { JsonRpcMiddleware } from './JsonRpcEngineV2'; import { JsonRpcEngineV2 } from './JsonRpcEngineV2'; +import { MiddlewareContext } from './MiddlewareContext'; import { JsonRpcEngineError, stringify, @@ -281,6 +282,20 @@ describe('JsonRpcEngineV2', () => { expect(result).toBe('bar'); }); + it('accepts an initial context', async () => { + const initialContext = new MiddlewareContext(); + initialContext.set('foo', 'bar'); + const engine = new JsonRpcEngineV2({ + middleware: [({ context }) => context.assertGet('foo')], + }); + + const result = await engine.handle(makeRequest(), { + context: initialContext, + }); + + expect(result).toBe('bar'); + }); + it('throws if a middleware attempts to modify properties of the context', async () => { const engine = new JsonRpcEngineV2({ middleware: [ @@ -564,7 +579,27 @@ describe('JsonRpcEngineV2', () => { expect(result).toBeNull(); expect(handleSpy).toHaveBeenCalledTimes(1); - expect(handleSpy).toHaveBeenCalledWith(request); + expect(handleSpy).toHaveBeenCalledWith(request, undefined); + }); + + it(`proxies to 'handle()' (with context)`, async () => { + const initialContext = new MiddlewareContext(); + initialContext.set('foo', 'bar'); + const engine = new JsonRpcEngineV2({ + middleware: [({ context }) => context.assertGet('foo')], + }); + const handleSpy = jest.spyOn(engine, 'handle'); + const request = makeRequest(); + + const result = await engine.handleAny(request, { + context: initialContext, + }); + + expect(result).toBe('bar'); + expect(handleSpy).toHaveBeenCalledTimes(1); + expect(handleSpy).toHaveBeenCalledWith(request, { + context: initialContext, + }); }); }); diff --git a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts index 4deeceaa96..6c19b1e428 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts @@ -45,6 +45,10 @@ type Options = { middleware: NonEmptyArray>; }; +type HandleOptions = { + context?: MiddlewareContext; +}; + /** * A JSON-RPC request and response processor. * @@ -86,20 +90,33 @@ export class JsonRpcEngineV2 { * Handle a JSON-RPC request. A result will be returned. * * @param request - The JSON-RPC request to handle. + * @param options - The options for the handle operation. + * @param options.context - The context to pass to the middleware. * @returns The JSON-RPC response. */ - async handle(request: Request & JsonRpcRequest): Promise; + async handle( + request: Request & JsonRpcRequest, + options?: HandleOptions, + ): Promise; /** * Handle a JSON-RPC notification. No result will be returned. * * @param notification - The JSON-RPC notification to handle. + * @param options - The options for the handle operation. + * @param options.context - The context to pass to the middleware. */ - async handle(notification: Request & JsonRpcNotification): Promise; - - async handle(request: Request): Promise { + async handle( + notification: Request & JsonRpcNotification, + options?: HandleOptions, + ): Promise; + + async handle( + request: Request, + { context }: HandleOptions = {}, + ): Promise { const isReq = isRequest(request); - const { result } = await this.#handle(request); + const { result } = await this.#handle(request, context); if (isReq && result === undefined) { throw new JsonRpcEngineError( @@ -115,10 +132,15 @@ export class JsonRpcEngineV2 { * Handle a JSON-RPC call. A response will be returned if the call is a request. * * @param request - The JSON-RPC call to handle. + * @param options - The options for the handle operation. + * @param options.context - The context to pass to the middleware. * @returns The JSON-RPC response, if any. */ - async handleAny(request: JsonRpcCall & Request): Promise { - return this.handle(request); + async handleAny( + request: JsonRpcCall & Request, + options?: HandleOptions, + ): Promise { + return this.handle(request, options); } /** From 7897c2cc709ef979a1e1991cf19a726a2861e254 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 28 Jul 2025 13:58:48 -0700 Subject: [PATCH 44/75] refactor: Add default generics to JsonRpcEngineV2 --- packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts | 5 ++++- packages/json-rpc-engine/src/v2/JsonRpcServer.ts | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts index 6c19b1e428..e4027958a7 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts @@ -71,7 +71,10 @@ type HandleOptions = { * } * ``` */ -export class JsonRpcEngineV2 { +export class JsonRpcEngineV2< + Request extends JsonRpcCall = JsonRpcCall, + Result extends Json = Json, +> { #middleware: Readonly< NonEmptyArray> >; diff --git a/packages/json-rpc-engine/src/v2/JsonRpcServer.ts b/packages/json-rpc-engine/src/v2/JsonRpcServer.ts index 6d356b926b..9283c93490 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcServer.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcServer.ts @@ -20,7 +20,7 @@ type Options = { handleError?: HandleError; } & ( | { - engine: JsonRpcEngineV2; + engine: JsonRpcEngineV2; } | { middleware: NonEmptyArray>; @@ -51,7 +51,7 @@ const jsonrpc = '2.0' as const; * ``` */ export class JsonRpcServer { - readonly #engine: JsonRpcEngineV2; + readonly #engine: JsonRpcEngineV2; readonly #handleError?: HandleError | undefined; From c989b9b073aa31da6ec23f7dcdcfa4d5debd6f79 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 12 Aug 2025 14:52:22 -0700 Subject: [PATCH 45/75] test: Add "middleware with engine.handle()" tests --- .../src/v2/JsonRpcEngineV2.test.ts | 427 +++++++++++++----- 1 file changed, 305 insertions(+), 122 deletions(-) diff --git a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts index 313f9d9614..594340801a 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts @@ -603,158 +603,341 @@ describe('JsonRpcEngineV2', () => { }); }); - describe('asMiddleware', () => { - it('ends a request if it returns a value', async () => { - // TODO: We may have to do a lot of these casts? - const engine1 = new JsonRpcEngineV2({ - middleware: [() => null], - }); - const engine2 = new JsonRpcEngineV2({ - middleware: [engine1.asMiddleware(), jest.fn(() => 'foo')], + describe('composition', () => { + describe('asMiddleware', () => { + it('ends a request if it returns a value', async () => { + // TODO: We may have to do a lot of these casts? + const engine1 = new JsonRpcEngineV2({ + middleware: [() => null], + }); + const engine2 = new JsonRpcEngineV2({ + middleware: [engine1.asMiddleware(), jest.fn(() => 'foo')], + }); + + const result = await engine2.handle(makeRequest()); + + expect(result).toBeNull(); }); - const result = await engine2.handle(makeRequest()); + it('permits returning undefined if a later middleware ends the request', async () => { + const engine1 = new JsonRpcEngineV2({ + middleware: [() => undefined], + }); + const engine2 = new JsonRpcEngineV2({ + middleware: [engine1.asMiddleware(), () => null], + }); - expect(result).toBeNull(); - }); + const result = await engine2.handle(makeRequest()); - it('permits returning undefined if a later middleware ends the request', async () => { - const engine1 = new JsonRpcEngineV2({ - middleware: [() => undefined], - }); - const engine2 = new JsonRpcEngineV2({ - middleware: [engine1.asMiddleware(), () => null], + expect(result).toBeNull(); }); - const result = await engine2.handle(makeRequest()); + it('composes nested engines', async () => { + const middleware1 = jest.fn(async ({ next }) => next()); + const middleware2 = jest.fn(async ({ next }) => next()); + const engine1 = new JsonRpcEngineV2({ + middleware: [middleware1], + }); + const engine2 = new JsonRpcEngineV2({ + middleware: [engine1.asMiddleware(), middleware2], + }); + const engine3 = new JsonRpcEngineV2({ + middleware: [engine2.asMiddleware(), () => null], + }); - expect(result).toBeNull(); - }); + const result = await engine3.handle(makeRequest()); - it('composes nested engines', async () => { - const middleware1 = jest.fn(async ({ next }) => next()); - const middleware2 = jest.fn(async ({ next }) => next()); - const engine1 = new JsonRpcEngineV2({ - middleware: [middleware1], - }); - const engine2 = new JsonRpcEngineV2({ - middleware: [engine1.asMiddleware(), middleware2], - }); - const engine3 = new JsonRpcEngineV2({ - middleware: [engine2.asMiddleware(), () => null], + expect(result).toBeNull(); + expect(middleware1).toHaveBeenCalledTimes(1); + expect(middleware2).toHaveBeenCalledTimes(1); }); - const result = await engine3.handle(makeRequest()); - - expect(result).toBeNull(); - expect(middleware1).toHaveBeenCalledTimes(1); - expect(middleware2).toHaveBeenCalledTimes(1); - }); + it('propagates request mutation', async () => { + const engine1 = new JsonRpcEngineV2({ + middleware: [ + ({ request, next }) => { + return next({ + ...request, + params: [2], + }); + }, + ({ request, next }) => { + return next({ + ...request, + method: 'test_request_2', + // @ts-expect-error Will obviously work. + params: [request.params[0] * 2], + }); + }, + ], + }); - it('propagates request mutation', async () => { - const engine1 = new JsonRpcEngineV2({ - middleware: [ - ({ request, next }) => { - return next({ - ...request, - params: [2], - }); - }, - ({ request, next }) => { - return next({ - ...request, - method: 'test_request_2', + let observedMethod: string | undefined; + const engine2 = new JsonRpcEngineV2({ + middleware: [ + engine1.asMiddleware(), + ({ request }) => { + observedMethod = request.method; // @ts-expect-error Will obviously work. - params: [request.params[0] * 2], - }); - }, - ], + return request.params[0] * 2; + }, + ], + }); + + const result = await engine2.handle(makeRequest()); + + expect(result).toBe(8); + expect(observedMethod).toBe('test_request_2'); }); - let observedMethod: string | undefined; - const engine2 = new JsonRpcEngineV2({ - middleware: [ - engine1.asMiddleware(), - ({ request }) => { - observedMethod = request.method; - // @ts-expect-error Will obviously work. - return request.params[0] * 2; - }, - ], + it('propagates context changes', async () => { + const engine1 = new JsonRpcEngineV2({ + middleware: [ + async ({ context, next }) => { + const nums = context.assertGet('foo'); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + nums[0]! *= 2; + return next(); + }, + ], + }); + + const engine2 = new JsonRpcEngineV2({ + middleware: [ + async ({ context, next }) => { + context.set('foo', [2]); + return next(); + }, + engine1.asMiddleware(), + async ({ context }) => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return context.assertGet('foo')[0]! * 2; + }, + ], + }); + + const result = await engine2.handle(makeRequest()); + + expect(result).toBe(8); }); - const result = await engine2.handle(makeRequest()); + it('observes results in expected order', async () => { + const returnHandlerResults: string[] = []; + const engine1 = new JsonRpcEngineV2({ + middleware: [ + async ({ next }) => { + await next(); + returnHandlerResults.push('1:a'); + }, + async ({ next }) => { + await next(); + returnHandlerResults.push('1:b'); + }, + ], + }); + + const engine2 = new JsonRpcEngineV2({ + middleware: [ + engine1.asMiddleware(), + async ({ next }) => { + await next(); + returnHandlerResults.push('2:a'); + }, + async ({ next }) => { + await next(); + returnHandlerResults.push('2:b'); + }, + () => null, + ], + }); + + await engine2.handle(makeRequest()); - expect(result).toBe(8); - expect(observedMethod).toBe('test_request_2'); + // Order of result handling is reversed _within_ engines, but not + // _between_ engines. + expect(returnHandlerResults).toStrictEqual([ + '1:b', + '1:a', + '2:b', + '2:a', + ]); + }); }); - it('propagates context changes', async () => { - const engine1 = new JsonRpcEngineV2({ - middleware: [ - async ({ context, next }) => { - const nums = context.assertGet('foo'); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - nums[0]! *= 2; - return next(); - }, - ], + describe('middleware with engine.handle()', () => { + it('composes nested engines', async () => { + const earlierMiddleware = jest.fn(async ({ next }) => next()); + + const engine1 = new JsonRpcEngineV2({ + middleware: [() => null], + }); + + const laterMiddleware = jest.fn(() => 'foo'); + const engine2 = new JsonRpcEngineV2({ + middleware: [ + earlierMiddleware, + async ({ request }) => { + return engine1.handle(request as JsonRpcRequest); + }, + laterMiddleware, + ], + }); + + const result = await engine2.handle(makeRequest()); + + expect(result).toBeNull(); + expect(earlierMiddleware).toHaveBeenCalledTimes(1); + expect(laterMiddleware).not.toHaveBeenCalled(); }); - const engine2 = new JsonRpcEngineV2({ - middleware: [ - async ({ context, next }) => { - context.set('foo', [2]); - return next(); - }, - engine1.asMiddleware(), - async ({ context }) => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return context.assertGet('foo')[0]! * 2; - }, - ], + it('does not propagate request mutation', async () => { + // Unlike asMiddleware(), although the inner engine mutates request, + // those mutations do not propagate when using engine.handle(). + const engine1 = new JsonRpcEngineV2({ + middleware: [ + ({ request, next }) => { + return next({ + ...request, + params: [2], + }); + }, + ({ request, next }) => { + return next({ + ...request, + method: 'test_request_2', + // @ts-expect-error Will obviously work at runtime + params: [request.params[0] * 2], + }); + }, + () => null, + ], + }); + + let observedMethod: string | undefined; + const engine2 = new JsonRpcEngineV2({ + middleware: [ + async ({ request, next, context }) => { + await engine1.handle(request as JsonRpcRequest, { context }); + return next(); + }, + ({ request }) => { + observedMethod = request.method; + // @ts-expect-error Will obviously work at runtime + return request.params[0] * 2; + }, + ], + }); + + const result = await engine2.handle(makeRequest({ params: [1] })); + + // Since inner-engine mutations do not affect the outer request, + // the outer middleware sees the original method and params. + expect(result).toBe(2); + expect(observedMethod).toBe('test_request'); }); - const result = await engine2.handle(makeRequest()); + it('propagates context changes', async () => { + const engine1 = new JsonRpcEngineV2({ + middleware: [ + async ({ context }) => { + const nums = context.assertGet('foo'); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + nums[0]! *= 2; + return null; + }, + ], + }); - expect(result).toBe(8); - }); + const engine2 = new JsonRpcEngineV2({ + middleware: [ + async ({ context, next }) => { + context.set('foo', [2]); + return next(); + }, + async ({ request, next, context }) => { + await engine1.handle(request as JsonRpcRequest, { context }); + return next(); + }, + async ({ context }) => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return context.assertGet('foo')[0]! * 2; + }, + ], + }); - it('runs return handlers in expected order', async () => { - const returnHandlerResults: string[] = []; - const engine1 = new JsonRpcEngineV2({ - middleware: [ - async ({ next }) => { - await next(); - returnHandlerResults.push('1:a'); - }, - async ({ next }) => { - await next(); - returnHandlerResults.push('1:b'); - }, - ], + const result = await engine2.handle(makeRequest()); + + expect(result).toBe(8); }); - const engine2 = new JsonRpcEngineV2({ - middleware: [ - engine1.asMiddleware(), - async ({ next }) => { - await next(); - returnHandlerResults.push('2:a'); - }, - async ({ next }) => { - await next(); - returnHandlerResults.push('2:b'); - }, - () => null, - ], + it('observes results in expected order', async () => { + const returnHandlerResults: string[] = []; + const engine1 = new JsonRpcEngineV2({ + middleware: [ + async ({ next }) => { + await next(); + returnHandlerResults.push('1:a'); + }, + async ({ next }) => { + await next(); + returnHandlerResults.push('1:b'); + }, + () => null, + ], + }); + + const engine2 = new JsonRpcEngineV2({ + middleware: [ + async ({ request, next, context }) => { + await engine1.handle(request as JsonRpcRequest, { context }); + return next(); + }, + async ({ next }) => { + await next(); + returnHandlerResults.push('2:a'); + }, + async ({ next }) => { + await next(); + returnHandlerResults.push('2:b'); + }, + () => null, + ], + }); + + await engine2.handle(makeRequest()); + + // Inner engine return handlers run before outer engine return handlers + // since engine1.handle() completes before engine2 continues. + expect(returnHandlerResults).toStrictEqual([ + '1:b', + '1:a', + '2:b', + '2:a', + ]); }); - await engine2.handle(makeRequest()); + it('throws if the inner engine throws', async () => { + const engine1 = new JsonRpcEngineV2({ + middleware: [ + () => { + throw new Error('test'); + }, + ], + }); - // Order of result handling is reversed _within_ engines, but not - // _between_ engines. - expect(returnHandlerResults).toStrictEqual(['1:b', '1:a', '2:b', '2:a']); + const engine2 = new JsonRpcEngineV2({ + middleware: [ + async ({ request }) => { + await engine1.handle(request as JsonRpcRequest); + return null; + }, + ], + }); + + await expect(engine2.handle(makeRequest())).rejects.toThrow( + new Error('test'), + ); + }); }); }); From 647a62f5a4431fb383eca864c93461d070bb8798 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 12 Aug 2025 19:55:29 -0700 Subject: [PATCH 46/75] refactor: Rename internal variable --- packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts index e4027958a7..7ca5ec2203 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts @@ -218,12 +218,16 @@ export class JsonRpcEngineV2< state.request = deepFreeze(request); } - const { value: middleware, done } = middlewareIterator.next(); + const { value: nextMiddleware, done } = middlewareIterator.next(); if (done) { return undefined; } - const result = await middleware({ request, context, next: makeNext() }); + const result = await nextMiddleware({ + request, + context, + next: makeNext(), + }); this.#updateResult(result, state); return state.result; From 070191dd0e294f7ff3dedb38984a64a3ad750aae Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Wed, 13 Aug 2025 22:59:50 -0700 Subject: [PATCH 47/75] docs: Tweak changelog --- packages/json-rpc-engine/CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/json-rpc-engine/CHANGELOG.md b/packages/json-rpc-engine/CHANGELOG.md index 32627220b9..162aa2f05a 100644 --- a/packages/json-rpc-engine/CHANGELOG.md +++ b/packages/json-rpc-engine/CHANGELOG.md @@ -10,7 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `JsonRpcEngineV2` ([#6176](https://github.com/MetaMask/core/pull/6176)) - - This is a complete rewrite of `JsonRpcEngine`. See the readme for details. + - This is a complete rewrite of `JsonRpcEngine`, intended to replace the original implementation. + See the readme for details. ### Changed From ff30e7b173d750a4718b8469728eee3c6bdaa908 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Wed, 13 Aug 2025 23:13:57 -0700 Subject: [PATCH 48/75] fix: Add v2.js to package.json files array --- packages/json-rpc-engine/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/json-rpc-engine/package.json b/packages/json-rpc-engine/package.json index 8b0889c55b..acff159827 100644 --- a/packages/json-rpc-engine/package.json +++ b/packages/json-rpc-engine/package.json @@ -45,7 +45,8 @@ "test": "test" }, "files": [ - "dist/" + "dist/", + "v2.js" ], "scripts": { "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", From 2e6bbfdd7f95288a17756615e0659a1a2df74d5d Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 28 Aug 2025 15:00:49 -0700 Subject: [PATCH 49/75] refactor: Use regular method syntax for #makeMiddlewareIterator --- packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts index 7ca5ec2203..5a9585a6d6 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts @@ -79,10 +79,6 @@ export class JsonRpcEngineV2< NonEmptyArray> >; - readonly #makeMiddlewareIterator = (): Iterator< - JsonRpcMiddleware - > => this.#middleware[Symbol.iterator](); - #isDestroyed = false; constructor({ middleware }: Options) { @@ -238,6 +234,12 @@ export class JsonRpcEngineV2< return makeNext; } + #makeMiddlewareIterator(): Iterator< + JsonRpcMiddleware + > { + return this.#middleware[Symbol.iterator](); + } + /** * Validate the result from a middleware and, if it's a new value, update the * current result. From 2ca42af0a9f235b0e757ab786eb8c95c14499f45 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 28 Aug 2025 15:27:39 -0700 Subject: [PATCH 50/75] refactor: Get rid of one type assertion --- packages/json-rpc-engine/src/v2/JsonRpcServer.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/json-rpc-engine/src/v2/JsonRpcServer.ts b/packages/json-rpc-engine/src/v2/JsonRpcServer.ts index 9283c93490..b8976e3ec7 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcServer.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcServer.ts @@ -91,18 +91,20 @@ export class JsonRpcServer { * notification. */ async handle(rawRequest: unknown): Promise { + // If rawRequest is not a notification, the originalId will be attached + // to the response. We attach our own, trusted id in #coerceRequest() + // while the request is being handled. const [originalId, isRequest] = getOriginalId(rawRequest); try { const request = this.#coerceRequest(rawRequest, isRequest); const result = await this.#engine.handleAny(request); - if (isRequest) { + if (result !== undefined) { return { jsonrpc, id: originalId as JsonRpcId, - // The result is guaranteed to be Json by the engine. - result: result as Json, + result, }; } } catch (error) { From 0e64adf1b686bf3a4f4ea594bbd3d1977a207805 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 28 Aug 2025 15:31:34 -0700 Subject: [PATCH 51/75] docs: Improve handleAny documentation --- packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts index 5a9585a6d6..c0457bd201 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts @@ -125,11 +125,13 @@ export class JsonRpcEngineV2< return result; } - // This exists because a JsonRpcCall overload of handle() cannot coexist with - // the other overloads due to type union / overload shenanigans. /** * Handle a JSON-RPC call. A response will be returned if the call is a request. * + * This exists because a {@link JsonRpcCall} overload of {@link handle} cannot + * coexist with the other overloads of that method. In particular, such an + * overload will always return `void`. + * * @param request - The JSON-RPC call to handle. * @param options - The options for the handle operation. * @param options.context - The context to pass to the middleware. From d5dd50602f5a5245d97c03675f4c5043c46208a4 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 28 Aug 2025 16:19:26 -0700 Subject: [PATCH 52/75] refactor: Make test util types narrower --- packages/json-rpc-engine/tests/utils.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/json-rpc-engine/tests/utils.ts b/packages/json-rpc-engine/tests/utils.ts index a42aca9251..8cd2758f07 100644 --- a/packages/json-rpc-engine/tests/utils.ts +++ b/packages/json-rpc-engine/tests/utils.ts @@ -1,17 +1,17 @@ import type { JsonRpcRequest } from '@metamask/utils'; -export const makeRequest = ( - params: Partial = {}, -): Request => +export const makeRequest = >( + params: Request = {} as Request, +) => ({ - jsonrpc: '2.0' as const, + jsonrpc: '2.0', id: '1', method: 'test_request', - params: [] as Request['params'], + params: [], ...params, - }) as Request; + }) as const satisfies JsonRpcRequest; -const requestProps = ['jsonrpc', 'method', 'params', 'id']; +const requestProps = ['jsonrpc', 'method', 'params', 'id'] as const; /** * Get the keys of a request that are not part of the standard JSON-RPC request @@ -21,5 +21,7 @@ const requestProps = ['jsonrpc', 'method', 'params', 'id']; * @returns The extraneous keys. */ export function getExtraneousKeys(req: Record): string[] { - return Object.keys(req).filter((key) => !requestProps.includes(key)); + return Object.keys(req).filter( + (key) => !requestProps.find((requestProp) => requestProp === key), + ); } From d4b60546b15f6c923611a8ea233e2dcb5fdf1da6 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 28 Aug 2025 16:25:10 -0700 Subject: [PATCH 53/75] refactor: handleError -> onError --- packages/json-rpc-engine/README.md | 8 +-- .../src/v2/JsonRpcServer.test.ts | 50 +++++++++---------- .../json-rpc-engine/src/v2/JsonRpcServer.ts | 16 +++--- 3 files changed, 37 insertions(+), 37 deletions(-) diff --git a/packages/json-rpc-engine/README.md b/packages/json-rpc-engine/README.md index 53fe2d3d61..e1e4f1dc22 100644 --- a/packages/json-rpc-engine/README.md +++ b/packages/json-rpc-engine/README.md @@ -50,7 +50,7 @@ Alternatively, pass the engine to a `JsonRpcServer`, which coerces raw request objects into well-formed requests, and handles error serialization: ```ts -const server = new JsonRpcServer({ engine, handleError }); +const server = new JsonRpcServer({ engine, onError }); const request = { id: '1', jsonrpc: '2.0', method: 'hello' }; // server.handle() never throws @@ -487,10 +487,10 @@ const engine = new JsonRpcEngine({ middleware }); const server = new JsonRpcServer({ engine, - handleError: (error) => console.error('Server error:', error), + onError: (error) => console.error('Server error:', error), }); -// server.handle() never throws - all errors are handled by handleError +// server.handle() never throws - all errors are handled by onError const response = await server.handle({ id: '1', jsonrpc: '2.0', @@ -510,7 +510,7 @@ await server.handle(notification); // Returns undefined The server accepts any object with a `method` property and validates JSON-RPC 2.0 compliance. Errors occurring during request validation or processing (by the engine) are passed -to `handleError`. +to `onError`. Response objects are returned for requests but not notifications, and contain the `result` in case of success and `error` in case of failure. diff --git a/packages/json-rpc-engine/src/v2/JsonRpcServer.test.ts b/packages/json-rpc-engine/src/v2/JsonRpcServer.test.ts index e4115209b9..5b6ebac47b 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcServer.test.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcServer.test.ts @@ -23,7 +23,7 @@ describe('JsonRpcServer', () => { it('can be constructed with an engine', () => { const server = new JsonRpcServer({ engine: makeEngine(), - handleError: () => undefined, + onError: () => undefined, }); expect(server).toBeDefined(); @@ -32,7 +32,7 @@ describe('JsonRpcServer', () => { it('can be constructed with middleware', () => { const server = new JsonRpcServer({ middleware: [() => null], - handleError: () => undefined, + onError: () => undefined, }); expect(server).toBeDefined(); @@ -41,7 +41,7 @@ describe('JsonRpcServer', () => { it('handles a request', async () => { const server = new JsonRpcServer({ engine: makeEngine(), - handleError: () => undefined, + onError: () => undefined, }); const response = await server.handle({ @@ -60,7 +60,7 @@ describe('JsonRpcServer', () => { it('handles a request with params', async () => { const server = new JsonRpcServer({ engine: makeEngine(), - handleError: () => undefined, + onError: () => undefined, }); const response = await server.handle({ @@ -80,7 +80,7 @@ describe('JsonRpcServer', () => { it('handles a notification', async () => { const server = new JsonRpcServer({ engine: makeEngine(), - handleError: () => undefined, + onError: () => undefined, }); const response = await server.handle({ @@ -94,7 +94,7 @@ describe('JsonRpcServer', () => { it('handles a notification with params', async () => { const server = new JsonRpcServer({ engine: makeEngine(), - handleError: () => undefined, + onError: () => undefined, }); const response = await server.handle({ @@ -109,7 +109,7 @@ describe('JsonRpcServer', () => { it('returns an error response for a failed request', async () => { const server = new JsonRpcServer({ engine: makeEngine(), - handleError: () => undefined, + onError: () => undefined, }); const response = await server.handle({ @@ -132,7 +132,7 @@ describe('JsonRpcServer', () => { it('returns undefined for a failed notification', async () => { const server = new JsonRpcServer({ engine: makeEngine(), - handleError: () => undefined, + onError: () => undefined, }); const response = await server.handle({ @@ -143,11 +143,11 @@ describe('JsonRpcServer', () => { expect(response).toBeUndefined(); }); - it('calls handleError for a failed request', async () => { - const handleError = jest.fn(); + it('calls onError for a failed request', async () => { + const onError = jest.fn(); const server = new JsonRpcServer({ engine: makeEngine(), - handleError, + onError, }); await server.handle({ @@ -156,11 +156,11 @@ describe('JsonRpcServer', () => { method: 'unknown', }); - expect(handleError).toHaveBeenCalledTimes(1); - expect(handleError).toHaveBeenCalledWith(new Error('Unknown method')); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(new Error('Unknown method')); }); - it('returns a failed request when handleError is not provided', async () => { + it('returns a failed request when onError is not provided', async () => { const server = new JsonRpcServer({ engine: makeEngine(), }); @@ -182,11 +182,11 @@ describe('JsonRpcServer', () => { }); }); - it('calls handleError for a failed notification', async () => { - const handleError = jest.fn(); + it('calls onError for a failed notification', async () => { + const onError = jest.fn(); const server = new JsonRpcServer({ engine: makeEngine(), - handleError, + onError, }); await server.handle({ @@ -194,14 +194,14 @@ describe('JsonRpcServer', () => { method: 'unknown', }); - expect(handleError).toHaveBeenCalledTimes(1); - expect(handleError).toHaveBeenCalledWith(new Error('Unknown method')); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(new Error('Unknown method')); }); it('accepts requests with malformed jsonrpc', async () => { const server = new JsonRpcServer({ engine: makeEngine(), - handleError: () => undefined, + onError: () => undefined, }); const response = await server.handle({ @@ -222,7 +222,7 @@ describe('JsonRpcServer', () => { async (id) => { const server = new JsonRpcServer({ engine: makeEngine(), - handleError: () => undefined, + onError: () => undefined, }); const response = await server.handle({ @@ -254,16 +254,16 @@ describe('JsonRpcServer', () => { ])( 'throws if the request is not minimally conformant', async (malformedRequest) => { - const handleError = jest.fn(); + const onError = jest.fn(); const server = new JsonRpcServer({ engine: makeEngine(), - handleError, + onError, }); await server.handle(malformedRequest); - expect(handleError).toHaveBeenCalledTimes(1); - expect(handleError).toHaveBeenCalledWith( + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith( rpcErrors.invalidRequest({ data: { request: malformedRequest, diff --git a/packages/json-rpc-engine/src/v2/JsonRpcServer.ts b/packages/json-rpc-engine/src/v2/JsonRpcServer.ts index b8976e3ec7..b467e59a85 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcServer.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcServer.ts @@ -14,10 +14,10 @@ import { JsonRpcEngineV2 } from './JsonRpcEngineV2'; import type { JsonRpcCall } from './utils'; import { getUniqueId } from '../getUniqueId'; -type HandleError = (error: unknown) => void; +type OnError = (error: unknown) => void; type Options = { - handleError?: HandleError; + onError?: OnError; } & ( | { engine: JsonRpcEngineV2; @@ -39,7 +39,7 @@ const jsonrpc = '2.0' as const; * ```ts * const server = new JsonRpcServer({ * engine, - * handleError, + * onError, * }); * * const response = await server.handle(request); @@ -53,13 +53,13 @@ const jsonrpc = '2.0' as const; export class JsonRpcServer { readonly #engine: JsonRpcEngineV2; - readonly #handleError?: HandleError | undefined; + readonly #onError?: OnError | undefined; /** * Construct a new JSON-RPC server. * * @param options - The options for the server. - * @param options.handleError - The callback to handle errors thrown by the + * @param options.onError - The callback to handle errors thrown by the * engine. Errors always result in a failed response object, containing a * JSON-RPC 2.0 serialized version of the original error. * @param options.engine - The engine to use. Mutually exclusive with @@ -68,7 +68,7 @@ export class JsonRpcServer { * `engine`. */ constructor(options: Options) { - this.#handleError = options.handleError; + this.#onError = options.onError; if ('engine' in options) { this.#engine = options.engine; @@ -82,7 +82,7 @@ export class JsonRpcServer { * property, so long as any other JSON-RPC 2.0 properties are valid. * * This method never throws. All errors are handled by the instance's - * `handleError` callback. A response with a `result` or `error` property is + * `onError` callback. A response with a `result` or `error` property is * returned unless the request is a notification, in which case `undefined` * is returned. * @@ -108,7 +108,7 @@ export class JsonRpcServer { }; } } catch (error) { - this.#handleError?.(error); + this.#onError?.(error); if (isRequest) { return { From 91fca1371f0fc62b22101117cfbceb3cbd136bdf Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Fri, 29 Aug 2025 09:24:09 -0700 Subject: [PATCH 54/75] refactor: Use ts-expect-error instead of a cast --- packages/json-rpc-engine/src/v2/JsonRpcServer.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/json-rpc-engine/src/v2/JsonRpcServer.ts b/packages/json-rpc-engine/src/v2/JsonRpcServer.ts index b467e59a85..173894b231 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcServer.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcServer.ts @@ -1,7 +1,6 @@ import { rpcErrors, serializeError } from '@metamask/rpc-errors'; import type { Json, - JsonRpcId, JsonRpcParams, JsonRpcRequest, JsonRpcResponse, @@ -103,7 +102,8 @@ export class JsonRpcServer { if (result !== undefined) { return { jsonrpc, - id: originalId as JsonRpcId, + // @ts-expect-error - Reassign the original id, regardless of its type. + id: originalId, result, }; } @@ -113,9 +113,8 @@ export class JsonRpcServer { if (isRequest) { return { jsonrpc, - // Remap the original id to the error response, regardless of its - // type, which is not our problem. - id: originalId as JsonRpcId, + // @ts-expect-error - Reassign the original id, regardless of its type. + id: originalId, error: serializeError(error, { shouldIncludeStack: false, shouldPreserveMessage: true, From bf93edd9ac0f667575e29a0601167223643592c4 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 15 Sep 2025 15:30:18 -0700 Subject: [PATCH 55/75] docs: Tweak some comments and docstrings --- packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts index c0457bd201..74b0069c99 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts @@ -126,7 +126,7 @@ export class JsonRpcEngineV2< } /** - * Handle a JSON-RPC call. A response will be returned if the call is a request. + * Handle a JSON-RPC call (i.e. request or notification). * * This exists because a {@link JsonRpcCall} overload of {@link handle} cannot * coexist with the other overloads of that method. In particular, such an @@ -135,7 +135,7 @@ export class JsonRpcEngineV2< * @param request - The JSON-RPC call to handle. * @param options - The options for the handle operation. * @param options.context - The context to pass to the middleware. - * @returns The JSON-RPC response, if any. + * @returns The JSON-RPC response, or `void` if the call is a notification. */ async handleAny( request: JsonRpcCall & Request, @@ -281,8 +281,8 @@ export class JsonRpcEngineV2< } if ( hasProperty(nextRequest, 'id') !== hasProperty(currentRequest, 'id') || - // @ts-expect-error - "id" does not exist on notifications, but this will - // produce the desired behavior at runtime. + // @ts-expect-error - "id" does not exist on notifications, but we can still + // check the value of the property at runtime. nextRequest.id !== currentRequest.id ) { throw new JsonRpcEngineError( From 725c6d7704adbeb2be4553775cfe747fedcae84b Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Wed, 8 Oct 2025 11:34:31 -0700 Subject: [PATCH 56/75] refactor: Respond to review --- packages/json-rpc-engine/README.md | 7 ++++--- packages/json-rpc-engine/src/asV2Middleware.ts | 5 +++-- packages/json-rpc-engine/src/index.test.ts | 1 + packages/json-rpc-engine/src/index.ts | 1 + packages/json-rpc-engine/src/v2/JsonRpcServer.ts | 6 ++++-- packages/json-rpc-engine/src/v2/compatibility-utils.ts | 2 +- packages/json-rpc-engine/tests/utils.ts | 4 ++-- 7 files changed, 16 insertions(+), 10 deletions(-) diff --git a/packages/json-rpc-engine/README.md b/packages/json-rpc-engine/README.md index e1e4f1dc22..91554f89a0 100644 --- a/packages/json-rpc-engine/README.md +++ b/packages/json-rpc-engine/README.md @@ -232,7 +232,7 @@ an error: const engine = new JsonRpcEngineV2({ middleware: [ ({ request, next }) => { - // Modifying either proeprty will cause an error + // Modifying either property will cause an error return next({ ...request, jsonrpc: '3.0', @@ -487,6 +487,7 @@ const engine = new JsonRpcEngine({ middleware }); const server = new JsonRpcServer({ engine, + // onError receives the raw error, before it is coerced into a JSON-RPC error. onError: (error) => console.error('Server error:', error), }); @@ -509,10 +510,10 @@ await server.handle(notification); // Returns undefined The server accepts any object with a `method` property and validates JSON-RPC 2.0 compliance. -Errors occurring during request validation or processing (by the engine) are passed -to `onError`. Response objects are returned for requests but not notifications, and contain the `result` in case of success and `error` in case of failure. +Errors thrown by the underlying engine are passed to `onError` before being serialized +and attached to the response object via the `error` property. ## Contributing diff --git a/packages/json-rpc-engine/src/asV2Middleware.ts b/packages/json-rpc-engine/src/asV2Middleware.ts index 9ffa8334e0..4b1ceb5431 100644 --- a/packages/json-rpc-engine/src/asV2Middleware.ts +++ b/packages/json-rpc-engine/src/asV2Middleware.ts @@ -1,6 +1,7 @@ import { serializeError } from '@metamask/rpc-errors'; import type { JsonRpcFailure, JsonRpcResponse } from '@metamask/utils'; import { + hasProperty, type Json, type JsonRpcParams, type JsonRpcRequest, @@ -63,9 +64,9 @@ export function asV2Middleware< }); propagateToContext(req, context); - if ('error' in response) { + if (hasProperty(response, 'error')) { throw unserializeError(response.error); - } else if ('result' in response) { + } else if (hasProperty(response, 'result')) { return response.result as Result; } return next(fromLegacyRequest(req as Request)); diff --git a/packages/json-rpc-engine/src/index.test.ts b/packages/json-rpc-engine/src/index.test.ts index 93d9d9c312..69151ddb72 100644 --- a/packages/json-rpc-engine/src/index.test.ts +++ b/packages/json-rpc-engine/src/index.test.ts @@ -7,6 +7,7 @@ describe('@metamask/json-rpc-engine', () => { "asV2Middleware", "createAsyncMiddleware", "createScaffoldMiddleware", + "getUniqueId", "createIdRemapMiddleware", "JsonRpcEngine", "mergeMiddleware", diff --git a/packages/json-rpc-engine/src/index.ts b/packages/json-rpc-engine/src/index.ts index 45ad138495..57e69b8d09 100644 --- a/packages/json-rpc-engine/src/index.ts +++ b/packages/json-rpc-engine/src/index.ts @@ -5,6 +5,7 @@ export type { } from './createAsyncMiddleware'; export { createAsyncMiddleware } from './createAsyncMiddleware'; export { createScaffoldMiddleware } from './createScaffoldMiddleware'; +export { getUniqueId } from './getUniqueId'; export { createIdRemapMiddleware } from './idRemapMiddleware'; export type { JsonRpcEngineCallbackError, diff --git a/packages/json-rpc-engine/src/v2/JsonRpcServer.ts b/packages/json-rpc-engine/src/v2/JsonRpcServer.ts index 173894b231..b2df68161e 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcServer.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcServer.ts @@ -60,7 +60,8 @@ export class JsonRpcServer { * @param options - The options for the server. * @param options.onError - The callback to handle errors thrown by the * engine. Errors always result in a failed response object, containing a - * JSON-RPC 2.0 serialized version of the original error. + * JSON-RPC 2.0 serialized version of the original error. If you need to + * access the original error, use the `onError` callback. * @param options.engine - The engine to use. Mutually exclusive with * `middleware`. * @param options.middleware - The middleware to use. Mutually exclusive with @@ -69,7 +70,8 @@ export class JsonRpcServer { constructor(options: Options) { this.#onError = options.onError; - if ('engine' in options) { + if (hasProperty(options, 'engine')) { + // @ts-expect-error - hasProperty fails to narrow the type. this.#engine = options.engine; } else { this.#engine = new JsonRpcEngineV2({ middleware: options.middleware }); diff --git a/packages/json-rpc-engine/src/v2/compatibility-utils.ts b/packages/json-rpc-engine/src/v2/compatibility-utils.ts index c84b0b081b..b7d55f28d7 100644 --- a/packages/json-rpc-engine/src/v2/compatibility-utils.ts +++ b/packages/json-rpc-engine/src/v2/compatibility-utils.ts @@ -23,7 +23,7 @@ export const deepClone = rfdc({ /** * Standard JSON-RPC request properties. */ -const requestProps = ['jsonrpc', 'method', 'params', 'id']; +export const requestProps = ['jsonrpc', 'method', 'params', 'id']; /** * Make a JSON-RPC request from a legacy request. Clones the params to avoid diff --git a/packages/json-rpc-engine/tests/utils.ts b/packages/json-rpc-engine/tests/utils.ts index 8cd2758f07..b55fd5e753 100644 --- a/packages/json-rpc-engine/tests/utils.ts +++ b/packages/json-rpc-engine/tests/utils.ts @@ -1,5 +1,7 @@ import type { JsonRpcRequest } from '@metamask/utils'; +import { requestProps } from '../src/v2/compatibility-utils'; + export const makeRequest = >( params: Request = {} as Request, ) => @@ -11,8 +13,6 @@ export const makeRequest = >( ...params, }) as const satisfies JsonRpcRequest; -const requestProps = ['jsonrpc', 'method', 'params', 'id'] as const; - /** * Get the keys of a request that are not part of the standard JSON-RPC request * properties. From af3d52fa3e55d26225795533c8d1dcd2f23db3a7 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Wed, 8 Oct 2025 17:41:44 -0700 Subject: [PATCH 57/75] refactor: Make destroy() throw on failure --- .../src/v2/JsonRpcEngineV2.test.ts | 25 +++++++------------ .../json-rpc-engine/src/v2/JsonRpcEngineV2.ts | 12 ++++----- 2 files changed, 15 insertions(+), 22 deletions(-) diff --git a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts index 594340801a..5f23eb5409 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts @@ -950,12 +950,12 @@ describe('JsonRpcEngineV2', () => { middleware: [middleware as unknown as JsonRpcMiddleware], }); - engine.destroy(); + await engine.destroy(); expect(middleware.destroy).toHaveBeenCalledTimes(1); }); - it('is idempotent', () => { + it('is idempotent', async () => { const middleware = { destroy: jest.fn(), }; @@ -964,8 +964,8 @@ describe('JsonRpcEngineV2', () => { middleware: [middleware as unknown as JsonRpcMiddleware], }); - engine.destroy(); - engine.destroy(); + await engine.destroy(); + await engine.destroy(); expect(middleware.destroy).toHaveBeenCalledTimes(1); }); @@ -975,7 +975,7 @@ describe('JsonRpcEngineV2', () => { middleware: [() => null], }); - engine.destroy(); + await engine.destroy(); await expect(engine.handle(makeRequest())).rejects.toThrow( new JsonRpcEngineError('Engine is destroyed'), @@ -986,15 +986,14 @@ describe('JsonRpcEngineV2', () => { const engine = new JsonRpcEngineV2({ middleware: [() => null], }); - engine.destroy(); + await engine.destroy(); expect(() => engine.asMiddleware()).toThrow( new JsonRpcEngineError('Engine is destroyed'), ); }); - it('logs an error if a middleware throws when destroying', async () => { - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + it('rejects if a middleware throws when destroying', async () => { const middleware = { destroy: jest.fn(() => { throw new Error('test'); @@ -1004,13 +1003,7 @@ describe('JsonRpcEngineV2', () => { middleware: [middleware as unknown as JsonRpcMiddleware], }); - engine.destroy(); - await new Promise((resolve) => setImmediate(resolve)); - - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Error destroying middleware:', - new Error('test'), - ); + await expect(engine.destroy()).rejects.toThrow(new Error('test')); }); it('calls the destroy() method of each middleware even if one throws', async () => { @@ -1029,7 +1022,7 @@ describe('JsonRpcEngineV2', () => { ] as unknown as NonEmptyArray, }); - engine.destroy(); + await expect(engine.destroy()).rejects.toThrow(new Error('test')); expect(middleware1.destroy).toHaveBeenCalledTimes(1); expect(middleware2.destroy).toHaveBeenCalledTimes(1); diff --git a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts index 74b0069c99..9756e82c05 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts @@ -312,15 +312,16 @@ export class JsonRpcEngineV2< * Destroy the engine. Calls the `destroy()` method of any middleware that has * one. Attempting to use the engine after destroying it will throw an error. */ - destroy(): void { + async destroy(): Promise { if (this.#isDestroyed) { return; } - this.#isDestroyed = true; - Promise.all( + + const destructionPromise = Promise.all( this.#middleware.map(async (middleware) => { if ( + // Intentionally using `in` to walk the prototype chain. 'destroy' in middleware && typeof middleware.destroy === 'function' ) { @@ -328,10 +329,9 @@ export class JsonRpcEngineV2< } return undefined; }), - ).catch((error) => { - console.error('Error destroying middleware:', error); - }); + ); this.#middleware = [] as never; + await destructionPromise; } #assertIsNotDestroyed(): void { From 4ccf262cef548ff310ae5f64441b867c480dbcf4 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Wed, 8 Oct 2025 18:12:15 -0700 Subject: [PATCH 58/75] docs: Tweak requests vs. notifications docs --- packages/json-rpc-engine/README.md | 10 ++++++---- packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts | 13 +++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/json-rpc-engine/README.md b/packages/json-rpc-engine/README.md index 91554f89a0..6097d1077f 100644 --- a/packages/json-rpc-engine/README.md +++ b/packages/json-rpc-engine/README.md @@ -132,7 +132,8 @@ JSON-RPC requests come in two flavors: - [Requests](https://www.jsonrpc.org/specification#request_object), i.e. request objects with an `id` - [Notifications](https://www.jsonrpc.org/specification#notification), i.e. request objects _without_ an `id` -Requests must return a non-`undefined` result, or the engine will error: +For requests, one of the engine's middleware must "end" the request by returning a non-`undefined` result, or `.handle()` +will throw an error: ```ts const engine = new JsonRpcEngineV2({ @@ -156,7 +157,8 @@ try { } ``` -Notifications, on the other hand, may only return `undefined`: +For notifications, on the other hand, every middleware must return `undefined`, and non-`undefined` return values +will cause an error: ```ts const notification = { jsonrpc: '2.0', method: 'hello' }; @@ -182,7 +184,7 @@ import { const engine = new JsonRpcEngineV2({ middleware: [ async ({ request, next }) => { - if (isRequest(request)) { + if (isRequest(request) && request.method === 'everything') { return 42; } return next(); @@ -192,7 +194,7 @@ const engine = new JsonRpcEngineV2({ console.log(`Received notification: ${request.method}`); return undefined; } - return 'Hello, World!'; + return null; }, ], }); diff --git a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts index 9756e82c05..b615368799 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts @@ -54,6 +54,19 @@ type HandleOptions = { * * Give it a stack of middleware, pass it requests, and get back responses. * + * #### Requests vs. notifications + * + * JSON-RPC requests come in two flavors: + * + * - [Requests](https://www.jsonrpc.org/specification#request_object), i.e. request objects with an `id` + * - [Notifications](https://www.jsonrpc.org/specification#notification), i.e. request objects _without_ an `id` + * + * For requests, one of the engine's middleware must "end" the request by returning a non-`undefined` result, + * or {@link handle} will throw an error: + * + * For notifications, on the other hand, every middleware must return `undefined`, and non-`undefined` return values + * will cause an error: + * * @template Request - The type of request to handle. * @template Result - The type of result to return. * From 863e07163fa7d9d2e48e178594bebbb69e6d5bd0 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Wed, 8 Oct 2025 18:27:41 -0700 Subject: [PATCH 59/75] docs: Make tweaks to docs actually make sense --- packages/json-rpc-engine/README.md | 6 +++--- packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/json-rpc-engine/README.md b/packages/json-rpc-engine/README.md index 6097d1077f..445c8b13fe 100644 --- a/packages/json-rpc-engine/README.md +++ b/packages/json-rpc-engine/README.md @@ -129,7 +129,7 @@ const engine = new JsonRpcEngineV2({ JSON-RPC requests come in two flavors: -- [Requests](https://www.jsonrpc.org/specification#request_object), i.e. request objects with an `id` +- [Requests](https://www.jsonrpc.org/specification#request_object), i.e. request objects _with_ an `id` - [Notifications](https://www.jsonrpc.org/specification#notification), i.e. request objects _without_ an `id` For requests, one of the engine's middleware must "end" the request by returning a non-`undefined` result, or `.handle()` @@ -157,8 +157,8 @@ try { } ``` -For notifications, on the other hand, every middleware must return `undefined`, and non-`undefined` return values -will cause an error: +For notifications, on the other hand, one of the engine's middleware must return `undefined` to end the request, +and any non-`undefined` return values will cause an error: ```ts const notification = { jsonrpc: '2.0', method: 'hello' }; diff --git a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts index b615368799..aec13bea25 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts @@ -58,14 +58,14 @@ type HandleOptions = { * * JSON-RPC requests come in two flavors: * - * - [Requests](https://www.jsonrpc.org/specification#request_object), i.e. request objects with an `id` + * - [Requests](https://www.jsonrpc.org/specification#request_object), i.e. request objects _with_ an `id` * - [Notifications](https://www.jsonrpc.org/specification#notification), i.e. request objects _without_ an `id` * * For requests, one of the engine's middleware must "end" the request by returning a non-`undefined` result, * or {@link handle} will throw an error: * - * For notifications, on the other hand, every middleware must return `undefined`, and non-`undefined` return values - * will cause an error: + * For notifications, on the other hand, one of the engine's middleware must return `undefined` to end the request, + * and any non-`undefined` return values will cause an error: * * @template Request - The type of request to handle. * @template Result - The type of result to return. From 5686990f1ae8edd22d4b2de298541cd12460a0c1 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 9 Oct 2025 10:12:34 -0700 Subject: [PATCH 60/75] docs: Add in-line comment for returning undefined in next() --- packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts index aec13bea25..0dc9b13308 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts @@ -231,6 +231,8 @@ export class JsonRpcEngineV2< const { value: nextMiddleware, done } = middlewareIterator.next(); if (done) { + // This will cause the last middleware to return `undefined`. See the class + // JSDoc or package README for more details. return undefined; } From a0ee96003c7f2282a7db3c4378d7e9446360497a Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 9 Oct 2025 10:31:12 -0700 Subject: [PATCH 61/75] test: Tweak a test --- .../json-rpc-engine/src/v2/asLegacyMiddleware.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/json-rpc-engine/src/v2/asLegacyMiddleware.test.ts b/packages/json-rpc-engine/src/v2/asLegacyMiddleware.test.ts index 268372b0a0..30810dab0e 100644 --- a/packages/json-rpc-engine/src/v2/asLegacyMiddleware.test.ts +++ b/packages/json-rpc-engine/src/v2/asLegacyMiddleware.test.ts @@ -111,12 +111,14 @@ describe('asLegacyMiddleware', () => { }); const legacyEngine = new JsonRpcEngine(); + legacyEngine.push((req, _res, next, _end) => { + expect(req.method).toBe('test_request'); + next(); + }); legacyEngine.push(asLegacyMiddleware(v2Engine)); legacyEngine.push((req, res, _next, end) => { - res.result = null; - expect(req.method).toBe('test_request_2'); - + res.result = null; end(); }); From 5fe6f64999655d287b5419dc1686007bb1bc2d58 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 9 Oct 2025 10:54:35 -0700 Subject: [PATCH 62/75] docs: Fix typo --- packages/json-rpc-engine/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/json-rpc-engine/README.md b/packages/json-rpc-engine/README.md index 445c8b13fe..a3d766dc79 100644 --- a/packages/json-rpc-engine/README.md +++ b/packages/json-rpc-engine/README.md @@ -475,7 +475,7 @@ console.log('Result:', result); // Result: success // ATTN: This will throw "Nothing ended request" -const result2 = await loggingEngine.handle(request): +const result2 = await loggingEngine.handle(request); ``` ### `JsonRpcServer` From ca15180e93b258a637109090605c95add879a4ef Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Fri, 10 Oct 2025 10:37:55 -0700 Subject: [PATCH 63/75] refactor: Constrain the result type by the request --- .../json-rpc-engine/src/asV2Middleware.ts | 15 +++--- .../src/v2/JsonRpcEngineV2.test.ts | 32 ++++++++++++- .../json-rpc-engine/src/v2/JsonRpcEngineV2.ts | 46 +++++++++++-------- .../json-rpc-engine/src/v2/JsonRpcServer.ts | 2 +- .../src/v2/asLegacyMiddleware.ts | 8 ++-- packages/json-rpc-engine/tests/utils.ts | 11 +++++ 6 files changed, 82 insertions(+), 32 deletions(-) diff --git a/packages/json-rpc-engine/src/asV2Middleware.ts b/packages/json-rpc-engine/src/asV2Middleware.ts index 4b1ceb5431..30aabf6811 100644 --- a/packages/json-rpc-engine/src/asV2Middleware.ts +++ b/packages/json-rpc-engine/src/asV2Middleware.ts @@ -2,7 +2,6 @@ import { serializeError } from '@metamask/rpc-errors'; import type { JsonRpcFailure, JsonRpcResponse } from '@metamask/utils'; import { hasProperty, - type Json, type JsonRpcParams, type JsonRpcRequest, } from '@metamask/utils'; @@ -19,9 +18,13 @@ import { propagateToRequest, unserializeError, } from './v2/compatibility-utils'; -// JsonRpcEngineV2 is used in docs. -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import type { JsonRpcMiddleware, JsonRpcEngineV2 } from './v2/JsonRpcEngineV2'; +import type { + // JsonRpcEngineV2 is used in docs. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + JsonRpcEngineV2, + JsonRpcMiddleware, + ResultConstraint, +} from './v2/JsonRpcEngineV2'; /** * Convert a legacy {@link JsonRpcEngine} into a {@link JsonRpcEngineV2} middleware. @@ -32,10 +35,10 @@ import type { JsonRpcMiddleware, JsonRpcEngineV2 } from './v2/JsonRpcEngineV2'; export function asV2Middleware< Params extends JsonRpcParams, Request extends JsonRpcRequest, - Result extends Json, + Result extends ResultConstraint, >(engine: JsonRpcEngine): JsonRpcMiddleware { const middleware = engine.asMiddleware(); - return async ({ request, context, next }): Promise => { + return async ({ request, context, next }): Promise => { const req = deepClone(request) as JsonRpcRequest; propagateToRequest(req, context); diff --git a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts index 5f23eb5409..12afebb94d 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts @@ -11,7 +11,7 @@ import { type JsonRpcNotification, type JsonRpcRequest, } from './utils'; -import { makeRequest } from '../../tests/utils'; +import { makeNotification, makeRequest } from '../../tests/utils'; const jsonrpc = '2.0' as const; @@ -939,6 +939,36 @@ describe('JsonRpcEngineV2', () => { ); }); }); + + describe('request- and notification-only engines', () => { + it('constructs a request-only engine', async () => { + const engine = new JsonRpcEngineV2({ + middleware: [() => null], + }); + + expect(await engine.handle(makeRequest())).toBeNull(); + // @ts-expect-error This is invalid and should cause a type error + await expect(engine.handle(makeNotification())).rejects.toThrow( + new JsonRpcEngineError( + `Result returned for notification: ${stringify(makeNotification())}`, + ), + ); + }); + + it('constructs a notification-only engine', async () => { + const engine = new JsonRpcEngineV2({ + middleware: [() => undefined], + }); + + expect(await engine.handle(makeNotification())).toBeUndefined(); + // TODO: This should cause a type error + await expect(engine.handle(makeRequest())).rejects.toThrow( + new JsonRpcEngineError( + `Nothing ended request: ${stringify(makeRequest())}`, + ), + ); + }); + }); }); describe('destroy', () => { diff --git a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts index 0dc9b13308..29c860ff71 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts @@ -16,13 +16,17 @@ import { } from './utils'; import type { JsonRpcCall } from './utils'; -export type Next = ( - request?: Readonly, -) => Promise | void>; +export type ResultConstraint = + Request extends JsonRpcRequest ? Json : void; + +export type Next< + Request extends JsonRpcCall, + Result extends ResultConstraint, +> = (request?: Readonly) => Promise | undefined>; export type MiddlewareParams< Request extends JsonRpcCall, - Result extends Json | void, + Result extends ResultConstraint, > = { request: Readonly; context: MiddlewareContext; @@ -31,18 +35,24 @@ export type MiddlewareParams< export type JsonRpcMiddleware< Request extends JsonRpcCall = JsonRpcCall, - Result extends Json | void = Json | void, + Result extends ResultConstraint = ResultConstraint, > = ( - params: MiddlewareParams, -) => Readonly | void | Promise | void>; + params: MiddlewareParams, +) => Readonly | undefined | Promise | undefined>; -type RequestState = { +type RequestState< + Request extends JsonRpcCall, + Result extends ResultConstraint, +> = { request: Request; - result: Result | void; + result: Result | undefined; }; -type Options = { - middleware: NonEmptyArray>; +type Options< + Request extends JsonRpcCall, + Result extends ResultConstraint, +> = { + middleware: NonEmptyArray>; }; type HandleOptions = { @@ -86,11 +96,9 @@ type HandleOptions = { */ export class JsonRpcEngineV2< Request extends JsonRpcCall = JsonRpcCall, - Result extends Json = Json, + Result extends ResultConstraint = ResultConstraint, > { - #middleware: Readonly< - NonEmptyArray> - >; + #middleware: Readonly>>; #isDestroyed = false; @@ -207,7 +215,7 @@ export class JsonRpcEngineV2< * @returns The `next()` function factory. */ #makeNextFactory( - middlewareIterator: Iterator>, + middlewareIterator: Iterator>, state: RequestState, context: MiddlewareContext, ): () => Next { @@ -216,7 +224,7 @@ export class JsonRpcEngineV2< const next = async ( request: Request = state.request, - ): Promise => { + ): Promise => { if (wasCalled) { throw new JsonRpcEngineError( `Middleware attempted to call next() multiple times for request: ${stringify(request)}`, @@ -251,9 +259,7 @@ export class JsonRpcEngineV2< return makeNext; } - #makeMiddlewareIterator(): Iterator< - JsonRpcMiddleware - > { + #makeMiddlewareIterator(): Iterator> { return this.#middleware[Symbol.iterator](); } diff --git a/packages/json-rpc-engine/src/v2/JsonRpcServer.ts b/packages/json-rpc-engine/src/v2/JsonRpcServer.ts index b2df68161e..a4a0577c97 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcServer.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcServer.ts @@ -22,7 +22,7 @@ type Options = { engine: JsonRpcEngineV2; } | { - middleware: NonEmptyArray>; + middleware: NonEmptyArray>; } ); diff --git a/packages/json-rpc-engine/src/v2/asLegacyMiddleware.ts b/packages/json-rpc-engine/src/v2/asLegacyMiddleware.ts index 8184d86faf..4864c2f8d4 100644 --- a/packages/json-rpc-engine/src/v2/asLegacyMiddleware.ts +++ b/packages/json-rpc-engine/src/v2/asLegacyMiddleware.ts @@ -1,4 +1,4 @@ -import type { Json, JsonRpcParams, JsonRpcRequest } from '@metamask/utils'; +import type { JsonRpcParams, JsonRpcRequest } from '@metamask/utils'; import { deepClone, @@ -6,7 +6,7 @@ import { makeContext, propagateToRequest, } from './compatibility-utils'; -import type { JsonRpcEngineV2 } from './JsonRpcEngineV2'; +import type { JsonRpcEngineV2, ResultConstraint } from './JsonRpcEngineV2'; import { createAsyncMiddleware } from '..'; import type { JsonRpcMiddleware as LegacyMiddleware } from '..'; @@ -19,7 +19,7 @@ import type { JsonRpcMiddleware as LegacyMiddleware } from '..'; export function asLegacyMiddleware< Params extends JsonRpcParams, Request extends JsonRpcRequest, - Result extends Json, + Result extends ResultConstraint, >(engine: JsonRpcEngineV2): LegacyMiddleware { const middleware = engine.asMiddleware(); return createAsyncMiddleware(async (req, res, next) => { @@ -32,7 +32,7 @@ export function asLegacyMiddleware< context, next: (finalRequest) => { modifiedRequest = finalRequest; - return Promise.resolve(); + return Promise.resolve(undefined); }, }); diff --git a/packages/json-rpc-engine/tests/utils.ts b/packages/json-rpc-engine/tests/utils.ts index b55fd5e753..03f3d748e0 100644 --- a/packages/json-rpc-engine/tests/utils.ts +++ b/packages/json-rpc-engine/tests/utils.ts @@ -1,6 +1,7 @@ import type { JsonRpcRequest } from '@metamask/utils'; import { requestProps } from '../src/v2/compatibility-utils'; +import type { JsonRpcNotification } from '../src/v2/utils'; export const makeRequest = >( params: Request = {} as Request, @@ -13,6 +14,16 @@ export const makeRequest = >( ...params, }) as const satisfies JsonRpcRequest; +export const makeNotification = >( + params: Request = {} as Request, +) => + ({ + jsonrpc: '2.0', + method: 'test_request', + params: [], + ...params, + }) as JsonRpcNotification; + /** * Get the keys of a request that are not part of the standard JSON-RPC request * properties. From f821e0544b97e9cb23b9cf6b8aea4e091c3fe7ef Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Fri, 10 Oct 2025 12:33:33 -0700 Subject: [PATCH 64/75] refactor: Fix handle() overloads, remove handleAny() --- .../src/v2/JsonRpcEngineV2.test.ts | 69 ++++++++----------- .../json-rpc-engine/src/v2/JsonRpcEngineV2.ts | 52 ++++++++------ .../json-rpc-engine/src/v2/JsonRpcServer.ts | 2 +- 3 files changed, 62 insertions(+), 61 deletions(-) diff --git a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts index 12afebb94d..8a2f14670f 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts @@ -5,6 +5,7 @@ import type { JsonRpcMiddleware } from './JsonRpcEngineV2'; import { JsonRpcEngineV2 } from './JsonRpcEngineV2'; import { MiddlewareContext } from './MiddlewareContext'; import { + isRequest, JsonRpcEngineError, stringify, type JsonRpcCall, @@ -567,42 +568,6 @@ describe('JsonRpcEngineV2', () => { }); }); - describe('handleAny', () => { - it(`proxies to 'handle()'`, async () => { - const engine = new JsonRpcEngineV2({ - middleware: [jest.fn(() => null)], - }); - const handleSpy = jest.spyOn(engine, 'handle'); - const request = makeRequest(); - - const result = await engine.handleAny(request); - - expect(result).toBeNull(); - expect(handleSpy).toHaveBeenCalledTimes(1); - expect(handleSpy).toHaveBeenCalledWith(request, undefined); - }); - - it(`proxies to 'handle()' (with context)`, async () => { - const initialContext = new MiddlewareContext(); - initialContext.set('foo', 'bar'); - const engine = new JsonRpcEngineV2({ - middleware: [({ context }) => context.assertGet('foo')], - }); - const handleSpy = jest.spyOn(engine, 'handle'); - const request = makeRequest(); - - const result = await engine.handleAny(request, { - context: initialContext, - }); - - expect(result).toBe('bar'); - expect(handleSpy).toHaveBeenCalledTimes(1); - expect(handleSpy).toHaveBeenCalledWith(request, { - context: initialContext, - }); - }); - }); - describe('composition', () => { describe('asMiddleware', () => { it('ends a request if it returns a value', async () => { @@ -947,7 +912,9 @@ describe('JsonRpcEngineV2', () => { }); expect(await engine.handle(makeRequest())).toBeNull(); - // @ts-expect-error This is invalid and should cause a type error + // @ts-expect-error Valid at runtime, but should cause a type error + expect(await engine.handle(makeRequest() as JsonRpcCall)).toBeNull(); + // @ts-expect-error Invalid at runtime and should cause a type error await expect(engine.handle(makeNotification())).rejects.toThrow( new JsonRpcEngineError( `Result returned for notification: ${stringify(makeNotification())}`, @@ -961,13 +928,37 @@ describe('JsonRpcEngineV2', () => { }); expect(await engine.handle(makeNotification())).toBeUndefined(); - // TODO: This should cause a type error - await expect(engine.handle(makeRequest())).rejects.toThrow( + await expect( + // @ts-expect-error Invalid at runtime and should cause a type error + engine.handle({ id: '1', jsonrpc, method: 'test_request' }), + ).rejects.toThrow( + new JsonRpcEngineError( + `Nothing ended request: ${stringify({ id: '1', jsonrpc, method: 'test_request' })}`, + ), + ); + await expect( + // @ts-expect-error Invalid at runtime and should cause a type error + engine.handle(makeRequest() as JsonRpcRequest), + ).rejects.toThrow( new JsonRpcEngineError( `Nothing ended request: ${stringify(makeRequest())}`, ), ); }); + + it('constructs a mixed engine', async () => { + const engine = new JsonRpcEngineV2({ + middleware: [ + // This is a much-needed conditional, actually + // eslint-disable-next-line jest/no-conditional-in-test + ({ request }) => (isRequest(request) ? null : undefined), + ], + }); + + expect(await engine.handle(makeRequest())).toBeNull(); + expect(await engine.handle(makeNotification())).toBeUndefined(); + expect(await engine.handle(makeRequest() as JsonRpcCall)).toBeNull(); + }); }); }); diff --git a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts index 29c860ff71..0a39025455 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts @@ -16,6 +16,20 @@ import { } from './utils'; import type { JsonRpcCall } from './utils'; +// Helper to forbid `id` on notifications +type WithoutId = Request & { id?: never }; + +// Helper to enable JsonRpcCall overload of handle() +type MixedParam = [ + Extract, +] extends [never] + ? never + : [Extract] extends [never] + ? never + : + | Extract + | WithoutId>; + export type ResultConstraint = Request extends JsonRpcRequest ? Json : void; @@ -115,7 +129,7 @@ export class JsonRpcEngineV2< * @returns The JSON-RPC response. */ async handle( - request: Request & JsonRpcRequest, + request: Extract, options?: HandleOptions, ): Promise; @@ -127,10 +141,25 @@ export class JsonRpcEngineV2< * @param options.context - The context to pass to the middleware. */ async handle( - notification: Request & JsonRpcNotification, + notification: Extract extends never + ? never + : WithoutId>, options?: HandleOptions, ): Promise; + /** + * Handle a JSON-RPC call (i.e. request or notification). + * + * @param call - The JSON-RPC call to handle. + * @param options - The options for the handle operation. + * @param options.context - The context to pass to the middleware. + * @returns The JSON-RPC response, or `void` if the call is a notification. + */ + async handle( + call: MixedParam, + options?: HandleOptions, + ): Promise; + async handle( request: Request, { context }: HandleOptions = {}, @@ -146,25 +175,6 @@ export class JsonRpcEngineV2< return result; } - /** - * Handle a JSON-RPC call (i.e. request or notification). - * - * This exists because a {@link JsonRpcCall} overload of {@link handle} cannot - * coexist with the other overloads of that method. In particular, such an - * overload will always return `void`. - * - * @param request - The JSON-RPC call to handle. - * @param options - The options for the handle operation. - * @param options.context - The context to pass to the middleware. - * @returns The JSON-RPC response, or `void` if the call is a notification. - */ - async handleAny( - request: JsonRpcCall & Request, - options?: HandleOptions, - ): Promise { - return this.handle(request, options); - } - /** * Handle a JSON-RPC request. Throws if a middleware performs an invalid * operation. Permits returning an `undefined` result. diff --git a/packages/json-rpc-engine/src/v2/JsonRpcServer.ts b/packages/json-rpc-engine/src/v2/JsonRpcServer.ts index a4a0577c97..08ce2c0b40 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcServer.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcServer.ts @@ -99,7 +99,7 @@ export class JsonRpcServer { try { const request = this.#coerceRequest(rawRequest, isRequest); - const result = await this.#engine.handleAny(request); + const result = await this.#engine.handle(request); if (result !== undefined) { return { From e3cd041d696dcbb6ea92df2ea3ef3d80188020a9 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Fri, 10 Oct 2025 12:40:37 -0700 Subject: [PATCH 65/75] test: Add mixed engines pipeline test --- .../src/v2/JsonRpcEngineV2.test.ts | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts index 8a2f14670f..9580f3b62f 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts @@ -949,7 +949,7 @@ describe('JsonRpcEngineV2', () => { it('constructs a mixed engine', async () => { const engine = new JsonRpcEngineV2({ middleware: [ - // This is a much-needed conditional, actually + // We need this conditional // eslint-disable-next-line jest/no-conditional-in-test ({ request }) => (isRequest(request) ? null : undefined), ], @@ -959,6 +959,41 @@ describe('JsonRpcEngineV2', () => { expect(await engine.handle(makeNotification())).toBeUndefined(); expect(await engine.handle(makeRequest() as JsonRpcCall)).toBeNull(); }); + + it('composes a pipeline of request- and notification-only engines', async () => { + const requestEngine = new JsonRpcEngineV2({ + middleware: [() => null], + }); + + const notificationEngine = new JsonRpcEngineV2< + JsonRpcNotification, + void + >({ + middleware: [() => undefined], + }); + + const orchestratorEngine = new JsonRpcEngineV2< + JsonRpcCall, + null | void + >({ + middleware: [ + ({ request, context }) => + // We need this conditional + // eslint-disable-next-line jest/no-conditional-in-test + isRequest(request) + ? requestEngine.handle(request, { context }) + : notificationEngine.handle(request as JsonRpcNotification, { + context, + }), + ], + }); + + const result1 = await orchestratorEngine.handle(makeRequest()); + const result2 = await orchestratorEngine.handle(makeNotification()); + + expect(result1).toBeNull(); + expect(result2).toBeUndefined(); + }); }); }); From 25be91815ca8213d49b8a3a7bc96647fcbbb1219 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Fri, 10 Oct 2025 12:46:30 -0700 Subject: [PATCH 66/75] docs: Tweak handle docstrings --- packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts index 0a39025455..01f0c36e4d 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts @@ -121,7 +121,7 @@ export class JsonRpcEngineV2< } /** - * Handle a JSON-RPC request. A result will be returned. + * Handle a JSON-RPC request. * * @param request - The JSON-RPC request to handle. * @param options - The options for the handle operation. @@ -134,7 +134,7 @@ export class JsonRpcEngineV2< ): Promise; /** - * Handle a JSON-RPC notification. No result will be returned. + * Handle a JSON-RPC notification. Notifications do not return a result. * * @param notification - The JSON-RPC notification to handle. * @param options - The options for the handle operation. @@ -148,12 +148,13 @@ export class JsonRpcEngineV2< ): Promise; /** - * Handle a JSON-RPC call (i.e. request or notification). + * Handle a JSON-RPC call, i.e. request or notification. Requests return a + * result, notifications do not. * * @param call - The JSON-RPC call to handle. * @param options - The options for the handle operation. * @param options.context - The context to pass to the middleware. - * @returns The JSON-RPC response, or `void` if the call is a notification. + * @returns The JSON-RPC response, or `undefined` if the call is a notification. */ async handle( call: MixedParam, From a8d4eb6c810b90a30ce4dfea3b68aab9d16a80c1 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 13 Oct 2025 11:20:53 -0700 Subject: [PATCH 67/75] test: Add parallel processing test --- .../src/v2/JsonRpcEngineV2.test.ts | 90 ++++++++++++++++++- 1 file changed, 89 insertions(+), 1 deletion(-) diff --git a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts index 9580f3b62f..7dea7783b0 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts @@ -1,5 +1,5 @@ /* eslint-disable n/callback-return */ // next() is not a Node.js callback. -import type { NonEmptyArray } from '@metamask/utils'; +import type { JsonRpcId, NonEmptyArray } from '@metamask/utils'; import type { JsonRpcMiddleware } from './JsonRpcEngineV2'; import { JsonRpcEngineV2 } from './JsonRpcEngineV2'; @@ -566,6 +566,94 @@ describe('JsonRpcEngineV2', () => { ); }); }); + + describe('parallel requests', () => { + // Basically, a deferred promise + const makeGate = () => { + let release!: () => void; + const gatePromise = new Promise((resolve) => (release = resolve)); + return { wait: () => gatePromise, release }; + }; + + // A deferred promise that resolves when the target amount is reached + const makeCountdownLatch = (target: number) => { + let count = 0; + let release!: () => void; + const countdownPromise = new Promise( + (resolve) => (release = resolve), + ); + + return { + increment: () => { + count += 1; + if (count === target) { + release(); + } + }, + waitAll: () => countdownPromise, + }; + }; + + it('processes requests in parallel (overlap and isolation)', async () => { + const N = 100; + const gate = makeGate(); + const latch = makeCountdownLatch(N); + + let inFlight = 0; + let maxInFlight = 0; + + const engine = new JsonRpcEngineV2({ + middleware: [ + async ({ context, next, request }) => { + // eslint-disable-next-line jest/no-conditional-in-test + context.set('id', context.get('id') ?? request.id); + + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + latch.increment(); + + await gate.wait(); + + inFlight -= 1; + return next(); + }, + ({ context, request }) => { + return `result:${request.id}:${context.get('id') as JsonRpcId}`; + }, + ], + }); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - Jest blows up here, but there's no error at dev time. + const requests: JsonRpcRequest[] = Array.from({ length: N }, (_, i) => + makeRequest({ + id: `${i}`, + }), + ); + + // Staggered handling is necessary to prove context isolation + const resultPromises = requests.map( + (request) => + new Promise((resolve) => { + setTimeout( + () => resolve(engine.handle(request)), + Math.floor(Math.random() * 100), + ); + }), + ); + + await latch.waitAll(); + expect(inFlight).toBe(N); + gate.release(); + + const results = await Promise.all(resultPromises); + expect(results).toStrictEqual( + requests.map((request) => `result:${request.id}:${request.id}`), + ); + expect(inFlight).toBe(0); + expect(maxInFlight).toBe(N); + }); + }); }); describe('composition', () => { From 7d8a23d3124ed8181a0854c99a45329fe8c4e8a8 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Mon, 13 Oct 2025 11:34:29 -0700 Subject: [PATCH 68/75] test: Tweak parallel processing test --- .../json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts index 7dea7783b0..413eca4dda 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts @@ -595,7 +595,7 @@ describe('JsonRpcEngineV2', () => { }; it('processes requests in parallel (overlap and isolation)', async () => { - const N = 100; + const N = 50; const gate = makeGate(); const latch = makeCountdownLatch(N); @@ -631,15 +631,8 @@ describe('JsonRpcEngineV2', () => { }), ); - // Staggered handling is necessary to prove context isolation - const resultPromises = requests.map( - (request) => - new Promise((resolve) => { - setTimeout( - () => resolve(engine.handle(request)), - Math.floor(Math.random() * 100), - ); - }), + const resultPromises = requests.map((request) => + engine.handle(request), ); await latch.waitAll(); From 866aed67a6b937cc76e164798a3aacbbf9a9e3b2 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Tue, 14 Oct 2025 12:47:40 -0700 Subject: [PATCH 69/75] test: Add "no queueing" test case --- .../src/v2/JsonRpcEngineV2.test.ts | 90 +++++++++++++++---- 1 file changed, 71 insertions(+), 19 deletions(-) diff --git a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts index 413eca4dda..c5b956a6be 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts @@ -1,5 +1,6 @@ /* eslint-disable n/callback-return */ // next() is not a Node.js callback. import type { JsonRpcId, NonEmptyArray } from '@metamask/utils'; +import { createDeferredPromise } from '@metamask/utils'; import type { JsonRpcMiddleware } from './JsonRpcEngineV2'; import { JsonRpcEngineV2 } from './JsonRpcEngineV2'; @@ -568,20 +569,16 @@ describe('JsonRpcEngineV2', () => { }); describe('parallel requests', () => { - // Basically, a deferred promise - const makeGate = () => { - let release!: () => void; - const gatePromise = new Promise((resolve) => (release = resolve)); - return { wait: () => gatePromise, release }; - }; - - // A deferred promise that resolves when the target amount is reached - const makeCountdownLatch = (target: number) => { + /** + * A "counter" latch that releases when a target count is reached. + * + * @param target - The target count to reach. + * @returns A counter latch. + */ + const makeCounterLatch = (target: number) => { let count = 0; - let release!: () => void; - const countdownPromise = new Promise( - (resolve) => (release = resolve), - ); + const { promise: countdownPromise, resolve: release } = + createDeferredPromise(); return { increment: () => { @@ -594,10 +591,37 @@ describe('JsonRpcEngineV2', () => { }; }; - it('processes requests in parallel (overlap and isolation)', async () => { - const N = 50; - const gate = makeGate(); - const latch = makeCountdownLatch(N); + /** + * A queue for processing a target number of requests in arbitrary order. + * + * @param size - The size of the queue. + * @returns An "arbitrary" queue. + */ + const makeArbitraryQueue = (size: number) => { + let count = 0; + const queue: { resolve: () => void }[] = new Array(size); + const { promise: gate, resolve: openGate } = createDeferredPromise(); + + const enqueue = async (id: number): Promise => { + const { promise, resolve } = createDeferredPromise(); + queue[id] = { resolve }; + count += 1; + + if (count === size) { + openGate(); + } + return gate.then(() => promise); + }; + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const dequeue = (id: number): void => queue[id]!.resolve(); + return { enqueue, dequeue, filled: () => gate }; + }; + + it('processes requests in parallel with isolated contexts', async () => { + const N = 32; + const { promise: gate, resolve: openGate } = createDeferredPromise(); + const latch = makeCounterLatch(N); let inFlight = 0; let maxInFlight = 0; @@ -612,7 +636,7 @@ describe('JsonRpcEngineV2', () => { maxInFlight = Math.max(maxInFlight, inFlight); latch.increment(); - await gate.wait(); + await gate; inFlight -= 1; return next(); @@ -637,7 +661,7 @@ describe('JsonRpcEngineV2', () => { await latch.waitAll(); expect(inFlight).toBe(N); - gate.release(); + openGate(); const results = await Promise.all(resultPromises); expect(results).toStrictEqual( @@ -646,6 +670,34 @@ describe('JsonRpcEngineV2', () => { expect(inFlight).toBe(0); expect(maxInFlight).toBe(N); }); + + it('eagerly processes requests in parallel, i.e. without queueing them', async () => { + const queue = makeArbitraryQueue(3); + const engine = new JsonRpcEngineV2< + JsonRpcRequest & { id: number }, + null + >({ + middleware: [ + async ({ request }) => { + await queue.enqueue(request.id); + return null; + }, + ], + }); + + const p0 = engine.handle(makeRequest({ id: 0 })); + const p1 = engine.handle(makeRequest({ id: 1 })); + const p2 = engine.handle(makeRequest({ id: 2 })); + + await queue.filled(); + + queue.dequeue(2); + expect(await p2).toBeNull(); + queue.dequeue(0); + expect(await p0).toBeNull(); + queue.dequeue(1); + expect(await p1).toBeNull(); + }); }); }); From cb45c79ec12e1141375b6c20a62289b91bdfc007 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 16 Oct 2025 15:26:45 -0700 Subject: [PATCH 70/75] refactor: Remove JsonRpcEngine Result generic Using a generic Result forced each middleware function to have the same return type. This makes the generic useless. Supporting this, almost all of our uses of the equivalent generic on the legacy JsonRpcMiddlware type are Json, any, or unknown. In consequence, we remove the Result generic. In addition, unfreezes the result returned from asLegacyMiddleware, a problem that was surfaced by the type refactor. Adds a test to verify this behavior. --- .../src/asV2Middleware.test.ts | 4 +- .../json-rpc-engine/src/asV2Middleware.ts | 7 +- .../src/v2/JsonRpcEngineV2.test.ts | 58 ++++++-------- .../json-rpc-engine/src/v2/JsonRpcEngineV2.ts | 79 ++++++++----------- .../json-rpc-engine/src/v2/JsonRpcServer.ts | 3 +- .../src/v2/asLegacyMiddleware.test.ts | 33 +++++--- .../src/v2/asLegacyMiddleware.ts | 7 +- 7 files changed, 91 insertions(+), 100 deletions(-) diff --git a/packages/json-rpc-engine/src/asV2Middleware.test.ts b/packages/json-rpc-engine/src/asV2Middleware.test.ts index 58262b1104..5d317e83d9 100644 --- a/packages/json-rpc-engine/src/asV2Middleware.test.ts +++ b/packages/json-rpc-engine/src/asV2Middleware.test.ts @@ -1,5 +1,5 @@ import { rpcErrors } from '@metamask/rpc-errors'; -import type { Json, JsonRpcRequest } from '@metamask/utils'; +import type { JsonRpcRequest } from '@metamask/utils'; import { JsonRpcEngine } from '.'; import { asV2Middleware } from './asV2Middleware'; @@ -90,7 +90,7 @@ describe('asV2Middleware', () => { }); legacyEngine.push(legacyMiddleware); - const v2Engine = new JsonRpcEngineV2({ + const v2Engine = new JsonRpcEngineV2({ middleware: [ ({ context, next }) => { context.set('value', 1); diff --git a/packages/json-rpc-engine/src/asV2Middleware.ts b/packages/json-rpc-engine/src/asV2Middleware.ts index 30aabf6811..31d514643c 100644 --- a/packages/json-rpc-engine/src/asV2Middleware.ts +++ b/packages/json-rpc-engine/src/asV2Middleware.ts @@ -35,10 +35,9 @@ import type { export function asV2Middleware< Params extends JsonRpcParams, Request extends JsonRpcRequest, - Result extends ResultConstraint, ->(engine: JsonRpcEngine): JsonRpcMiddleware { +>(engine: JsonRpcEngine): JsonRpcMiddleware { const middleware = engine.asMiddleware(); - return async ({ request, context, next }): Promise => { + return async ({ request, context, next }) => { const req = deepClone(request) as JsonRpcRequest; propagateToRequest(req, context); @@ -70,7 +69,7 @@ export function asV2Middleware< if (hasProperty(response, 'error')) { throw unserializeError(response.error); } else if (hasProperty(response, 'result')) { - return response.result as Result; + return response.result as ResultConstraint; } return next(fromLegacyRequest(req as Request)); }; diff --git a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts index c5b956a6be..328909cf41 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts @@ -326,25 +326,24 @@ describe('JsonRpcEngineV2', () => { }); it('handles mixed synchronous and asynchronous middleware', async () => { - const middleware1: JsonRpcMiddleware> = jest.fn( - async ({ context, next }) => { + const middleware1: JsonRpcMiddleware> = + jest.fn(async ({ context, next }) => { context.set('foo', [1]); return next(); - }, - ); - const middleware2: JsonRpcMiddleware> = jest.fn( - ({ context, next }) => { + }); + const middleware2: JsonRpcMiddleware> = + jest.fn(({ context, next }) => { const nums = context.assertGet('foo'); nums.push(2); return next(); - }, - ); - const middleware3: JsonRpcMiddleware> = jest.fn( - async ({ context }) => { - const nums = context.get('foo') as number[]; - return [...nums, 3]; - }, - ); + }); + const middleware3: JsonRpcMiddleware< + JsonRpcRequest, + number[] + > = jest.fn(async ({ context }) => { + const nums = context.assertGet('foo'); + return [...nums, 3]; + }); const engine = new JsonRpcEngineV2({ middleware: [middleware1, middleware2, middleware3], }); @@ -626,7 +625,7 @@ describe('JsonRpcEngineV2', () => { let inFlight = 0; let maxInFlight = 0; - const engine = new JsonRpcEngineV2({ + const engine = new JsonRpcEngineV2({ middleware: [ async ({ context, next, request }) => { // eslint-disable-next-line jest/no-conditional-in-test @@ -673,10 +672,7 @@ describe('JsonRpcEngineV2', () => { it('eagerly processes requests in parallel, i.e. without queueing them', async () => { const queue = makeArbitraryQueue(3); - const engine = new JsonRpcEngineV2< - JsonRpcRequest & { id: number }, - null - >({ + const engine = new JsonRpcEngineV2({ middleware: [ async ({ request }) => { await queue.enqueue(request.id); @@ -705,7 +701,7 @@ describe('JsonRpcEngineV2', () => { describe('asMiddleware', () => { it('ends a request if it returns a value', async () => { // TODO: We may have to do a lot of these casts? - const engine1 = new JsonRpcEngineV2({ + const engine1 = new JsonRpcEngineV2({ middleware: [() => null], }); const engine2 = new JsonRpcEngineV2({ @@ -866,7 +862,7 @@ describe('JsonRpcEngineV2', () => { it('composes nested engines', async () => { const earlierMiddleware = jest.fn(async ({ next }) => next()); - const engine1 = new JsonRpcEngineV2({ + const engine1 = new JsonRpcEngineV2({ middleware: [() => null], }); @@ -1040,7 +1036,7 @@ describe('JsonRpcEngineV2', () => { describe('request- and notification-only engines', () => { it('constructs a request-only engine', async () => { - const engine = new JsonRpcEngineV2({ + const engine = new JsonRpcEngineV2({ middleware: [() => null], }); @@ -1056,7 +1052,7 @@ describe('JsonRpcEngineV2', () => { }); it('constructs a notification-only engine', async () => { - const engine = new JsonRpcEngineV2({ + const engine = new JsonRpcEngineV2({ middleware: [() => undefined], }); @@ -1080,9 +1076,8 @@ describe('JsonRpcEngineV2', () => { }); it('constructs a mixed engine', async () => { - const engine = new JsonRpcEngineV2({ + const engine = new JsonRpcEngineV2({ middleware: [ - // We need this conditional // eslint-disable-next-line jest/no-conditional-in-test ({ request }) => (isRequest(request) ? null : undefined), ], @@ -1094,24 +1089,17 @@ describe('JsonRpcEngineV2', () => { }); it('composes a pipeline of request- and notification-only engines', async () => { - const requestEngine = new JsonRpcEngineV2({ + const requestEngine = new JsonRpcEngineV2({ middleware: [() => null], }); - const notificationEngine = new JsonRpcEngineV2< - JsonRpcNotification, - void - >({ + const notificationEngine = new JsonRpcEngineV2({ middleware: [() => undefined], }); - const orchestratorEngine = new JsonRpcEngineV2< - JsonRpcCall, - null | void - >({ + const orchestratorEngine = new JsonRpcEngineV2({ middleware: [ ({ request, context }) => - // We need this conditional // eslint-disable-next-line jest/no-conditional-in-test isRequest(request) ? requestEngine.handle(request, { context }) diff --git a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts index 01f0c36e4d..1605e6996a 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts @@ -33,40 +33,30 @@ type MixedParam = [ export type ResultConstraint = Request extends JsonRpcRequest ? Json : void; -export type Next< - Request extends JsonRpcCall, - Result extends ResultConstraint, -> = (request?: Readonly) => Promise | undefined>; - -export type MiddlewareParams< - Request extends JsonRpcCall, - Result extends ResultConstraint, -> = { +export type Next = ( + request?: Readonly, +) => Promise> | undefined>; + +export type MiddlewareParams = { request: Readonly; context: MiddlewareContext; - next: Next; + next: Next; }; export type JsonRpcMiddleware< Request extends JsonRpcCall = JsonRpcCall, Result extends ResultConstraint = ResultConstraint, > = ( - params: MiddlewareParams, + params: MiddlewareParams, ) => Readonly | undefined | Promise | undefined>; -type RequestState< - Request extends JsonRpcCall, - Result extends ResultConstraint, -> = { +type RequestState = { request: Request; - result: Result | undefined; + result: Readonly> | undefined; }; -type Options< - Request extends JsonRpcCall, - Result extends ResultConstraint, -> = { - middleware: NonEmptyArray>; +type Options = { + middleware: NonEmptyArray>; }; type HandleOptions = { @@ -108,15 +98,12 @@ type HandleOptions = { * } * ``` */ -export class JsonRpcEngineV2< - Request extends JsonRpcCall = JsonRpcCall, - Result extends ResultConstraint = ResultConstraint, -> { - #middleware: Readonly>>; +export class JsonRpcEngineV2 { + #middleware: Readonly>>; #isDestroyed = false; - constructor({ middleware }: Options) { + constructor({ middleware }: Options) { this.#middleware = [...middleware]; } @@ -129,9 +116,11 @@ export class JsonRpcEngineV2< * @returns The JSON-RPC response. */ async handle( - request: Extract, + request: Extract extends never + ? never + : Extract, options?: HandleOptions, - ): Promise; + ): Promise>; /** * Handle a JSON-RPC notification. Notifications do not return a result. @@ -159,12 +148,12 @@ export class JsonRpcEngineV2< async handle( call: MixedParam, options?: HandleOptions, - ): Promise; + ): Promise | void>; async handle( request: Request, { context }: HandleOptions = {}, - ): Promise { + ): Promise> | void> { const isReq = isRequest(request); const { result } = await this.#handle(request, context); @@ -187,15 +176,12 @@ export class JsonRpcEngineV2< async #handle( originalRequest: Request, context: MiddlewareContext = new MiddlewareContext(), - ): Promise<{ - result: Result | void; - request: Readonly; - }> { + ): Promise> { this.#assertIsNotDestroyed(); deepFreeze(originalRequest); - const state: RequestState = { + const state: RequestState = { request: originalRequest, result: undefined, }; @@ -226,16 +212,16 @@ export class JsonRpcEngineV2< * @returns The `next()` function factory. */ #makeNextFactory( - middlewareIterator: Iterator>, - state: RequestState, + middlewareIterator: Iterator>, + state: RequestState, context: MiddlewareContext, - ): () => Next { - const makeNext = (): Next => { + ): () => Next { + const makeNext = (): Next => { let wasCalled = false; const next = async ( request: Request = state.request, - ): Promise => { + ): Promise> | undefined> => { if (wasCalled) { throw new JsonRpcEngineError( `Middleware attempted to call next() multiple times for request: ${stringify(request)}`, @@ -270,7 +256,7 @@ export class JsonRpcEngineV2< return makeNext; } - #makeMiddlewareIterator(): Iterator> { + #makeMiddlewareIterator(): Iterator> { return this.#middleware[Symbol.iterator](); } @@ -282,8 +268,11 @@ export class JsonRpcEngineV2< * @param state - The current values of the request and result. */ #updateResult( - result: Result | void, - state: RequestState, + result: + | Readonly> + | ResultConstraint + | void, + state: RequestState, ): void { if (isNotification(state.request) && result !== undefined) { throw new JsonRpcEngineError( @@ -328,7 +317,7 @@ export class JsonRpcEngineV2< * * @returns The JSON-RPC middleware. */ - asMiddleware(): JsonRpcMiddleware { + asMiddleware(): JsonRpcMiddleware { this.#assertIsNotDestroyed(); return async ({ request, context, next }) => { diff --git a/packages/json-rpc-engine/src/v2/JsonRpcServer.ts b/packages/json-rpc-engine/src/v2/JsonRpcServer.ts index 08ce2c0b40..bc555df4bb 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcServer.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcServer.ts @@ -1,6 +1,5 @@ import { rpcErrors, serializeError } from '@metamask/rpc-errors'; import type { - Json, JsonRpcParams, JsonRpcRequest, JsonRpcResponse, @@ -22,7 +21,7 @@ type Options = { engine: JsonRpcEngineV2; } | { - middleware: NonEmptyArray>; + middleware: NonEmptyArray; } ); diff --git a/packages/json-rpc-engine/src/v2/asLegacyMiddleware.test.ts b/packages/json-rpc-engine/src/v2/asLegacyMiddleware.test.ts index 30810dab0e..b83652976c 100644 --- a/packages/json-rpc-engine/src/v2/asLegacyMiddleware.test.ts +++ b/packages/json-rpc-engine/src/v2/asLegacyMiddleware.test.ts @@ -1,5 +1,4 @@ import type { - Json, JsonRpcFailure, JsonRpcRequest, JsonRpcSuccess, @@ -13,7 +12,7 @@ import { JsonRpcEngine } from '../JsonRpcEngine'; describe('asLegacyMiddleware', () => { it('converts a v2 engine to a legacy middleware', () => { - const engine = new JsonRpcEngineV2({ + const engine = new JsonRpcEngineV2({ middleware: [() => null], }); const middleware = asLegacyMiddleware(engine); @@ -21,7 +20,7 @@ describe('asLegacyMiddleware', () => { }); it('forwards a result to the legacy engine', async () => { - const v2Engine = new JsonRpcEngineV2({ + const v2Engine = new JsonRpcEngineV2({ middleware: [() => null], }); @@ -35,8 +34,24 @@ describe('asLegacyMiddleware', () => { expect(response.result).toBeNull(); }); + it('forwarded results are not frozen', async () => { + const v2Engine = new JsonRpcEngineV2({ + middleware: [() => []], + }); + + const legacyEngine = new JsonRpcEngine(); + legacyEngine.push(asLegacyMiddleware(v2Engine)); + + const response = (await legacyEngine.handle( + makeRequest(), + )) as JsonRpcSuccess; + + expect(response.result).toStrictEqual([]); + expect(Object.isFrozen(response.result)).toBe(false); + }); + it('forwards an error to the legacy engine', async () => { - const v2Engine = new JsonRpcEngineV2({ + const v2Engine = new JsonRpcEngineV2({ middleware: [ () => { throw new Error('test'); @@ -65,7 +80,7 @@ describe('asLegacyMiddleware', () => { it('allows the legacy engine to continue when not ending the request', async () => { const v2Middleware = jest.fn(({ next }) => next()); - const v2Engine = new JsonRpcEngineV2({ + const v2Engine = new JsonRpcEngineV2({ middleware: [v2Middleware], }); @@ -85,7 +100,7 @@ describe('asLegacyMiddleware', () => { it('allows the legacy engine to continue when not ending the request (passing through the original request)', async () => { const v2Middleware = jest.fn(({ request, next }) => next(request)); - const v2Engine = new JsonRpcEngineV2({ + const v2Engine = new JsonRpcEngineV2({ middleware: [v2Middleware], }); @@ -104,7 +119,7 @@ describe('asLegacyMiddleware', () => { }); it('propagates request modifications to the legacy engine', async () => { - const v2Engine = new JsonRpcEngineV2({ + const v2Engine = new JsonRpcEngineV2({ middleware: [ ({ request, next }) => next({ ...request, method: 'test_request_2' }), ], @@ -138,9 +153,9 @@ describe('asLegacyMiddleware', () => { context.set('newValue', 2); return next(); - }) as JsonRpcMiddleware); + }) satisfies JsonRpcMiddleware); - const v2Engine = new JsonRpcEngineV2({ + const v2Engine = new JsonRpcEngineV2({ middleware: [v2Middleware], }); diff --git a/packages/json-rpc-engine/src/v2/asLegacyMiddleware.ts b/packages/json-rpc-engine/src/v2/asLegacyMiddleware.ts index 4864c2f8d4..96970d50a2 100644 --- a/packages/json-rpc-engine/src/v2/asLegacyMiddleware.ts +++ b/packages/json-rpc-engine/src/v2/asLegacyMiddleware.ts @@ -19,8 +19,9 @@ import type { JsonRpcMiddleware as LegacyMiddleware } from '..'; export function asLegacyMiddleware< Params extends JsonRpcParams, Request extends JsonRpcRequest, - Result extends ResultConstraint, ->(engine: JsonRpcEngineV2): LegacyMiddleware { +>( + engine: JsonRpcEngineV2, +): LegacyMiddleware> { const middleware = engine.asMiddleware(); return createAsyncMiddleware(async (req, res, next) => { const request = fromLegacyRequest(req as Request); @@ -42,7 +43,7 @@ export function asLegacyMiddleware< propagateToRequest(req, context); if (result !== undefined) { - res.result = result; + res.result = deepClone(result) as ResultConstraint; return undefined; } return next(); From 2aeb16827978f8e0ab4be4b4cd4cc1ab6995fca3 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 16 Oct 2025 15:26:54 -0700 Subject: [PATCH 71/75] feat: Make handle() overload abuse even more difficult --- packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts index 1605e6996a..dd94aaf21a 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts @@ -120,7 +120,11 @@ export class JsonRpcEngineV2 { ? never : Extract, options?: HandleOptions, - ): Promise>; + ): Promise< + Extract extends never + ? never + : ResultConstraint + >; /** * Handle a JSON-RPC notification. Notifications do not return a result. @@ -134,7 +138,11 @@ export class JsonRpcEngineV2 { ? never : WithoutId>, options?: HandleOptions, - ): Promise; + ): Promise< + Extract extends never + ? never + : ResultConstraint + >; /** * Handle a JSON-RPC call, i.e. request or notification. Requests return a From 761d8d22bddeda7892df7a261e37839b49ac01db Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Fri, 17 Oct 2025 14:13:28 -0700 Subject: [PATCH 72/75] feat: Improve signatures of JsonRpcServer.handle() --- .../json-rpc-engine/src/v2/JsonRpcServer.ts | 39 +++++++++++++++---- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/packages/json-rpc-engine/src/v2/JsonRpcServer.ts b/packages/json-rpc-engine/src/v2/JsonRpcServer.ts index bc555df4bb..66a8475d4a 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcServer.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcServer.ts @@ -1,5 +1,6 @@ import { rpcErrors, serializeError } from '@metamask/rpc-errors'; import type { + JsonRpcNotification, JsonRpcParams, JsonRpcRequest, JsonRpcResponse, @@ -78,19 +79,43 @@ export class JsonRpcServer { } /** - * Handle an alleged JSON-RPC request. Permits any plain object with a `method` - * property, so long as any other JSON-RPC 2.0 properties are valid. + * Handle a JSON-RPC request. * - * This method never throws. All errors are handled by the instance's - * `onError` callback. A response with a `result` or `error` property is - * returned unless the request is a notification, in which case `undefined` - * is returned. + * This method never throws. For requests, a response is always returned. + * All errors are passed to the engine's `onError` callback. + * + * @param request - The request to handle. + * @returns The JSON-RPC response. + */ + async handle(request: JsonRpcRequest): Promise; + + /** + * Handle a JSON-RPC notification. + * + * This method never throws. For notifications, `undefined` is always returned. + * All errors are passed to the engine's `onError` callback. + * + * @param notification - The notification to handle. + */ + async handle(notification: JsonRpcNotification): Promise; + + /** + * Handle an alleged JSON-RPC request or notification. Permits any plain + * object with `{ method: string }`, so long as any present JSON-RPC 2.0 + * properties are valid. If the object has no `id`, it will be treated as + * a notification and vice versa. + * + * This method never throws. All errors are passed to the engine's + * `onError` callback. A JSON-RPC response is always returned for requests, + * and `undefined` is returned for notifications. * * @param rawRequest - The raw request to handle. * @returns The JSON-RPC response, or `undefined` if the request is a * notification. */ - async handle(rawRequest: unknown): Promise { + async handle(rawRequest: unknown): Promise; + + async handle(rawRequest: unknown): Promise { // If rawRequest is not a notification, the originalId will be attached // to the response. We attach our own, trusted id in #coerceRequest() // while the request is being handled. From 56f5a8d3f898be8d22d8a8fc892e90229b11cfc4 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Fri, 17 Oct 2025 14:13:49 -0700 Subject: [PATCH 73/75] fix: Make compatbility-utils.ts compatible with Jest --- packages/json-rpc-engine/src/v2/compatibility-utils.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/json-rpc-engine/src/v2/compatibility-utils.ts b/packages/json-rpc-engine/src/v2/compatibility-utils.ts index b7d55f28d7..d946dea229 100644 --- a/packages/json-rpc-engine/src/v2/compatibility-utils.ts +++ b/packages/json-rpc-engine/src/v2/compatibility-utils.ts @@ -156,7 +156,9 @@ export function unserializeError(thrown: unknown): Error | JsonRpcError { const error = code === undefined - ? // @ts-expect-error - Error type outdated + ? // Jest complains if we use the `@ts-expect-error` directive here. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - Our error type is outdated. new Error(message, { cause }) : new JsonRpcError(code, message, { ...(isObject(data) ? data : undefined), From 7d7ded12c2e733f19d2ebd5a8c9dd931e87bd07c Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Fri, 17 Oct 2025 14:50:13 -0700 Subject: [PATCH 74/75] refactor: Replace rfdc with klona --- packages/json-rpc-engine/package.json | 2 +- .../src/v2/asLegacyMiddleware.ts | 3 +- .../src/v2/compatibility-utils.test.ts | 37 +++++++++++++++++++ .../src/v2/compatibility-utils.ts | 26 +++++++++---- yarn.lock | 4 +- 5 files changed, 61 insertions(+), 11 deletions(-) diff --git a/packages/json-rpc-engine/package.json b/packages/json-rpc-engine/package.json index 3983aec42c..114fd7c838 100644 --- a/packages/json-rpc-engine/package.json +++ b/packages/json-rpc-engine/package.json @@ -72,7 +72,7 @@ "@metamask/utils": "^11.8.1", "@types/deep-freeze-strict": "^1.1.0", "deep-freeze-strict": "^1.1.1", - "rfdc": "^1.4.1" + "klona": "^2.0.6" }, "devDependencies": { "@lavamoat/allow-scripts": "^3.0.4", diff --git a/packages/json-rpc-engine/src/v2/asLegacyMiddleware.ts b/packages/json-rpc-engine/src/v2/asLegacyMiddleware.ts index 96970d50a2..f5cfe6be40 100644 --- a/packages/json-rpc-engine/src/v2/asLegacyMiddleware.ts +++ b/packages/json-rpc-engine/src/v2/asLegacyMiddleware.ts @@ -43,7 +43,8 @@ export function asLegacyMiddleware< propagateToRequest(req, context); if (result !== undefined) { - res.result = deepClone(result) as ResultConstraint; + // Unclear why the `as unknown` is needed here, but the cast is safe. + res.result = deepClone(result) as unknown as ResultConstraint; return undefined; } return next(); diff --git a/packages/json-rpc-engine/src/v2/compatibility-utils.test.ts b/packages/json-rpc-engine/src/v2/compatibility-utils.test.ts index 4f8350b9bf..d8c477ae60 100644 --- a/packages/json-rpc-engine/src/v2/compatibility-utils.test.ts +++ b/packages/json-rpc-engine/src/v2/compatibility-utils.test.ts @@ -28,6 +28,43 @@ describe('compatibility-utils', () => { expect(clonedRequest).toStrictEqual(request); expect(clonedRequest).not.toBe(request); }); + + it('produces a mutable clone of a frozen object', () => { + const request = Object.freeze({ + jsonrpc, + method: 'test_method' as string, + params: Object.freeze([1, 2, 3]), + id: 1, + }); + + const clonedRequest = deepClone(request); + + expect(clonedRequest).toStrictEqual(request); + expect(clonedRequest).not.toBe(request); + expect(Object.isFrozen(clonedRequest)).toBe(false); + expect(Object.isFrozen(clonedRequest.params)).toBe(false); + + clonedRequest.method = 'modified_method'; + clonedRequest.params[1] = 42; + + expect(request.method).toBe('test_method'); + expect(clonedRequest.params[1]).toBe(42); + }); + + it('ignores symbol properties', () => { + const symbolProp = Symbol('test'); + const request = { + jsonrpc, + method: 'test_method' as string, + params: [1, 2, 3], + id: 1, + [symbolProp]: 'value', + }; + + const clonedRequest = deepClone(request); + // @ts-expect-error - Symbol properties are omitted + expect(clonedRequest[symbolProp]).toBeUndefined(); + }); }); describe('fromLegacyRequest', () => { diff --git a/packages/json-rpc-engine/src/v2/compatibility-utils.ts b/packages/json-rpc-engine/src/v2/compatibility-utils.ts index d946dea229..d4eebf9ccc 100644 --- a/packages/json-rpc-engine/src/v2/compatibility-utils.ts +++ b/packages/json-rpc-engine/src/v2/compatibility-utils.ts @@ -1,7 +1,8 @@ import { getMessageFromCode, JsonRpcError } from '@metamask/rpc-errors'; import type { Json } from '@metamask/utils'; import { hasProperty, isObject } from '@metamask/utils'; -import rfdc from 'rfdc'; +// ATTN: We must NOT use 'klona/full' here because it freezes properties on the clone. +import { klona } from 'klona'; import { MiddlewareContext } from './MiddlewareContext'; import { stringify, type JsonRpcRequest } from './utils'; @@ -9,16 +10,27 @@ import { stringify, type JsonRpcRequest } from './utils'; // Legacy engine compatibility utils /** - * Create a deep clone of a value. Assumes acyclical objects. Ignores the - * prototype chain. + * Create a deep clone of a value as follows: + * - Assumes acyclical objects + * - Does not copy property descriptors (i.e. uses mutable defaults) + * - Ignores non-enumerable properties + * - Ignores getters and setters * + * @throws If the value is an object with a circular reference. * @param value - The value to clone. * @returns The cloned value. */ -export const deepClone = rfdc({ - circles: false, - proto: false, -}); +export const deepClone = (value: T): DeepCloned => + klona(value) as DeepCloned; + +// Matching the default implementation of klona, this type: +// - Removes readonly modifiers +// - Excludes non-enumerable / symbol properties +type DeepCloned = T extends readonly (infer U)[] + ? DeepCloned[] + : T extends object + ? { -readonly [K in keyof T & (string | number)]: DeepCloned } + : T; /** * Standard JSON-RPC request properties. diff --git a/yarn.lock b/yarn.lock index 88326f0993..4ba4d28ba4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3833,7 +3833,7 @@ __metadata: deepmerge: "npm:^4.2.2" jest: "npm:^27.5.1" jest-it-up: "npm:^2.0.2" - rfdc: "npm:^1.4.1" + klona: "npm:^2.0.6" ts-jest: "npm:^27.1.4" typedoc: "npm:^0.24.8" typescript: "npm:~5.2.2" @@ -13794,7 +13794,7 @@ __metadata: languageName: node linkType: hard -"rfdc@npm:^1.3.0, rfdc@npm:^1.4.1": +"rfdc@npm:^1.3.0": version: 1.4.1 resolution: "rfdc@npm:1.4.1" checksum: 10/2f3d11d3d8929b4bfeefc9acb03aae90f971401de0add5ae6c5e38fec14f0405e6a4aad8fdb76344bfdd20c5193110e3750cbbd28ba86d73729d222b6cf4a729 From 471ff5e1fa02b602409d4f8a9f994e930a9d11c5 Mon Sep 17 00:00:00 2001 From: Erik Marks <25517051+rekmarks@users.noreply.github.com> Date: Thu, 23 Oct 2025 23:02:12 -0700 Subject: [PATCH 75/75] feat(json-rpc-engine): Type-safe context inference (#6902) --- packages/json-rpc-engine/README.md | 285 +++++++-- packages/json-rpc-engine/src/README.md | 2 +- .../src/asV2Middleware.test.ts | 54 +- .../src/v2/JsonRpcEngineV2.test.ts | 565 +++++++++++------- .../json-rpc-engine/src/v2/JsonRpcEngineV2.ts | 141 ++++- .../src/v2/JsonRpcServer.test.ts | 3 +- .../json-rpc-engine/src/v2/JsonRpcServer.ts | 5 +- .../src/v2/MiddlewareContext.test.ts | 35 +- .../src/v2/MiddlewareContext.ts | 131 +++- .../src/v2/asLegacyMiddleware.test.ts | 55 +- .../src/v2/compatibility-utils.test.ts | 23 +- .../src/v2/compatibility-utils.ts | 2 +- packages/json-rpc-engine/src/v2/index.ts | 10 +- packages/json-rpc-engine/src/v2/utils.test.ts | 14 +- packages/json-rpc-engine/src/v2/utils.ts | 49 +- packages/json-rpc-engine/tests/utils.ts | 29 + 16 files changed, 1027 insertions(+), 376 deletions(-) diff --git a/packages/json-rpc-engine/README.md b/packages/json-rpc-engine/README.md index a3d766dc79..1113ede22d 100644 --- a/packages/json-rpc-engine/README.md +++ b/packages/json-rpc-engine/README.md @@ -17,9 +17,21 @@ or ```ts import { JsonRpcEngineV2 } from '@metamask/json-rpc-engine/v2'; +import type { + Json, + JsonRpcMiddleware, + MiddlewareContext, +} from '@metamask/json-rpc-engine/v2'; + +type Middleware = JsonRpcMiddleware< + JsonRpcRequest, + Json, + MiddlewareContext<{ hello: string }> +>; -const engine = new JsonRpcEngineV2({ - // Create a stack of middleware and pass it to the engine: +// Engines are instantiated using the `create()` factory method as opposed to +// the constructor, which is private. +const engine = JsonRpcEngineV2.create({ middleware: [ ({ request, next, context }) => { if (request.method === 'hello') { @@ -28,7 +40,7 @@ const engine = new JsonRpcEngineV2({ } return null; }, - ({ context }) => context.get('hello'), + ({ context }) => context.assertGet('hello'), ], }); ``` @@ -81,7 +93,7 @@ import { JsonRpcEngine } from '@metamask/json-rpc-engine'; const legacyEngine = new JsonRpcEngine(); -const v2Engine = new JsonRpcEngineV2({ +const v2Engine = JsonRpcEngineV2.create({ middleware: [ // ... ], @@ -91,8 +103,8 @@ legacyEngine.push(asLegacyMiddleware(v2Engine)); ``` In keeping with the conventions of the legacy engine, non-JSON-RPC string properties of the `context` will be -copied over to the request once the V2 engine is done with the request. _Note that any symbol keys of the `context` -will **not** be copied over._ +copied over to the request once the V2 engine is done with the request. _Note that **only `string` keys** of +the `context` will be copied over._ ### Middleware @@ -104,27 +116,51 @@ They receive a `MiddlewareParams` object containing: - `context` - An append-only `Map` for passing data between middleware - `next` - - Function to call the next middleware in the stack + - Function that calls the next middleware in the stack and returns its result (if any) Here's a basic example: ```ts -const engine = new JsonRpcEngineV2({ +const engine = JsonRpcEngineV2.create({ middleware: [ ({ next, context }) => { context.set('foo', 'bar'); - // Proceed to the next middleware + // Proceed to the next middleware and return its result return next(); }, async ({ request, context }) => { await doSomething(request, context.get('foo')); - // Return a result to end the request + // Return a result wihout calling next() to end the request return 42; }, ], }); ``` +In practice, middleware functions are often defined apart from the engine in which +they are used. Middleware defined in this manner must use the `JsonRpcMiddleware` type: + +```ts +export const permissionMiddleware: JsonRpcMiddleware< + JsonRpcRequest, + Json, // The result + MiddlewareContext<{ user: User; permissions: Permissions }> +> = async ({ request, context, next }) => { + const user = context.assertGet('user'); + const permissions = await getUserPermissions(user.id); + context.set('permissions', permissions); + return next(); +}; +``` + +Middleware can specify a return type, however `next()` always returns the widest possible +type based on the type of the `request`. See [Requests vs. notifications](#requests-vs-notifications) +for more details. + +Creating a useful `JsonRpcEngineV2` requires composing differently typed middleware together. +See [Engine composition](#engine-composition) for how to +accomplish this in the same or a set of composed engines. + ### Requests vs. notifications JSON-RPC requests come in two flavors: @@ -132,11 +168,14 @@ JSON-RPC requests come in two flavors: - [Requests](https://www.jsonrpc.org/specification#request_object), i.e. request objects _with_ an `id` - [Notifications](https://www.jsonrpc.org/specification#notification), i.e. request objects _without_ an `id` +`next()` returns `Json` for requests, `void` for notifications, and `Json | void` if the type of the request +object is not known. + For requests, one of the engine's middleware must "end" the request by returning a non-`undefined` result, or `.handle()` will throw an error: ```ts -const engine = new JsonRpcEngineV2({ +const engine = JsonRpcEngineV2.create({ middleware: [ () => { if (Math.random() > 0.5) { @@ -158,7 +197,7 @@ try { ``` For notifications, on the other hand, one of the engine's middleware must return `undefined` to end the request, -and any non-`undefined` return values will cause an error: +and any non-`undefined` return values will cause an error to be thrown: ```ts const notification = { jsonrpc: '2.0', method: 'hello' }; @@ -174,6 +213,12 @@ try { If your middleware may be passed both requests and notifications, use the `isRequest` or `isNotification` utilities to determine what to do: +> [!NOTE] +> Middleware that handle both requests and notifications—i.e. the `JsonRpcCall` type— +> must ensure that their return values are valid for incoming requests at runtime. +> There is no compile time type error if such a middleware returns e.g. a string +> for a notification. + ```ts import { isRequest, @@ -181,7 +226,7 @@ import { JsonRpcEngineV2, } from '@metamask/json-rpc-engine/v2'; -const engine = new JsonRpcEngineV2({ +const engine = JsonRpcEngineV2.create({ middleware: [ async ({ request, next }) => { if (isRequest(request) && request.method === 'everything') { @@ -208,7 +253,7 @@ Middleware can modify the `method` and `params` properties by passing a new request object to `next()`: ```ts -const engine = new JsonRpcEngineV2({ +const engine = JsonRpcEngineV2.create({ middleware: [ ({ request, next }) => { // Modify the request for subsequent middleware @@ -231,12 +276,12 @@ Modifying the `jsonrpc` or `id` properties is not allowed, and will cause an error: ```ts -const engine = new JsonRpcEngineV2({ +const engine = JsonRpcEngineV2.create({ middleware: [ ({ request, next }) => { - // Modifying either property will cause an error return next({ ...request, + // Modifying either property will cause an error jsonrpc: '3.0', id: 'foo', }); @@ -244,6 +289,9 @@ const engine = new JsonRpcEngineV2({ () => 42, ], }); + +// Error: Middleware attempted to modify readonly property... +await engine.handle(anyRequest); ``` ### Result handling @@ -251,7 +299,7 @@ const engine = new JsonRpcEngineV2({ Middleware can observe the result by awaiting `next()`: ```ts -const engine = new JsonRpcEngineV2({ +const engine = JsonRpcEngineV2.create({ middleware: [ async ({ request, next }) => { const startTime = Date.now(); @@ -263,8 +311,8 @@ const engine = new JsonRpcEngineV2({ `Request ${request.method} producing ${result} took ${duration}ms`, ); - // By returning undefined, the same result will be forwarded to earlier - // middleware awaiting next() + // By returning `undefined`, the result will be forwarded unmodified to earlier + // middleware. }, ({ request }) => { return 'Hello, World!'; @@ -277,7 +325,7 @@ Like the `request`, the `result` is also immutable. Middleware can update the result by returning a new one. ```ts -const engine = new JsonRpcEngineV2({ +const engine = JsonRpcEngineV2.create({ middleware: [ async ({ request, next }) => { const result = await next(); @@ -294,6 +342,7 @@ const engine = new JsonRpcEngineV2({ }; } + // Returning the unmodified result is equivalent to returning `undefined` return result; }, ({ request }) => { @@ -318,12 +367,12 @@ console.log(result); // } ``` -### Context sharing +### The `MiddlewareContext` Use the `context` to share data between middleware: ```ts -const engine = new JsonRpcEngineV2({ +const engine = JsonRpcEngineV2.create({ middleware: [ async ({ context, next }) => { context.set('user', { id: '123', name: 'Alice' }); @@ -331,8 +380,7 @@ const engine = new JsonRpcEngineV2({ }, async ({ context, next }) => { // context.assertGet() throws if the value does not exist - // Use with caution: it does not otherwise perform any type checks. - const user = context.assertGet<{ id: string; name: string }>('user'); + const user = context.assertGet('user') as { id: string; name: string }; context.set('permissions', await getUserPermissions(user.id)); return next(); }, @@ -345,12 +393,13 @@ const engine = new JsonRpcEngineV2({ }); ``` -The `context` accepts symbol and string keys. To prevent accidental naming collisions, -it is append-only with deletions. -If you need to modify a context value over multiple middleware, use an array or object: +The `context` supports `PropertyKey` keys, i.e. strings, numbers, and symbols. +To prevent accidental naming collisions, existing keys must be deleted before they can be +overwritten via `set()`. +Context values are not frozen, and objects can be mutated as normal: ```ts -const engine = new JsonRpcEngineV2({ +const engine = JsonRpcEngineV2.create({ middleware: [ async ({ context, next }) => { context.set('user', { id: '123', name: 'Alice' }); @@ -366,12 +415,37 @@ const engine = new JsonRpcEngineV2({ }); ``` +#### Constraining context keys and values + +The context exposes a generic parameter `KeyValues`, which determines the keys and values +a context instance supports: + +```ts +const context = new MiddlewareContext(); +context.set('foo', 'bar'); +context.get('foo'); // 'bar' +context.get('fizz'); // undefined +``` + +By default, `KeyValues` is `Record`. However, any object type can be +specified, effectively turning the context into a strongly typed `Map`: + +```ts +const context = new MiddlewareContext<{ foo: string }>([['foo', 'bar']]); +context.get('foo'); // 'bar' +context.get('fizz'); // Type error +``` + +The context is itself exposed as the third generic parameter of the `JsonRpcMiddleware` type. +See [Instrumenting middleware pipelines](#instrumenting-middleware-pipelines) for how to +compose different context types together. + ### Error handling Errors in middleware are propagated up the call stack: ```ts -const engine = new JsonRpcEngineV2({ +const engine = JsonRpcEngineV2.create({ middleware: [ ({ next }) => { return next(); @@ -395,7 +469,7 @@ try { If your middleware awaits `next()`, it can handle errors using `try`/`catch`: ```ts -const engine = new JsonRpcEngineV2({ +const engine = JsonRpcEngineV2.create({ middleware: [ ({ request, next }) => { try { @@ -423,12 +497,102 @@ console.log('Result:', result); // Result: 42 ``` +#### Internal errors + +The engine throws `JsonRpcEngineError` values when its invariants are violated, e.g. a middleware returns +a result value for a notification. +If you want to reliably detect these cases, use `JsonRpcEngineError.isInstance(error)`, which works across +versions of this package in the same realm. + ### Engine composition +#### Instrumenting middleware pipelines + +As discussed in the [Middleware](#middleware) section, middleware are often defined apart from the +engine in which they are used. To be used within the same engine, a set of middleware must have +compatible types. Specifically, all middleware must: + +- Handle either `JsonRpcRequest`, `JsonRpcNotification`, or both (i.e. `JsonRpcCall`) + - It is okay to mix `JsonRpcCall` middleware with either `JsonRpcRequest` or `JsonRpcNotification` + middleware, as long as the latter two are not mixed together. +- Return valid results for the overall request type +- Specify mutually inclusive context types + - The context types may be the same, partially intersecting, or completely disjoint + so long as they are not mutually exclusive. + +For example, the following middleware are compatible: + +```ts +const middleware1: JsonRpcMiddleware< + JsonRpcRequest, + Json, + MiddlewareContext<{ foo: string }> +> = /* ... */; + +const middleware2: JsonRpcMiddleware< + JsonRpcRequest, + Json, + MiddlewareContext<{ bar: string }> +> = /* ... */; + +const middleware3: JsonRpcMiddleware< + JsonRpcRequest, + { foo: string; bar: string }, + MiddlewareContext<{ foo: string; bar: string; baz: number }> +> = /* ... */; + +// ✅ OK +const engine = JsonRpcEngineV2.create({ + middleware: [middleware1, middleware2, middleware3], +}); +``` + +The following middleware are incompatible due to mismatched request types: + +> [!WARNING] +> Providing `JsonRpcRequest`- and `JsonRpcNotification`-only middleware to the same engine is +> unsound and should be avoided. However, doing so will **not** cause a type error, and it +> is the programmer's responsibility to prevent it from happening. + +```ts +const middleware1: JsonRpcMiddleware = /* ... */; + +const middleware2: JsonRpcMiddleware = /* ... */; + +// ⚠️ Attempting to call engine.handle() will NOT cause a type error, but it +// may cause errors at runtime and should be avoided. +const engine = JsonRpcEngineV2.create({ + middleware: [middleware1, middleware2], +}); +``` + +Finally, these middleware are incompatible due to mismatched context types: + +```ts +const middleware1: JsonRpcMiddleware< + JsonRpcRequest, + Json, + MiddlewareContext<{ foo: string }> +> = /* ... */; + +const middleware2: JsonRpcMiddleware< + JsonRpcRequest, + Json, + MiddlewareContext<{ foo: number }> +> = /* ... */; + +// ❌ The type of the engine is `never`; accessing any property will cause a type error +const engine = JsonRpcEngineV2.create({ + middleware: [middleware1, middleware2], +}); +``` + +#### `asMiddleware()` + Engines can be nested by converting them to middleware using `asMiddleware()`: ```ts -const subEngine = new JsonRpcEngineV2({ +const subEngine = JsonRpcEngineV2.create({ middleware: [ ({ request }) => { return 'Sub-engine result'; @@ -436,7 +600,7 @@ const subEngine = new JsonRpcEngineV2({ ], }); -const mainEngine = new JsonRpcEngineV2({ +const mainEngine = JsonRpcEngineV2.create({ middleware: [ subEngine.asMiddleware(), ({ request, next }) => { @@ -451,7 +615,7 @@ Engines used as middleware may return `undefined` for requests, but only when used as middleware: ```ts -const loggingEngine = new JsonRpcEngineV2({ +const loggingEngine = JsonRpcEngineV2.create({ middleware: [ ({ request, next }) => { console.log('Observed request:', request.method); @@ -459,7 +623,7 @@ const loggingEngine = new JsonRpcEngineV2({ ], }); -const mainEngine = new JsonRpcEngineV2({ +const mainEngine = JsonRpcEngineV2.create({ middleware: [ loggingEngine.asMiddleware(), ({ request }) => { @@ -478,6 +642,40 @@ console.log('Result:', result); const result2 = await loggingEngine.handle(request); ``` +#### Calling `handle()` in a middleware + +You can also compose different engines together by calling `handle(request, context)` +on a different engine in a middleware. Keep in mind that, unlike when using `asMiddleware()`, +these "sub"-engines must return results for requests. + +This method of composition can be useful to instrument request- and notification-only +middleware pipelines: + +```ts +const requestEngine = JsonRpcEngineV2.create({ + middleware: [ + /* Request-only middleware */ + ], +}); + +const notificationEngine = JsonRpcEngineV2.create({ + middleware: [ + /* Notification-only middleware */ + ], +}); + +const orchestratorEngine = JsonRpcEngineV2.create({ + middleware: [ + ({ request, context }) => + isRequest(request) + ? requestEngine.handle(request, { context }) + : notificationEngine.handle(request as JsonRpcNotification, { + context, + }), + ], +}); +``` + ### `JsonRpcServer` The `JsonRpcServer` wraps a `JsonRpcEngineV2` to provide JSON-RPC 2.0 compliance and error handling. It coerces raw request objects into well-formed requests and handles error serialization. @@ -505,17 +703,24 @@ if ('result' in response) { // Handle error response } -// Notifications return undefined +// Notifications always return undefined const notification = { jsonrpc: '2.0', method: 'hello' }; await server.handle(notification); // Returns undefined ``` -The server accepts any object with a `method` property and validates JSON-RPC 2.0 -compliance. -Response objects are returned for requests but not notifications, and contain +The server accepts any object with a `method` property, coercing it into a request or notification +depending on the presence or absence of the `id` property, respectively. +Except for the `id`, all present JSON-RPC 2.0 fields are validated for spec conformance. +The `id` is replaced during request processing with an internal, trusted value, although the +original `id` is attached to the response before it is returned. + +Response objects are returned for requests, and contain the `result` in case of success and `error` in case of failure. -Errors thrown by the underlying engine are passed to `onError` before being serialized -and attached to the response object via the `error` property. +`undefined` is always returned for notifications. + +Errors thrown by the underlying engine are always passed to `onError` unmodified. +If the request is not a notification, the error is subsequently serialized and attached +to the response object via the `error` property. ## Contributing diff --git a/packages/json-rpc-engine/src/README.md b/packages/json-rpc-engine/src/README.md index 61fe01051f..a83af82fbc 100644 --- a/packages/json-rpc-engine/src/README.md +++ b/packages/json-rpc-engine/src/README.md @@ -33,7 +33,7 @@ import { asV2Middleware, JsonRpcEngine } from '@metamask/json-rpc-engine'; const legacyEngine = new JsonRpcEngine(); legacyEngine.push(/* ... */); -const v2Engine = new JsonRpcEngineV2({ +const v2Engine = JsonRpcEngineV2.create({ middleware: [asV2Middleware(legacyEngine)], }); ``` diff --git a/packages/json-rpc-engine/src/asV2Middleware.test.ts b/packages/json-rpc-engine/src/asV2Middleware.test.ts index 5d317e83d9..81d06190e9 100644 --- a/packages/json-rpc-engine/src/asV2Middleware.test.ts +++ b/packages/json-rpc-engine/src/asV2Middleware.test.ts @@ -1,10 +1,16 @@ import { rpcErrors } from '@metamask/rpc-errors'; -import type { JsonRpcRequest } from '@metamask/utils'; +import type { Json, JsonRpcRequest } from '@metamask/utils'; import { JsonRpcEngine } from '.'; import { asV2Middleware } from './asV2Middleware'; import { JsonRpcEngineV2 } from './v2/JsonRpcEngineV2'; -import { getExtraneousKeys, makeRequest } from '../tests/utils'; +import type { JsonRpcMiddleware as V2Middleware } from './v2/JsonRpcEngineV2'; +import type { MiddlewareContext } from './v2/MiddlewareContext'; +import { + getExtraneousKeys, + makeNullMiddleware, + makeRequest, +} from '../tests/utils'; describe('asV2Middleware', () => { it('converts a legacy engine to a v2 middleware', () => { @@ -20,7 +26,7 @@ describe('asV2Middleware', () => { end(); }); - const v2Engine = new JsonRpcEngineV2({ + const v2Engine = JsonRpcEngineV2.create({ middleware: [asV2Middleware(legacyEngine)], }); @@ -35,7 +41,7 @@ describe('asV2Middleware', () => { end(); }); - const v2Engine = new JsonRpcEngineV2({ + const v2Engine = JsonRpcEngineV2.create({ middleware: [asV2Middleware(legacyEngine)], }); @@ -51,7 +57,7 @@ describe('asV2Middleware', () => { end(); }); - const v2Engine = new JsonRpcEngineV2({ + const v2Engine = JsonRpcEngineV2.create({ middleware: [asV2Middleware(legacyEngine)], }); @@ -67,8 +73,8 @@ describe('asV2Middleware', () => { }); legacyEngine.push(legacyMiddleware); - const v2Engine = new JsonRpcEngineV2({ - middleware: [asV2Middleware(legacyEngine), () => null], + const v2Engine = JsonRpcEngineV2.create({ + middleware: [asV2Middleware(legacyEngine), makeNullMiddleware()], }); const result = await v2Engine.handle(makeRequest()); @@ -90,24 +96,22 @@ describe('asV2Middleware', () => { }); legacyEngine.push(legacyMiddleware); - const v2Engine = new JsonRpcEngineV2({ - middleware: [ - ({ context, next }) => { - context.set('value', 1); - return next(); - }, - asV2Middleware(legacyEngine), - ({ context }) => { - observedContextValues.push(context.assertGet('newValue')); - - expect(Array.from(context.keys())).toStrictEqual([ - 'value', - 'newValue', - ]); - - return null; - }, - ], + type Context = MiddlewareContext>; + const middleware1: V2Middleware = ({ + context, + next, + }) => { + context.set('value', 1); + return next(); + }; + const middleware2: V2Middleware = ({ + context, + }) => { + observedContextValues.push(context.assertGet('newValue')); + return null; + }; + const v2Engine = JsonRpcEngineV2.create({ + middleware: [middleware1, asV2Middleware(legacyEngine), middleware2], }); await v2Engine.handle(makeRequest()); diff --git a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts index 328909cf41..45e30a6afb 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.test.ts @@ -1,9 +1,10 @@ /* eslint-disable n/callback-return */ // next() is not a Node.js callback. -import type { JsonRpcId, NonEmptyArray } from '@metamask/utils'; +import type { Json, JsonRpcId } from '@metamask/utils'; import { createDeferredPromise } from '@metamask/utils'; -import type { JsonRpcMiddleware } from './JsonRpcEngineV2'; +import type { JsonRpcMiddleware, ResultConstraint } from './JsonRpcEngineV2'; import { JsonRpcEngineV2 } from './JsonRpcEngineV2'; +import type { EmptyContext } from './MiddlewareContext'; import { MiddlewareContext } from './MiddlewareContext'; import { isRequest, @@ -13,16 +14,73 @@ import { type JsonRpcNotification, type JsonRpcRequest, } from './utils'; -import { makeNotification, makeRequest } from '../../tests/utils'; +import { + makeNotification, + makeNotificationMiddleware, + makeNullMiddleware, + makeRequest, + makeRequestMiddleware, +} from '../../tests/utils'; const jsonrpc = '2.0' as const; describe('JsonRpcEngineV2', () => { + describe('create', () => { + it('throws if the middleware array is empty', () => { + expect(() => JsonRpcEngineV2.create({ middleware: [] })).toThrow( + new JsonRpcEngineError('Middleware array cannot be empty'), + ); + }); + + it('type errors if passed middleware with incompatible context types', async () => { + const middleware1: JsonRpcMiddleware< + JsonRpcCall, + ResultConstraint, + MiddlewareContext<{ foo: string }> + > = ({ next, context }) => { + context.set('foo', 'bar'); + return next(); + }; + + const middleware2: JsonRpcMiddleware< + JsonRpcCall, + ResultConstraint, + MiddlewareContext<{ foo: number }> + > = ({ context }) => context.assertGet('foo'); + const engine = JsonRpcEngineV2.create({ + middleware: [middleware1, middleware2], + }); + + // @ts-expect-error - The engine is `InvalidEngine`. + expect(await engine.handle(makeRequest())).toBe('bar'); + }); + + // Keeping this here for documentation purposes. + // eslint-disable-next-line jest/no-disabled-tests + it.skip('type errors if passed middleware with incompatible request types', async () => { + const middleware1: JsonRpcMiddleware = ({ next }) => + next(); + const middleware2: JsonRpcMiddleware = () => { + return 'foo'; + }; + const engine = JsonRpcEngineV2.create({ + middleware: [middleware1, middleware2], + }); + + // TODO: We want this to cause a type error, but it's unclear if it can be + // made to work due to the difficulty (impossibility?) of distinguishing + // between these two cases: + // - JsonRpcMiddleware | JsonRpcMiddleware (invalid) + // - JsonRpcMiddleware | JsonRpcMiddleware (valid) + expect(await engine.handle(makeRequest() as JsonRpcRequest)).toBe('foo'); + }); + }); + describe('handle', () => { describe('notifications', () => { it('passes the notification through a middleware', async () => { const middleware: JsonRpcMiddleware = jest.fn(); - const engine = new JsonRpcEngineV2({ + const engine = JsonRpcEngineV2.create({ middleware: [middleware], }); const notification = { jsonrpc, method: 'test_request' }; @@ -38,7 +96,7 @@ describe('JsonRpcEngineV2', () => { }); it('returns no result', async () => { - const engine = new JsonRpcEngineV2({ + const engine = JsonRpcEngineV2.create({ middleware: [jest.fn()], }); const notification = { jsonrpc, method: 'test_request' }; @@ -51,7 +109,7 @@ describe('JsonRpcEngineV2', () => { it('returns no result, with multiple middleware', async () => { const middleware1 = jest.fn(({ next }) => next()); const middleware2 = jest.fn(); - const engine = new JsonRpcEngineV2({ + const engine = JsonRpcEngineV2.create({ middleware: [middleware1, middleware2], }); const notification = { jsonrpc, method: 'test_request' }; @@ -64,7 +122,7 @@ describe('JsonRpcEngineV2', () => { }); it('throws if a middleware throws', async () => { - const engine = new JsonRpcEngineV2({ + const engine = JsonRpcEngineV2.create({ middleware: [ jest.fn(() => { throw new Error('test'); @@ -79,7 +137,7 @@ describe('JsonRpcEngineV2', () => { }); it('throws if a middleware throws, with multiple middleware', async () => { - const engine = new JsonRpcEngineV2({ + const engine = JsonRpcEngineV2.create({ middleware: [ jest.fn(({ next }) => next()), jest.fn(() => { @@ -95,8 +153,8 @@ describe('JsonRpcEngineV2', () => { }); it('throws if a result is returned, from the first middleware', async () => { - const engine = new JsonRpcEngineV2({ - middleware: [jest.fn(() => 'foo')], + const engine = JsonRpcEngineV2.create({ + middleware: [() => 'foo'], }); const notification = { jsonrpc, method: 'test_request' }; @@ -108,13 +166,13 @@ describe('JsonRpcEngineV2', () => { }); it('throws if a result is returned, from a later middleware', async () => { - const engine = new JsonRpcEngineV2({ + const engine = JsonRpcEngineV2.create({ middleware: [ - jest.fn(async ({ next }) => { + async ({ next }) => { await next(); return undefined; - }), - jest.fn(() => null), + }, + makeNullMiddleware(), ], }); const notification = { jsonrpc, method: 'test_request' }; @@ -127,7 +185,7 @@ describe('JsonRpcEngineV2', () => { }); it('throws if a middleware calls next() multiple times', async () => { - const engine = new JsonRpcEngineV2({ + const engine = JsonRpcEngineV2.create({ middleware: [ jest.fn(async ({ next }) => { await next(); @@ -148,10 +206,8 @@ describe('JsonRpcEngineV2', () => { describe('requests', () => { it('returns a result from the middleware', async () => { - const middleware: JsonRpcMiddleware = jest.fn( - () => null, - ); - const engine = new JsonRpcEngineV2({ + const middleware = jest.fn(() => null); + const engine = JsonRpcEngineV2.create({ middleware: [middleware], }); const request = makeRequest(); @@ -168,9 +224,9 @@ describe('JsonRpcEngineV2', () => { }); it('returns a result from the middleware, with multiple middleware', async () => { - const middleware1 = jest.fn(({ next }) => next()); - const middleware2 = jest.fn(() => null); - const engine = new JsonRpcEngineV2({ + const middleware1: JsonRpcMiddleware = jest.fn(({ next }) => next()); + const middleware2: JsonRpcMiddleware = jest.fn(() => null); + const engine = JsonRpcEngineV2.create({ middleware: [middleware1, middleware2], }); const request = makeRequest(); @@ -193,7 +249,7 @@ describe('JsonRpcEngineV2', () => { }); it('throws if a middleware throws', async () => { - const engine = new JsonRpcEngineV2({ + const engine = JsonRpcEngineV2.create({ middleware: [ jest.fn(() => { throw new Error('test'); @@ -207,7 +263,7 @@ describe('JsonRpcEngineV2', () => { }); it('throws if a middleware throws, with multiple middleware', async () => { - const engine = new JsonRpcEngineV2({ + const engine = JsonRpcEngineV2.create({ middleware: [ jest.fn(({ next }) => next()), jest.fn(() => { @@ -222,7 +278,7 @@ describe('JsonRpcEngineV2', () => { }); it('throws if no middleware returns a result', async () => { - const engine = new JsonRpcEngineV2({ + const engine = JsonRpcEngineV2.create({ middleware: [jest.fn(({ next }) => next()), jest.fn()], }); const request = makeRequest(); @@ -235,13 +291,13 @@ describe('JsonRpcEngineV2', () => { }); it('throws if a middleware calls next() multiple times', async () => { - const engine = new JsonRpcEngineV2({ + const engine = JsonRpcEngineV2.create({ middleware: [ jest.fn(async ({ next }) => { await next(); await next(); }), - jest.fn(), + makeNullMiddleware(), ], }); const request = makeRequest(); @@ -256,26 +312,38 @@ describe('JsonRpcEngineV2', () => { describe('context', () => { it('passes the context to the middleware', async () => { - const middleware = jest.fn(({ context }) => { - expect(context).toBeInstanceOf(Map); - return null; - }); - const engine = new JsonRpcEngineV2({ - middleware: [middleware], + const engine = JsonRpcEngineV2.create< + JsonRpcMiddleware + >({ + middleware: [ + ({ context }) => { + expect(context).toBeInstanceOf(Map); + return null; + }, + ], }); await engine.handle(makeRequest()); }); it('propagates context changes to subsequent middleware', async () => { - const middleware1 = jest.fn(async ({ context, next }) => { + type Context = MiddlewareContext<{ foo: string }>; + const middleware1: JsonRpcMiddleware< + JsonRpcCall, + Json | void, + Context + > = async ({ context, next }) => { context.set('foo', 'bar'); return next(); - }); - const middleware2 = jest.fn(({ context }) => { + }; + const middleware2: JsonRpcMiddleware< + JsonRpcCall, + string | undefined, + Context + > = ({ context }) => { return context.get('foo') as string | undefined; - }); - const engine = new JsonRpcEngineV2({ + }; + const engine = JsonRpcEngineV2.create({ middleware: [middleware1, middleware2], }); @@ -285,10 +353,15 @@ describe('JsonRpcEngineV2', () => { }); it('accepts an initial context', async () => { - const initialContext = new MiddlewareContext(); + const initialContext = new MiddlewareContext>(); initialContext.set('foo', 'bar'); - const engine = new JsonRpcEngineV2({ - middleware: [({ context }) => context.assertGet('foo')], + const middleware: JsonRpcMiddleware< + JsonRpcRequest, + string, + MiddlewareContext> + > = ({ context }) => context.assertGet('foo'); + const engine = JsonRpcEngineV2.create({ + middleware: [middleware], }); const result = await engine.handle(makeRequest(), { @@ -298,12 +371,49 @@ describe('JsonRpcEngineV2', () => { expect(result).toBe('bar'); }); + it('accepts middleware with different context types', async () => { + const middleware1: JsonRpcMiddleware< + JsonRpcCall, + ResultConstraint, + MiddlewareContext<{ foo: string }> + > = ({ context, next }) => { + context.set('foo', 'bar'); + return next(); + }; + + const middleware2: JsonRpcMiddleware< + JsonRpcCall, + ResultConstraint + > = ({ next }) => next(); + + const middleware3: JsonRpcMiddleware< + JsonRpcCall, + ResultConstraint, + EmptyContext + > = ({ next }) => next(); + + const middleware4: JsonRpcMiddleware< + JsonRpcCall, + string, + MiddlewareContext<{ foo: string; bar: number }> + > = ({ context }) => context.assertGet('foo'); + + const engine = JsonRpcEngineV2.create({ + middleware: [middleware1, middleware2, middleware3, middleware4], + }); + + const result = await engine.handle(makeRequest()); + + expect(result).toBe('bar'); + }); + it('throws if a middleware attempts to modify properties of the context', async () => { - const engine = new JsonRpcEngineV2({ + const engine = JsonRpcEngineV2.create({ middleware: [ - jest.fn(({ context }) => { + ({ context }) => { + // @ts-expect-error - Destructive testing. context.set = () => undefined; - }), + }, ], }); @@ -315,9 +425,8 @@ describe('JsonRpcEngineV2', () => { describe('asynchrony', () => { it('handles asynchronous middleware', async () => { - const middleware = jest.fn(async () => null); - const engine = new JsonRpcEngineV2({ - middleware: [middleware], + const engine = JsonRpcEngineV2.create({ + middleware: [async () => null], }); const result = await engine.handle(makeRequest()); @@ -326,26 +435,28 @@ describe('JsonRpcEngineV2', () => { }); it('handles mixed synchronous and asynchronous middleware', async () => { - const middleware1: JsonRpcMiddleware> = - jest.fn(async ({ context, next }) => { - context.set('foo', [1]); - return next(); - }); - const middleware2: JsonRpcMiddleware> = - jest.fn(({ context, next }) => { - const nums = context.assertGet('foo'); - nums.push(2); - return next(); - }); - const middleware3: JsonRpcMiddleware< + type Middleware = JsonRpcMiddleware< JsonRpcRequest, - number[] - > = jest.fn(async ({ context }) => { - const nums = context.assertGet('foo'); - return [...nums, 3]; - }); - const engine = new JsonRpcEngineV2({ - middleware: [middleware1, middleware2, middleware3], + Json, + MiddlewareContext> + >; + + const engine = JsonRpcEngineV2.create({ + middleware: [ + async ({ context, next }) => { + context.set('foo', [1]); + return next(); + }, + ({ context, next }) => { + const nums = context.assertGet('foo'); + nums.push(2); + return next(); + }, + async ({ context }) => { + const nums = context.assertGet('foo'); + return [...nums, 3]; + }, + ], }); const result = await engine.handle(makeRequest()); @@ -378,7 +489,7 @@ describe('JsonRpcEngineV2', () => { observedMethod = request.method; return null; }); - const engine = new JsonRpcEngineV2({ + const engine = JsonRpcEngineV2.create({ middleware: [middleware1, middleware2, middleware3], }); const request = makeRequest({ params: [1] }); @@ -393,10 +504,10 @@ describe('JsonRpcEngineV2', () => { }); it('throws if directly modifying the request', async () => { - const engine = new JsonRpcEngineV2({ + const engine = JsonRpcEngineV2.create({ middleware: [ jest.fn(({ request }) => { - // @ts-expect-error Destructive testing. + // @ts-expect-error - Destructive testing. request.params = [2]; }) as JsonRpcMiddleware, ], @@ -410,7 +521,7 @@ describe('JsonRpcEngineV2', () => { }); it('throws if a middleware attempts to modify the request "id" property', async () => { - const engine = new JsonRpcEngineV2({ + const engine = JsonRpcEngineV2.create({ middleware: [ jest.fn(async ({ request, next }) => { return await next({ @@ -418,7 +529,7 @@ describe('JsonRpcEngineV2', () => { id: '2', }); }), - jest.fn(() => null), + makeNullMiddleware(), ], }); const request = makeRequest(); @@ -431,15 +542,16 @@ describe('JsonRpcEngineV2', () => { }); it('throws if a middleware attempts to modify the request "jsonrpc" property', async () => { - const engine = new JsonRpcEngineV2({ + const engine = JsonRpcEngineV2.create({ middleware: [ - jest.fn(async ({ request, next }) => { + async ({ request, next }) => { return await next({ ...request, + // @ts-expect-error - Destructive testing. jsonrpc: '3.0', }); - }), - jest.fn(() => null), + }, + makeNullMiddleware(), ], }); const request = makeRequest(); @@ -454,13 +566,15 @@ describe('JsonRpcEngineV2', () => { describe('result handling', () => { it('updates the result after next() is called', async () => { - const engine = new JsonRpcEngineV2({ + const engine = JsonRpcEngineV2.create< + JsonRpcMiddleware + >({ middleware: [ - jest.fn(async ({ next }) => { - const result = await next(); + async ({ next }) => { + const result = (await next()) as number; return result + 1; - }), - jest.fn(() => 1), + }, + () => 1, ], }); @@ -470,13 +584,13 @@ describe('JsonRpcEngineV2', () => { }); it('updates an undefined result with a new value', async () => { - const engine = new JsonRpcEngineV2({ + const engine = JsonRpcEngineV2.create({ middleware: [ - jest.fn(async ({ next }) => { + async ({ next }) => { await next(); return null; - }), - jest.fn(() => undefined), + }, + makeNotificationMiddleware(), ], }); @@ -486,12 +600,12 @@ describe('JsonRpcEngineV2', () => { }); it('returning undefined propagates previously defined result', async () => { - const engine = new JsonRpcEngineV2({ + const engine = JsonRpcEngineV2.create({ middleware: [ jest.fn(async ({ next }) => { await next(); }), - jest.fn(() => null), + makeNullMiddleware(), ], }); @@ -502,7 +616,7 @@ describe('JsonRpcEngineV2', () => { it('catches errors thrown by later middleware', async () => { let observedError: Error | undefined; - const engine = new JsonRpcEngineV2({ + const engine = JsonRpcEngineV2.create({ middleware: [ jest.fn(async ({ next }) => { try { @@ -526,21 +640,22 @@ describe('JsonRpcEngineV2', () => { it('handles returned results in reverse middleware order', async () => { const returnHandlerResults: number[] = []; - const middleware1 = jest.fn(async ({ next }) => { - await next(); - returnHandlerResults.push(1); - }); - const middleware2 = jest.fn(async ({ next }) => { - await next(); - returnHandlerResults.push(2); - }); - const middleware3 = jest.fn(async ({ next }) => { - await next(); - returnHandlerResults.push(3); - }); - const middleware4 = jest.fn(() => null); - const engine = new JsonRpcEngineV2({ - middleware: [middleware1, middleware2, middleware3, middleware4], + const engine = JsonRpcEngineV2.create({ + middleware: [ + async ({ next }) => { + await next(); + returnHandlerResults.push(1); + }, + async ({ next }) => { + await next(); + returnHandlerResults.push(2); + }, + async ({ next }) => { + await next(); + returnHandlerResults.push(3); + }, + makeNullMiddleware(), + ], }); await engine.handle(makeRequest()); @@ -549,14 +664,15 @@ describe('JsonRpcEngineV2', () => { }); it('throws if directly modifying the result', async () => { - const middleware1 = jest.fn(async ({ next }) => { - const result = await next(); - result.foo = 'baz'; - return result; - }); - const middleware2 = jest.fn(() => ({ foo: 'bar' })); - const engine = new JsonRpcEngineV2({ - middleware: [middleware1, middleware2], + const engine = JsonRpcEngineV2.create({ + middleware: [ + async ({ next }) => { + const result = (await next()) as { foo: string }; + result.foo = 'baz'; + return result; + }, + () => ({ foo: 'bar' }), + ], }); await expect(engine.handle(makeRequest())).rejects.toThrow( @@ -625,25 +741,33 @@ describe('JsonRpcEngineV2', () => { let inFlight = 0; let maxInFlight = 0; - const engine = new JsonRpcEngineV2({ - middleware: [ - async ({ context, next, request }) => { - // eslint-disable-next-line jest/no-conditional-in-test - context.set('id', context.get('id') ?? request.id); + type Context = MiddlewareContext<{ id: JsonRpcId }>; + const inflightMiddleware: JsonRpcMiddleware< + JsonRpcRequest, + Json, + Context + > = async ({ context, next, request }) => { + // eslint-disable-next-line jest/no-conditional-in-test + context.set('id', context.get('id') ?? request.id); - inFlight += 1; - maxInFlight = Math.max(maxInFlight, inFlight); - latch.increment(); + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + latch.increment(); - await gate; + await gate; - inFlight -= 1; - return next(); - }, - ({ context, request }) => { - return `result:${request.id}:${context.get('id') as JsonRpcId}`; - }, - ], + inFlight -= 1; + return next(); + }; + const resultMiddleware: JsonRpcMiddleware< + JsonRpcRequest, + string, + Context + > = ({ context, request }) => { + return `result:${request.id}:${context.assertGet('id')}`; + }; + const engine = JsonRpcEngineV2.create({ + middleware: [inflightMiddleware, resultMiddleware], }); // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -672,13 +796,14 @@ describe('JsonRpcEngineV2', () => { it('eagerly processes requests in parallel, i.e. without queueing them', async () => { const queue = makeArbitraryQueue(3); - const engine = new JsonRpcEngineV2({ - middleware: [ - async ({ request }) => { - await queue.enqueue(request.id); - return null; - }, - ], + const middleware: JsonRpcMiddleware< + JsonRpcRequest & { id: number } + > = async ({ request }) => { + await queue.enqueue(request.id); + return null; + }; + const engine = JsonRpcEngineV2.create({ + middleware: [middleware], }); const p0 = engine.handle(makeRequest({ id: 0 })); @@ -700,11 +825,10 @@ describe('JsonRpcEngineV2', () => { describe('composition', () => { describe('asMiddleware', () => { it('ends a request if it returns a value', async () => { - // TODO: We may have to do a lot of these casts? - const engine1 = new JsonRpcEngineV2({ - middleware: [() => null], + const engine1 = JsonRpcEngineV2.create({ + middleware: [makeNullMiddleware()], }); - const engine2 = new JsonRpcEngineV2({ + const engine2 = JsonRpcEngineV2.create({ middleware: [engine1.asMiddleware(), jest.fn(() => 'foo')], }); @@ -714,11 +838,11 @@ describe('JsonRpcEngineV2', () => { }); it('permits returning undefined if a later middleware ends the request', async () => { - const engine1 = new JsonRpcEngineV2({ - middleware: [() => undefined], + const engine1 = JsonRpcEngineV2.create({ + middleware: [makeNotificationMiddleware()], }); - const engine2 = new JsonRpcEngineV2({ - middleware: [engine1.asMiddleware(), () => null], + const engine2 = JsonRpcEngineV2.create({ + middleware: [engine1.asMiddleware(), makeNullMiddleware()], }); const result = await engine2.handle(makeRequest()); @@ -729,13 +853,13 @@ describe('JsonRpcEngineV2', () => { it('composes nested engines', async () => { const middleware1 = jest.fn(async ({ next }) => next()); const middleware2 = jest.fn(async ({ next }) => next()); - const engine1 = new JsonRpcEngineV2({ + const engine1 = JsonRpcEngineV2.create({ middleware: [middleware1], }); - const engine2 = new JsonRpcEngineV2({ + const engine2 = JsonRpcEngineV2.create({ middleware: [engine1.asMiddleware(), middleware2], }); - const engine3 = new JsonRpcEngineV2({ + const engine3 = JsonRpcEngineV2.create({ middleware: [engine2.asMiddleware(), () => null], }); @@ -747,7 +871,7 @@ describe('JsonRpcEngineV2', () => { }); it('propagates request mutation', async () => { - const engine1 = new JsonRpcEngineV2({ + const engine1 = JsonRpcEngineV2.create({ middleware: [ ({ request, next }) => { return next({ @@ -759,21 +883,19 @@ describe('JsonRpcEngineV2', () => { return next({ ...request, method: 'test_request_2', - // @ts-expect-error Will obviously work. - params: [request.params[0] * 2], + params: [(request.params as [number])[0] * 2], }); }, ], }); let observedMethod: string | undefined; - const engine2 = new JsonRpcEngineV2({ + const engine2 = JsonRpcEngineV2.create({ middleware: [ engine1.asMiddleware(), ({ request }) => { observedMethod = request.method; - // @ts-expect-error Will obviously work. - return request.params[0] * 2; + return (request.params as [number])[0] * 2; }, ], }); @@ -785,18 +907,17 @@ describe('JsonRpcEngineV2', () => { }); it('propagates context changes', async () => { - const engine1 = new JsonRpcEngineV2({ + const engine1 = JsonRpcEngineV2.create({ middleware: [ async ({ context, next }) => { - const nums = context.assertGet('foo'); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - nums[0]! *= 2; + const nums = context.assertGet('foo') as [number]; + nums[0] *= 2; return next(); }, ], }); - const engine2 = new JsonRpcEngineV2({ + const engine2 = JsonRpcEngineV2.create({ middleware: [ async ({ context, next }) => { context.set('foo', [2]); @@ -804,8 +925,8 @@ describe('JsonRpcEngineV2', () => { }, engine1.asMiddleware(), async ({ context }) => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return context.assertGet('foo')[0]! * 2; + const nums = context.assertGet('foo') as [number]; + return nums[0] * 2; }, ], }); @@ -817,7 +938,7 @@ describe('JsonRpcEngineV2', () => { it('observes results in expected order', async () => { const returnHandlerResults: string[] = []; - const engine1 = new JsonRpcEngineV2({ + const engine1 = JsonRpcEngineV2.create({ middleware: [ async ({ next }) => { await next(); @@ -830,7 +951,7 @@ describe('JsonRpcEngineV2', () => { ], }); - const engine2 = new JsonRpcEngineV2({ + const engine2 = JsonRpcEngineV2.create({ middleware: [ engine1.asMiddleware(), async ({ next }) => { @@ -862,17 +983,23 @@ describe('JsonRpcEngineV2', () => { it('composes nested engines', async () => { const earlierMiddleware = jest.fn(async ({ next }) => next()); - const engine1 = new JsonRpcEngineV2({ - middleware: [() => null], + const engine1Middleware: JsonRpcMiddleware = () => null; + const engine1 = JsonRpcEngineV2.create({ + middleware: [engine1Middleware], }); - const laterMiddleware = jest.fn(() => 'foo'); - const engine2 = new JsonRpcEngineV2({ + const engine1ProxyMiddleware: JsonRpcMiddleware< + JsonRpcRequest + > = async ({ request }) => { + return engine1.handle(request); + }; + const laterMiddleware: JsonRpcMiddleware = jest.fn( + () => 'foo', + ); + const engine2 = JsonRpcEngineV2.create({ middleware: [ earlierMiddleware, - async ({ request }) => { - return engine1.handle(request as JsonRpcRequest); - }, + engine1ProxyMiddleware, laterMiddleware, ], }); @@ -887,7 +1014,7 @@ describe('JsonRpcEngineV2', () => { it('does not propagate request mutation', async () => { // Unlike asMiddleware(), although the inner engine mutates request, // those mutations do not propagate when using engine.handle(). - const engine1 = new JsonRpcEngineV2({ + const engine1 = JsonRpcEngineV2.create({ middleware: [ ({ request, next }) => { return next({ @@ -899,26 +1026,28 @@ describe('JsonRpcEngineV2', () => { return next({ ...request, method: 'test_request_2', - // @ts-expect-error Will obviously work at runtime - params: [request.params[0] * 2], + params: [(request.params as [number])[0] * 2], }); }, - () => null, + makeNullMiddleware(), ], }); let observedMethod: string | undefined; - const engine2 = new JsonRpcEngineV2({ + const observedMethodMiddleware: JsonRpcMiddleware< + JsonRpcRequest, + number + > = ({ request }) => { + observedMethod = request.method; + return (request.params as [number])[0] * 2; + }; + const engine2 = JsonRpcEngineV2.create({ middleware: [ async ({ request, next, context }) => { - await engine1.handle(request as JsonRpcRequest, { context }); + await engine1.handle(request, { context }); return next(); }, - ({ request }) => { - observedMethod = request.method; - // @ts-expect-error Will obviously work at runtime - return request.params[0] * 2; - }, + observedMethodMiddleware, ], }); @@ -931,30 +1060,29 @@ describe('JsonRpcEngineV2', () => { }); it('propagates context changes', async () => { - const engine1 = new JsonRpcEngineV2({ + const engine1 = JsonRpcEngineV2.create({ middleware: [ async ({ context }) => { - const nums = context.assertGet('foo'); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - nums[0]! *= 2; + const nums = context.assertGet('foo') as [number]; + nums[0] *= 2; return null; }, ], }); - const engine2 = new JsonRpcEngineV2({ + const engine2 = JsonRpcEngineV2.create({ middleware: [ async ({ context, next }) => { context.set('foo', [2]); return next(); }, async ({ request, next, context }) => { - await engine1.handle(request as JsonRpcRequest, { context }); + await engine1.handle(request, { context }); return next(); }, async ({ context }) => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return context.assertGet('foo')[0]! * 2; + const nums = context.assertGet('foo') as [number]; + return nums[0] * 2; }, ], }); @@ -966,7 +1094,7 @@ describe('JsonRpcEngineV2', () => { it('observes results in expected order', async () => { const returnHandlerResults: string[] = []; - const engine1 = new JsonRpcEngineV2({ + const engine1 = JsonRpcEngineV2.create({ middleware: [ async ({ next }) => { await next(); @@ -976,11 +1104,11 @@ describe('JsonRpcEngineV2', () => { await next(); returnHandlerResults.push('1:b'); }, - () => null, + makeNullMiddleware(), ], }); - const engine2 = new JsonRpcEngineV2({ + const engine2 = JsonRpcEngineV2.create({ middleware: [ async ({ request, next, context }) => { await engine1.handle(request as JsonRpcRequest, { context }); @@ -994,7 +1122,7 @@ describe('JsonRpcEngineV2', () => { await next(); returnHandlerResults.push('2:b'); }, - () => null, + makeNullMiddleware(), ], }); @@ -1011,7 +1139,7 @@ describe('JsonRpcEngineV2', () => { }); it('throws if the inner engine throws', async () => { - const engine1 = new JsonRpcEngineV2({ + const engine1 = JsonRpcEngineV2.create({ middleware: [ () => { throw new Error('test'); @@ -1019,7 +1147,7 @@ describe('JsonRpcEngineV2', () => { ], }); - const engine2 = new JsonRpcEngineV2({ + const engine2 = JsonRpcEngineV2.create({ middleware: [ async ({ request }) => { await engine1.handle(request as JsonRpcRequest); @@ -1036,14 +1164,14 @@ describe('JsonRpcEngineV2', () => { describe('request- and notification-only engines', () => { it('constructs a request-only engine', async () => { - const engine = new JsonRpcEngineV2({ - middleware: [() => null], + const engine = JsonRpcEngineV2.create({ + middleware: [makeRequestMiddleware()], }); expect(await engine.handle(makeRequest())).toBeNull(); - // @ts-expect-error Valid at runtime, but should cause a type error + // @ts-expect-error - Valid at runtime, but should cause a type error expect(await engine.handle(makeRequest() as JsonRpcCall)).toBeNull(); - // @ts-expect-error Invalid at runtime and should cause a type error + // @ts-expect-error - Invalid at runtime and should cause a type error await expect(engine.handle(makeNotification())).rejects.toThrow( new JsonRpcEngineError( `Result returned for notification: ${stringify(makeNotification())}`, @@ -1052,13 +1180,13 @@ describe('JsonRpcEngineV2', () => { }); it('constructs a notification-only engine', async () => { - const engine = new JsonRpcEngineV2({ - middleware: [() => undefined], + const engine = JsonRpcEngineV2.create({ + middleware: [makeNotificationMiddleware()], }); expect(await engine.handle(makeNotification())).toBeUndefined(); await expect( - // @ts-expect-error Invalid at runtime and should cause a type error + // @ts-expect-error - Invalid at runtime and should cause a type error engine.handle({ id: '1', jsonrpc, method: 'test_request' }), ).rejects.toThrow( new JsonRpcEngineError( @@ -1066,7 +1194,7 @@ describe('JsonRpcEngineV2', () => { ), ); await expect( - // @ts-expect-error Invalid at runtime and should cause a type error + // @ts-expect-error - Invalid at runtime and should cause a type error engine.handle(makeRequest() as JsonRpcRequest), ).rejects.toThrow( new JsonRpcEngineError( @@ -1076,11 +1204,12 @@ describe('JsonRpcEngineV2', () => { }); it('constructs a mixed engine', async () => { - const engine = new JsonRpcEngineV2({ - middleware: [ - // eslint-disable-next-line jest/no-conditional-in-test - ({ request }) => (isRequest(request) ? null : undefined), - ], + const mixedMiddleware: JsonRpcMiddleware = ({ request }) => { + // eslint-disable-next-line jest/no-conditional-in-test + return isRequest(request) ? null : undefined; + }; + const engine = JsonRpcEngineV2.create({ + middleware: [mixedMiddleware], }); expect(await engine.handle(makeRequest())).toBeNull(); @@ -1089,15 +1218,15 @@ describe('JsonRpcEngineV2', () => { }); it('composes a pipeline of request- and notification-only engines', async () => { - const requestEngine = new JsonRpcEngineV2({ - middleware: [() => null], + const requestEngine = JsonRpcEngineV2.create({ + middleware: [makeRequestMiddleware()], }); - const notificationEngine = new JsonRpcEngineV2({ - middleware: [() => undefined], + const notificationEngine = JsonRpcEngineV2.create({ + middleware: [makeNotificationMiddleware()], }); - const orchestratorEngine = new JsonRpcEngineV2({ + const orchestratorEngine = JsonRpcEngineV2.create({ middleware: [ ({ request, context }) => // eslint-disable-next-line jest/no-conditional-in-test @@ -1123,7 +1252,7 @@ describe('JsonRpcEngineV2', () => { const middleware = { destroy: jest.fn(), }; - const engine = new JsonRpcEngineV2({ + const engine = JsonRpcEngineV2.create({ middleware: [middleware as unknown as JsonRpcMiddleware], }); @@ -1137,7 +1266,7 @@ describe('JsonRpcEngineV2', () => { destroy: jest.fn(), }; - const engine = new JsonRpcEngineV2({ + const engine = JsonRpcEngineV2.create({ middleware: [middleware as unknown as JsonRpcMiddleware], }); @@ -1148,8 +1277,8 @@ describe('JsonRpcEngineV2', () => { }); it('causes handle() to throw after destroying the engine', async () => { - const engine = new JsonRpcEngineV2({ - middleware: [() => null], + const engine = JsonRpcEngineV2.create({ + middleware: [makeNullMiddleware()], }); await engine.destroy(); @@ -1160,8 +1289,8 @@ describe('JsonRpcEngineV2', () => { }); it('causes asMiddleware() to throw after destroying the engine', async () => { - const engine = new JsonRpcEngineV2({ - middleware: [() => null], + const engine = JsonRpcEngineV2.create({ + middleware: [makeNullMiddleware()], }); await engine.destroy(); @@ -1176,7 +1305,7 @@ describe('JsonRpcEngineV2', () => { throw new Error('test'); }), }; - const engine = new JsonRpcEngineV2({ + const engine = JsonRpcEngineV2.create({ middleware: [middleware as unknown as JsonRpcMiddleware], }); @@ -1192,11 +1321,11 @@ describe('JsonRpcEngineV2', () => { const middleware2 = { destroy: jest.fn(), }; - const engine = new JsonRpcEngineV2({ + const engine = JsonRpcEngineV2.create({ middleware: [ middleware1, middleware2, - ] as unknown as NonEmptyArray, + ] as unknown as JsonRpcMiddleware[], }); await expect(engine.destroy()).rejects.toThrow(new Error('test')); diff --git a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts index dd94aaf21a..4f55df4a38 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcEngineV2.ts @@ -7,6 +7,7 @@ import { } from '@metamask/utils'; import deepFreeze from 'deep-freeze-strict'; +import type { ContextConstraint, MergeContexts } from './MiddlewareContext'; import { MiddlewareContext } from './MiddlewareContext'; import { isNotification, @@ -37,17 +38,21 @@ export type Next = ( request?: Readonly, ) => Promise> | undefined>; -export type MiddlewareParams = { +export type MiddlewareParams< + Request extends JsonRpcCall, + Context extends MiddlewareContext, +> = { request: Readonly; - context: MiddlewareContext; + context: Context; next: Next; }; export type JsonRpcMiddleware< Request extends JsonRpcCall = JsonRpcCall, Result extends ResultConstraint = ResultConstraint, + Context extends ContextConstraint = MiddlewareContext, > = ( - params: MiddlewareParams, + params: MiddlewareParams, ) => Readonly | undefined | Promise | undefined>; type RequestState = { @@ -55,14 +60,52 @@ type RequestState = { result: Readonly> | undefined; }; -type Options = { - middleware: NonEmptyArray>; +type HandleOptions = { + context?: Context; }; -type HandleOptions = { - context?: MiddlewareContext; +type ConstructorOptions< + Request extends JsonRpcCall, + Context extends MiddlewareContext, +> = { + middleware: NonEmptyArray< + JsonRpcMiddleware, Context> + >; }; +type RequestOf = + Middleware extends JsonRpcMiddleware< + infer Request, + ResultConstraint, + // Non-polluting `any` constraint. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any + > + ? Request + : never; + +type ContextOf = + // Non-polluting `any` constraint. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Middleware extends JsonRpcMiddleware, infer C> + ? C + : never; + +type MergedContextOf< + // Non-polluting `any` constraint. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Middleware extends JsonRpcMiddleware, +> = MergeContexts>; + +const INVALID_ENGINE = Symbol('Invalid engine'); + +/** + * An internal type for invalid engines that explains why the engine is invalid. + * + * @template Message - The message explaining why the engine is invalid. + */ +type InvalidEngine = { [INVALID_ENGINE]: Message }; + /** * A JSON-RPC request and response processor. * @@ -86,7 +129,7 @@ type HandleOptions = { * * @example * ```ts - * const engine = new JsonRpcEngineV2({ + * const engine = JsonRpcEngineV2.create({ * middleware, * }); * @@ -98,15 +141,65 @@ type HandleOptions = { * } * ``` */ -export class JsonRpcEngineV2 { - #middleware: Readonly>>; +export class JsonRpcEngineV2< + Request extends JsonRpcCall = JsonRpcCall, + Context extends ContextConstraint = MiddlewareContext, +> { + #middleware: Readonly< + NonEmptyArray< + JsonRpcMiddleware, Context> + > + >; #isDestroyed = false; - constructor({ middleware }: Options) { + // See .create() for why this is private. + private constructor({ middleware }: ConstructorOptions) { this.#middleware = [...middleware]; } + // We use a static factory method in order to construct a supertype of all middleware contexts, + // which enables us to instantiate an engine despite different middleware expecting different + // context types. + /** + * Create a new JSON-RPC engine. + * + * @throws If the middleware array is empty. + * @param options - The options for the engine. + * @param options.middleware - The middleware to use. + * @returns The JSON-RPC engine. + */ + static create< + Middleware extends JsonRpcMiddleware< + // Non-polluting `any` constraint. + /* eslint-disable @typescript-eslint/no-explicit-any */ + any, + ResultConstraint, + any + /* eslint-enable @typescript-eslint/no-explicit-any */ + > = JsonRpcMiddleware, + >({ middleware }: { middleware: Middleware[] }) { + // We can't use NonEmptyArray for the params because it ruins type inference. + if (middleware.length === 0) { + throw new JsonRpcEngineError('Middleware array cannot be empty'); + } + + type MergedContext = MergedContextOf; + type InputRequest = RequestOf; + const mw = middleware as unknown as NonEmptyArray< + JsonRpcMiddleware< + InputRequest, + ResultConstraint, + MergedContext + > + >; + return new JsonRpcEngineV2({ + middleware: mw, + }) as MergedContext extends never + ? InvalidEngine<'Some middleware have incompatible context types'> + : JsonRpcEngineV2; + } + /** * Handle a JSON-RPC request. * @@ -119,7 +212,7 @@ export class JsonRpcEngineV2 { request: Extract extends never ? never : Extract, - options?: HandleOptions, + options?: HandleOptions, ): Promise< Extract extends never ? never @@ -137,7 +230,7 @@ export class JsonRpcEngineV2 { notification: Extract extends never ? never : WithoutId>, - options?: HandleOptions, + options?: HandleOptions, ): Promise< Extract extends never ? never @@ -155,12 +248,12 @@ export class JsonRpcEngineV2 { */ async handle( call: MixedParam, - options?: HandleOptions, + options?: HandleOptions, ): Promise | void>; async handle( request: Request, - { context }: HandleOptions = {}, + { context }: HandleOptions = {}, ): Promise> | void> { const isReq = isRequest(request); const { result } = await this.#handle(request, context); @@ -183,7 +276,7 @@ export class JsonRpcEngineV2 { */ async #handle( originalRequest: Request, - context: MiddlewareContext = new MiddlewareContext(), + context: Context = new MiddlewareContext() as Context, ): Promise> { this.#assertIsNotDestroyed(); @@ -220,9 +313,11 @@ export class JsonRpcEngineV2 { * @returns The `next()` function factory. */ #makeNextFactory( - middlewareIterator: Iterator>, + middlewareIterator: Iterator< + JsonRpcMiddleware, Context> + >, state: RequestState, - context: MiddlewareContext, + context: Context, ): () => Next { const makeNext = (): Next => { let wasCalled = false; @@ -264,7 +359,9 @@ export class JsonRpcEngineV2 { return makeNext; } - #makeMiddlewareIterator(): Iterator> { + #makeMiddlewareIterator(): Iterator< + JsonRpcMiddleware, Context> + > { return this.#middleware[Symbol.iterator](); } @@ -325,7 +422,11 @@ export class JsonRpcEngineV2 { * * @returns The JSON-RPC middleware. */ - asMiddleware(): JsonRpcMiddleware { + asMiddleware(): JsonRpcMiddleware< + Request, + ResultConstraint, + Context + > { this.#assertIsNotDestroyed(); return async ({ request, context, next }) => { diff --git a/packages/json-rpc-engine/src/v2/JsonRpcServer.test.ts b/packages/json-rpc-engine/src/v2/JsonRpcServer.test.ts index 5b6ebac47b..88d90d2e7d 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcServer.test.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcServer.test.ts @@ -1,5 +1,6 @@ import { rpcErrors } from '@metamask/rpc-errors'; +import type { JsonRpcMiddleware } from './JsonRpcEngineV2'; import { JsonRpcEngineV2 } from './JsonRpcEngineV2'; import { JsonRpcServer } from './JsonRpcServer'; import { isRequest } from './utils'; @@ -7,7 +8,7 @@ import { isRequest } from './utils'; const jsonrpc = '2.0' as const; const makeEngine = () => { - return new JsonRpcEngineV2({ + return JsonRpcEngineV2.create({ middleware: [ ({ request }) => { if (request.method !== 'hello') { diff --git a/packages/json-rpc-engine/src/v2/JsonRpcServer.ts b/packages/json-rpc-engine/src/v2/JsonRpcServer.ts index 66a8475d4a..bd10a67bf4 100644 --- a/packages/json-rpc-engine/src/v2/JsonRpcServer.ts +++ b/packages/json-rpc-engine/src/v2/JsonRpcServer.ts @@ -74,7 +74,7 @@ export class JsonRpcServer { // @ts-expect-error - hasProperty fails to narrow the type. this.#engine = options.engine; } else { - this.#engine = new JsonRpcEngineV2({ middleware: options.middleware }); + this.#engine = JsonRpcEngineV2.create({ middleware: options.middleware }); } } @@ -201,7 +201,8 @@ function isMinimalRequest(rawRequest: unknown): rawRequest is MinimalRequest { } /** - * Check if a request has valid params. + * Check if a request has valid params, i.e. an array or object. + * The contents of the params are not inspected. * * @param rawRequest - The request to check. * @returns `true` if the request has valid params, `false` otherwise. diff --git a/packages/json-rpc-engine/src/v2/MiddlewareContext.test.ts b/packages/json-rpc-engine/src/v2/MiddlewareContext.test.ts index afec7d8112..8d755ddb48 100644 --- a/packages/json-rpc-engine/src/v2/MiddlewareContext.test.ts +++ b/packages/json-rpc-engine/src/v2/MiddlewareContext.test.ts @@ -1,14 +1,9 @@ import { MiddlewareContext } from './MiddlewareContext'; describe('MiddlewareContext', () => { - it('is a map', () => { - const context = new MiddlewareContext(); - expect(context).toBeInstanceOf(Map); - }); - it('can be constructed with entries', () => { const symbol = Symbol('test'); - const context = new MiddlewareContext([ + const context = new MiddlewareContext<{ test: string; [symbol]: string }>([ ['test', 'value'], [symbol, 'value'], ]); @@ -21,28 +16,48 @@ describe('MiddlewareContext', () => { expect(Object.isFrozen(context)).toBe(true); }); + it('type errors and returns undefined when getting unknown keys', () => { + const context = new MiddlewareContext<{ test: string }>(); + // @ts-expect-error - foo is not a valid key + expect(context.get('foo')).toBeUndefined(); + }); + + it('type errors and throws when assertGet:ing unknown keys', () => { + const context = new MiddlewareContext<{ test: string }>(); + // @ts-expect-error - foo is not a valid key + expect(() => context.assertGet('foo')).toThrow( + `Context key "foo" not found`, + ); + }); + + it('type errors when setting unknown keys', () => { + const context = new MiddlewareContext<{ test: string }>(); + // @ts-expect-error - foo is not a valid key + expect(context.set('foo', 'value')).toBe(context); + }); + it('assertGet throws if the key is not found', () => { - const context = new MiddlewareContext(); + const context = new MiddlewareContext<{ test: string }>(); expect(() => context.assertGet('test')).toThrow( `Context key "test" not found`, ); }); it('assertGet returns the value if the key is found (string)', () => { - const context = new MiddlewareContext(); + const context = new MiddlewareContext<{ test: string }>(); context.set('test', 'value'); expect(context.assertGet('test')).toBe('value'); }); it('assertGet returns the value if the key is found (symbol)', () => { - const context = new MiddlewareContext(); const symbol = Symbol('test'); + const context = new MiddlewareContext<{ [symbol]: string }>(); context.set(symbol, 'value'); expect(context.assertGet(symbol)).toBe('value'); }); it('throws if setting an already set key', () => { - const context = new MiddlewareContext(); + const context = new MiddlewareContext<{ test: string }>(); context.set('test', 'value'); expect(() => context.set('test', 'value')).toThrow( `MiddlewareContext key "test" already exists`, diff --git a/packages/json-rpc-engine/src/v2/MiddlewareContext.ts b/packages/json-rpc-engine/src/v2/MiddlewareContext.ts index 5bfa1ac198..8eb291dcc7 100644 --- a/packages/json-rpc-engine/src/v2/MiddlewareContext.ts +++ b/packages/json-rpc-engine/src/v2/MiddlewareContext.ts @@ -1,29 +1,134 @@ +import type { UnionToIntersection } from './utils'; + /** - * A append-only context object for middleware. Its interface is frozen. + * An context object for middleware that attempts to protect against accidental + * modifications. Its interface is frozen. + * + * Map keys may not be directly overridden with {@link set}. Instead, use + * {@link delete} to remove a key and then {@link set} to add a new value. * - * Map keys may still be deleted. The append-only behavior is mostly intended - * to prevent accidental naming collisions. + * The override protections are circumvented when using e.g. `Reflect.set`, so + * don't do that. * - * The append-only behavior is overriden when using e.g. `Reflect.set`, - * so don't do that. + * @template KeyValues - The type of the keys and values in the context. + * @example + * // By default, the context permits any PropertyKey as a key. + * const context = new MiddlewareContext(); + * context.set('foo', 'bar'); + * context.get('foo'); // 'bar' + * context.get('fizz'); // undefined + * @example + * // By specifying an object type, the context permits only the keys of the object. + * type Context = MiddlewareContext<{ foo: string }>; + * const context = new Context([['foo', 'bar']]); + * context.get('foo'); // 'bar' + * context.get('fizz'); // Type error */ -export class MiddlewareContext extends Map { - constructor(entries?: Iterable) { +export class MiddlewareContext< + KeyValues extends Record = Record, +> extends Map { + constructor( + entries?: Iterable, + ) { super(entries); Object.freeze(this); } - assertGet(key: string | symbol): Value { - if (!this.has(key)) { + get(key: K): KeyValues[K] | undefined { + return super.get(key) as KeyValues[K] | undefined; + } + + /** + * Get a value from the context. Throws if the key is not found. + * + * @param key - The key to get the value for. + * @returns The value. + */ + assertGet(key: K): KeyValues[K] { + if (!super.has(key)) { throw new Error(`Context key "${String(key)}" not found`); } - return this.get(key) as Value; + return super.get(key) as KeyValues[K]; } - set(key: string | symbol, value: Value): this { - if (this.has(key)) { + /** + * Set a value in the context. Throws if the key already exists. + * {@link delete} an existing key before setting it to a new value. + * + * @throws If the key already exists. + * @param key - The key to set the value for. + * @param value - The value to set. + * @returns The context. + */ + set(key: K, value: KeyValues[K]): this { + if (super.has(key)) { throw new Error(`MiddlewareContext key "${String(key)}" already exists`); } - return super.set(key, value) as this; + super.set(key, value); + return this; } } + +/** + * Infer the KeyValues type from a {@link MiddlewareContext}. + */ +type InferKeyValues = T extends MiddlewareContext ? U : never; + +/** + * Simplifies an object type by "merging" its properties. + * + * - Expands intersections into a single object type. + * - Forces mapped/conditional results to resolve into a readable shape. + * - No runtime effect; purely a type-level normalization. + * + * @example + * type A = { a: string } & { b: number }; + * type B = Simplify; // { a: string; b: number } + */ +type Simplify = T extends infer O ? { [K in keyof O]: O[K] } : never; + +/** + * Rejects record types that contain any `never`-valued property. + * + * If any property of `T` resolves to `never`, the result is `never`; otherwise it returns `T` unchanged. + * Useful as a guard to ensure computed/merged record types didn't collapse any fields to `never`. + * + * @example + * type A = ExcludeNever<{ a: string; b: never }>; // never + * type B = ExcludeNever<{ a: string; b: number }>; // { a: string; b: number } + */ +type ExcludeNever> = { + [K in keyof T]-?: [T[K]] extends [never] ? K : never; +}[keyof T] extends never + ? T + : never; + +/** + * Merge a union of {@link MiddlewareContext}s into a single {@link MiddlewareContext} + * supertype. + * + * @param Contexts - The union of {@link MiddlewareContext}s to merge. + * @returns The merged {@link MiddlewareContext} supertype. + * @example + * type A = MiddlewareContext<{ a: string }> | MiddlewareContext<{ b: number }>; + * type B = MergeContexts; // MiddlewareContext<{ a: string, b: number }> + */ +export type MergeContexts = + ExcludeNever< + Simplify>> + > extends never + ? never + : MiddlewareContext< + ExcludeNever>>> + >; + +// Non-polluting `any` constraint. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type ContextConstraint = MiddlewareContext; + +/** + * The empty context type, i.e. `MiddlewareContext<{}>`. + */ +// The empty object type is literally an empty object in this context. +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export type EmptyContext = MiddlewareContext<{}>; diff --git a/packages/json-rpc-engine/src/v2/asLegacyMiddleware.test.ts b/packages/json-rpc-engine/src/v2/asLegacyMiddleware.test.ts index b83652976c..b7968898f6 100644 --- a/packages/json-rpc-engine/src/v2/asLegacyMiddleware.test.ts +++ b/packages/json-rpc-engine/src/v2/asLegacyMiddleware.test.ts @@ -5,23 +5,27 @@ import type { } from '@metamask/utils'; import { asLegacyMiddleware } from './asLegacyMiddleware'; -import type { JsonRpcMiddleware } from './JsonRpcEngineV2'; +import type { JsonRpcMiddleware, ResultConstraint } from './JsonRpcEngineV2'; import { JsonRpcEngineV2 } from './JsonRpcEngineV2'; -import { getExtraneousKeys, makeRequest } from '../../tests/utils'; +import { + getExtraneousKeys, + makeRequest, + makeRequestMiddleware, +} from '../../tests/utils'; import { JsonRpcEngine } from '../JsonRpcEngine'; describe('asLegacyMiddleware', () => { it('converts a v2 engine to a legacy middleware', () => { - const engine = new JsonRpcEngineV2({ - middleware: [() => null], + const engine = JsonRpcEngineV2.create({ + middleware: [makeRequestMiddleware()], }); const middleware = asLegacyMiddleware(engine); expect(typeof middleware).toBe('function'); }); it('forwards a result to the legacy engine', async () => { - const v2Engine = new JsonRpcEngineV2({ - middleware: [() => null], + const v2Engine = JsonRpcEngineV2.create({ + middleware: [makeRequestMiddleware()], }); const legacyEngine = new JsonRpcEngine(); @@ -35,8 +39,9 @@ describe('asLegacyMiddleware', () => { }); it('forwarded results are not frozen', async () => { - const v2Engine = new JsonRpcEngineV2({ - middleware: [() => []], + const v2Middleware: JsonRpcMiddleware = () => []; + const v2Engine = JsonRpcEngineV2.create({ + middleware: [v2Middleware], }); const legacyEngine = new JsonRpcEngine(); @@ -51,12 +56,11 @@ describe('asLegacyMiddleware', () => { }); it('forwards an error to the legacy engine', async () => { - const v2Engine = new JsonRpcEngineV2({ - middleware: [ - () => { - throw new Error('test'); - }, - ], + const v2Middleware: JsonRpcMiddleware = () => { + throw new Error('test'); + }; + const v2Engine = JsonRpcEngineV2.create({ + middleware: [v2Middleware], }); const legacyEngine = new JsonRpcEngine(); @@ -79,8 +83,10 @@ describe('asLegacyMiddleware', () => { }); it('allows the legacy engine to continue when not ending the request', async () => { - const v2Middleware = jest.fn(({ next }) => next()); - const v2Engine = new JsonRpcEngineV2({ + const v2Middleware: JsonRpcMiddleware = jest.fn( + ({ next }) => next(), + ); + const v2Engine = JsonRpcEngineV2.create({ middleware: [v2Middleware], }); @@ -99,8 +105,10 @@ describe('asLegacyMiddleware', () => { }); it('allows the legacy engine to continue when not ending the request (passing through the original request)', async () => { - const v2Middleware = jest.fn(({ request, next }) => next(request)); - const v2Engine = new JsonRpcEngineV2({ + const v2Middleware: JsonRpcMiddleware = jest.fn( + ({ request, next }) => next(request), + ); + const v2Engine = JsonRpcEngineV2.create({ middleware: [v2Middleware], }); @@ -119,7 +127,7 @@ describe('asLegacyMiddleware', () => { }); it('propagates request modifications to the legacy engine', async () => { - const v2Engine = new JsonRpcEngineV2({ + const v2Engine = JsonRpcEngineV2.create>({ middleware: [ ({ request, next }) => next({ ...request, method: 'test_request_2' }), ], @@ -147,15 +155,18 @@ describe('asLegacyMiddleware', () => { const observedContextValues: number[] = []; const v2Middleware = jest.fn((({ context, next }) => { - observedContextValues.push(context.assertGet('value')); + observedContextValues.push(context.assertGet('value') as number); expect(Array.from(context.keys())).toStrictEqual(['value']); context.set('newValue', 2); return next(); - }) satisfies JsonRpcMiddleware); + }) satisfies JsonRpcMiddleware< + JsonRpcRequest, + ResultConstraint + >); - const v2Engine = new JsonRpcEngineV2({ + const v2Engine = JsonRpcEngineV2.create({ middleware: [v2Middleware], }); diff --git a/packages/json-rpc-engine/src/v2/compatibility-utils.test.ts b/packages/json-rpc-engine/src/v2/compatibility-utils.test.ts index d8c477ae60..9e75753daa 100644 --- a/packages/json-rpc-engine/src/v2/compatibility-utils.test.ts +++ b/packages/json-rpc-engine/src/v2/compatibility-utils.test.ts @@ -123,7 +123,7 @@ describe('compatibility-utils', () => { params: undefined, }; - // @ts-expect-error - Intentional abuse + // @ts-expect-error - Destructive testing const request = fromLegacyRequest(legacyRequest); expect(request).toStrictEqual({ @@ -140,7 +140,7 @@ describe('compatibility-utils', () => { id: 42, }; - // @ts-expect-error - Intentional abuse + // @ts-expect-error - Destructive testing const request = fromLegacyRequest(legacyRequest); expect(request).toStrictEqual({ @@ -159,7 +159,7 @@ describe('compatibility-utils', () => { id: 42, }; - // @ts-expect-error - Intentional abuse + // @ts-expect-error - Destructive testing const request = fromLegacyRequest(legacyRequest); expect(request).toStrictEqual({ @@ -265,7 +265,7 @@ describe('compatibility-utils', () => { params: [1], id: 42, }; - const context = new MiddlewareContext(); + const context = new MiddlewareContext>(); context.set('extraProp', 'value'); context.set('anotherProp', { nested: true }); @@ -282,16 +282,18 @@ describe('compatibility-utils', () => { }); it('does not copy non-string properties from context to request', () => { + const symbol = Symbol('anotherProp'); + const context = new MiddlewareContext(); + context.set('extraProp', 'value'); + context.set(symbol, { nested: true }); + context.set(42, 'value'); + const request = { jsonrpc, method: 'test_method', params: [1], id: 42, }; - const context = new MiddlewareContext(); - context.set('extraProp', 'value'); - context.set(Symbol('anotherProp'), { nested: true }); - propagateToRequest(request, context); expect(request).toStrictEqual({ @@ -301,6 +303,7 @@ describe('compatibility-utils', () => { id: 42, extraProp: 'value', }); + expect(symbol in request).toBe(false); }); it('excludes JSON-RPC properties from propagation', () => { @@ -310,7 +313,7 @@ describe('compatibility-utils', () => { params: [1], id: 42, }; - const context = new MiddlewareContext(); + const context = new MiddlewareContext>(); context.set('jsonrpc', '3.0'); context.set('method', 'other_method'); context.set('params', [2]); @@ -336,7 +339,7 @@ describe('compatibility-utils', () => { id: 42, existingKey: 'oldValue', }; - const context = new MiddlewareContext(); + const context = new MiddlewareContext>(); context.set('existingKey', 'newValue'); propagateToRequest(request, context); diff --git a/packages/json-rpc-engine/src/v2/compatibility-utils.ts b/packages/json-rpc-engine/src/v2/compatibility-utils.ts index d4eebf9ccc..03257b6a2e 100644 --- a/packages/json-rpc-engine/src/v2/compatibility-utils.ts +++ b/packages/json-rpc-engine/src/v2/compatibility-utils.ts @@ -87,7 +87,7 @@ export function makeContext>( */ export function propagateToContext( req: Record, - context: MiddlewareContext, + context: MiddlewareContext>, ) { Object.keys(req) .filter( diff --git a/packages/json-rpc-engine/src/v2/index.ts b/packages/json-rpc-engine/src/v2/index.ts index e9298e2ae6..eb63fa05b7 100644 --- a/packages/json-rpc-engine/src/v2/index.ts +++ b/packages/json-rpc-engine/src/v2/index.ts @@ -2,6 +2,12 @@ export { asLegacyMiddleware } from './asLegacyMiddleware'; export { getUniqueId } from '../getUniqueId'; export * from './JsonRpcEngineV2'; export { JsonRpcServer } from './JsonRpcServer'; -export type { MiddlewareContext } from './MiddlewareContext'; +export type { MiddlewareContext, EmptyContext } from './MiddlewareContext'; export { isNotification, isRequest, JsonRpcEngineError } from './utils'; -export type { JsonRpcCall, JsonRpcNotification, JsonRpcRequest } from './utils'; +export type { + Json, + JsonRpcCall, + JsonRpcNotification, + JsonRpcParams, + JsonRpcRequest, +} from './utils'; diff --git a/packages/json-rpc-engine/src/v2/utils.test.ts b/packages/json-rpc-engine/src/v2/utils.test.ts index 7dcc5185eb..f7fde4f0e0 100644 --- a/packages/json-rpc-engine/src/v2/utils.test.ts +++ b/packages/json-rpc-engine/src/v2/utils.test.ts @@ -27,7 +27,7 @@ describe('utils', () => { }, false, ], - ])('should return $expected for $request', (request, expected) => { + ])('returns $expected for $request', (request, expected) => { expect(isRequest(request)).toBe(expected); }); }); @@ -39,13 +39,13 @@ describe('utils', () => { { id: 1, jsonrpc, method: 'eth_getBlockByNumber', params: ['latest'] }, false, ], - ])('should return $expected for $request', (request, expected) => { + ])('returns $expected for $request', (request, expected) => { expect(isNotification(request)).toBe(expected); }); }); describe('stringify', () => { - it('should stringify a JSON object', () => { + it('stringifies a JSON object', () => { expect(stringify({ foo: 'bar' })).toMatchInlineSnapshot(` "{ \\"foo\\": \\"bar\\" @@ -55,11 +55,17 @@ describe('utils', () => { }); describe('JsonRpcEngineError', () => { - it('should create an error with the correct name', () => { + it('creates an error with the correct name', () => { const error = new JsonRpcEngineError('test'); expect(error).toBeInstanceOf(Error); expect(error.name).toBe('JsonRpcEngineError'); expect(error.message).toBe('test'); }); + + it('isInstance checks if a value is a JsonRpcEngineError instance', () => { + const error = new JsonRpcEngineError('test'); + expect(JsonRpcEngineError.isInstance(error)).toBe(true); + expect(JsonRpcEngineError.isInstance(new Error('test'))).toBe(false); + }); }); }); diff --git a/packages/json-rpc-engine/src/v2/utils.ts b/packages/json-rpc-engine/src/v2/utils.ts index abe761098c..345c9907b1 100644 --- a/packages/json-rpc-engine/src/v2/utils.ts +++ b/packages/json-rpc-engine/src/v2/utils.ts @@ -1,15 +1,16 @@ import { hasProperty, - type JsonRpcNotification as BaseJsonRpcNotification, + type JsonRpcNotification, type JsonRpcParams, - type JsonRpcRequest as BaseJsonRpcRequest, + type JsonRpcRequest, } from '@metamask/utils'; -export type JsonRpcNotification = - BaseJsonRpcNotification; - -export type JsonRpcRequest = - BaseJsonRpcRequest; +export type { + Json, + JsonRpcParams, + JsonRpcRequest, + JsonRpcNotification, +} from '@metamask/utils'; export type JsonRpcCall = | JsonRpcNotification @@ -23,6 +24,20 @@ export const isNotification = ( msg: JsonRpcCall, ): msg is JsonRpcNotification => !isRequest(msg); +/** + * An unholy incantation that converts a union of object types into an + * intersection of object types. + * + * @example + * type A = { a: string } | { b: number }; + * type B = UnionToIntersection; // { a: string } & { b: number } + */ +export type UnionToIntersection = ( + U extends never ? never : (k: U) => void +) extends (k: infer I) => void + ? I + : never; + /** * JSON-stringifies a value. * @@ -33,9 +48,29 @@ export function stringify(value: unknown): string { return JSON.stringify(value, null, 2); } +const JsonRpcEngineErrorSymbol = Symbol.for('JsonRpcEngineError'); + export class JsonRpcEngineError extends Error { + private readonly [JsonRpcEngineErrorSymbol] = true; + constructor(message: string) { super(message); this.name = 'JsonRpcEngineError'; } + + /** + * Check if a value is a {@link JsonRpcEngineError} instance. + * Works across different package versions in the same realm. + * + * @param value - The value to check. + * @returns Whether the value is a {@link JsonRpcEngineError} instance. + */ + static isInstance( + value: Value, + ): value is Value & JsonRpcEngineError { + return ( + hasProperty(value, JsonRpcEngineErrorSymbol) && + value[JsonRpcEngineErrorSymbol] === true + ); + } } diff --git a/packages/json-rpc-engine/tests/utils.ts b/packages/json-rpc-engine/tests/utils.ts index 03f3d748e0..2ed886379a 100644 --- a/packages/json-rpc-engine/tests/utils.ts +++ b/packages/json-rpc-engine/tests/utils.ts @@ -1,4 +1,5 @@ import type { JsonRpcRequest } from '@metamask/utils'; +import type { JsonRpcMiddleware } from 'src/v2/JsonRpcEngineV2'; import { requestProps } from '../src/v2/compatibility-utils'; import type { JsonRpcNotification } from '../src/v2/utils'; @@ -24,6 +25,34 @@ export const makeNotification = >( ...params, }) as JsonRpcNotification; +/** + * Creates a {@link JsonRpcCall} middleware that returns `null`. + * + * @returns The middleware. + */ +export const makeNullMiddleware = (): JsonRpcMiddleware => { + return () => null; +}; + +/** + * Creates a {@link JsonRpcRequest} middleware that returns `null`. + * + * @returns The middleware. + */ +export const makeRequestMiddleware = (): JsonRpcMiddleware => { + return () => null; +}; + +/** + * Creates a {@link JsonRpcNotification} middleware that returns `undefined`. + * + * @returns The middleware. + */ +export const makeNotificationMiddleware = + (): JsonRpcMiddleware => { + return () => undefined; + }; + /** * Get the keys of a request that are not part of the standard JSON-RPC request * properties.