diff --git a/README.md b/README.md index 83f276c0a04..918e9a8b984 100644 --- a/README.md +++ b/README.md @@ -345,6 +345,7 @@ linkStyle default opacity:0.5 geolocation_controller --> base_controller; geolocation_controller --> controller_utils; geolocation_controller --> messenger; + json_rpc_engine --> messenger; json_rpc_middleware_stream --> json_rpc_engine; keyring_controller --> base_controller; keyring_controller --> messenger; diff --git a/packages/json-rpc-engine/CHANGELOG.md b/packages/json-rpc-engine/CHANGELOG.md index 19f9bd77b2f..14411f2039e 100644 --- a/packages/json-rpc-engine/CHANGELOG.md +++ b/packages/json-rpc-engine/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `createMethodMiddleware` ([#8506](https://github.com/MetaMask/core/pull/8506)) + - This utility allows JSON-RPC method implementations to use both the hooks pattern and the messenger. + ## [10.2.4] ### Fixed diff --git a/packages/json-rpc-engine/package.json b/packages/json-rpc-engine/package.json index 343ceabf715..f6b5e89cb03 100644 --- a/packages/json-rpc-engine/package.json +++ b/packages/json-rpc-engine/package.json @@ -70,6 +70,7 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { + "@metamask/messenger": "^1.1.1", "@metamask/rpc-errors": "^7.0.2", "@metamask/safe-event-emitter": "^3.0.0", "@metamask/utils": "^11.9.0", diff --git a/packages/json-rpc-engine/src/v2/createMethodMiddleware.test.ts b/packages/json-rpc-engine/src/v2/createMethodMiddleware.test.ts new file mode 100644 index 00000000000..5b0a8af573a --- /dev/null +++ b/packages/json-rpc-engine/src/v2/createMethodMiddleware.test.ts @@ -0,0 +1,56 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; + +import { makeRequest } from '../../tests/utils'; +import { + createMethodMiddleware, + MethodHandler, +} from './createMethodMiddleware'; +import { JsonRpcEngineV2 } from './JsonRpcEngineV2'; + +type TestAction = { + type: 'Example:TestAction'; + handler: () => Promise; +}; + +const getValueA = { + hookNames: { testHook: true }, + implementation: ({ hooks }): Promise => hooks.testHook(), +} satisfies MethodHandler<{ testHook: () => Promise }>; + +const getValueB = { + actionNames: ['Example:TestAction'], + implementation: ({ messenger }): Promise => + messenger.call('Example:TestAction'), +} satisfies MethodHandler; + +const messenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, +}); + +messenger.registerActionHandler('Example:TestAction', async () => 'B'); + +const middleware = createMethodMiddleware({ + handlers: { getValueA, getValueB }, + hooks: { testHook: async () => 'A' }, + messenger, +}); + +const engine = JsonRpcEngineV2.create({ middleware: [middleware] }); + +describe('createMethodMiddleware', () => { + it('passes in the requested hooks', async () => { + const result = await engine.handle(makeRequest({ method: 'getValueA' })); + expect(result).toBe('A'); + }); + + it('passes in a delegated messenger', async () => { + const result = await engine.handle(makeRequest({ method: 'getValueB' })); + expect(result).toBe('B'); + }); + + it('skips unrecognized methods', async () => { + await expect( + engine.handle(makeRequest({ method: 'getValueC' })), + ).rejects.toThrow('Nothing ended request'); + }); +}); diff --git a/packages/json-rpc-engine/src/v2/createMethodMiddleware.ts b/packages/json-rpc-engine/src/v2/createMethodMiddleware.ts new file mode 100644 index 00000000000..1e9bd87f97e --- /dev/null +++ b/packages/json-rpc-engine/src/v2/createMethodMiddleware.ts @@ -0,0 +1,165 @@ +import { ActionConstraint, Messenger } from '@metamask/messenger'; + +import { JsonRpcMiddleware, Next } from './JsonRpcEngineV2'; +import { ContextConstraint } from './MiddlewareContext'; +import { + Json, + JsonRpcParams, + JsonRpcRequest, + UnionToIntersection, +} from './utils'; + +// The helpers below seem excessive, but they are required for inference of hooks/actions. +type HandlerActions = Handler extends { + implementation: (options: infer Options) => unknown; +} + ? Options extends { messenger: Messenger } + ? Actions + : never + : never; + +type HandlerHooks = Handler extends { + implementation: (options: infer Options) => unknown; +} + ? Options extends { hooks: infer Hooks } + ? Hooks + : never + : never; + +export type MethodHandler< + Hooks extends Record = Record, + MessengerActions extends ActionConstraint = never, + Parameters extends JsonRpcParams = JsonRpcParams, + Result extends Json = Json, + Context extends ContextConstraint = ContextConstraint, +> = { + implementation: (options: { + request: Readonly>; + context: Context; + next: Next; + messenger: Messenger; + hooks: Hooks; + }) => Promise | Result; + hookNames?: { [Key in keyof Hooks]: true }; + actionNames?: MessengerActions['type'][]; +}; + +type AnyMethodHandler = { + implementation( + this: void, + options: { + request: Readonly; + context: ContextConstraint; + next: Next; + messenger: unknown; + hooks: unknown; + }, + ): Promise | Json; + hookNames?: Record; + actionNames?: readonly string[]; +}; + +export type CreateMethodMiddlewareOptions< + Handlers extends Record, +> = { + handlers: Handlers; + messenger: Messenger>; + hooks: UnionToIntersection>; +}; + +type ResolvedHandler = { + implementation: AnyMethodHandler['implementation']; + hooks: Record; + messenger: Messenger; +}; + +/** + * Create a JSON-RPC middleware that handles the passed JSON-RPC method handlers using the messenger and hooks. + * + * @param options The options. + * @param options.handlers - The JSON-RPC method handler implementations. + * @param options.messenger - The messenger to be used by the handlers. + * @param options.hooks - The hooks to be used by the handlers. + * @returns A JsonRpcEngineV2 middleware. + */ +export function createMethodMiddleware< + Handlers extends Record, + Context extends ContextConstraint, +>( + options: CreateMethodMiddlewareOptions, +): JsonRpcMiddleware { + const { messenger: rootMessenger } = options; + const allHooks = options.hooks as Record; + + const handlers = Object.entries(options.handlers).reduce< + Record + >((accumulator, [handlerName, handler]) => { + const handlerHooks = selectHooks(allHooks, handler.hookNames) ?? {}; + const handlerMessenger = new Messenger< + string, + HandlerActions, + never, + typeof rootMessenger + >({ + namespace: handlerName, + parent: rootMessenger, + }); + + rootMessenger.delegate({ + actions: (handler.actionNames ?? []) as HandlerActions< + Handlers[keyof Handlers] + >['type'][], + messenger: handlerMessenger, + }); + + accumulator[handlerName] = { + implementation: handler.implementation, + hooks: handlerHooks, + messenger: handlerMessenger, + }; + return accumulator; + }, {}); + + return ({ request, context, next }) => { + const handler = handlers[request.method]; + if (handler === undefined) { + return next(); + } + + const { implementation, hooks, messenger } = handler; + + return implementation({ request, context, next, hooks, messenger }); + }; +} + +/** + * Returns the subset of the specified `hooks` that are included in the + * `hookNames` object. This is a Principle of Least Authority (POLA) measure + * to ensure that each RPC method implementation only has access to the + * API "hooks" it needs to do its job. + * + * @param hooks - The hooks to select from. + * @param hookNames - The names of the hooks to select. + * @returns The selected hooks. + * @template Hooks - The hooks to select from. + * @template HookName - The names of the hooks to select. + */ +export function selectHooks< + Hooks extends Record, + HookName extends keyof Hooks, +>( + hooks: Hooks, + hookNames?: Record, +): Pick | undefined { + if (hookNames) { + return Object.keys(hookNames).reduce>>( + (hookSubset, _hookName) => { + const hookName = _hookName as HookName; + hookSubset[hookName] = hooks[hookName]; + return hookSubset; + }, + {}, + ) as Pick; + } + return undefined; +} diff --git a/packages/json-rpc-engine/src/v2/index.test.ts b/packages/json-rpc-engine/src/v2/index.test.ts index a36b0ef1b99..cd406ad8a88 100644 --- a/packages/json-rpc-engine/src/v2/index.test.ts +++ b/packages/json-rpc-engine/src/v2/index.test.ts @@ -9,10 +9,12 @@ describe('@metamask/json-rpc-engine/v2', () => { "JsonRpcServer", "MiddlewareContext", "asLegacyMiddleware", + "createMethodMiddleware", "createScaffoldMiddleware", "getUniqueId", "isNotification", "isRequest", + "selectHooks", ] `); }); diff --git a/packages/json-rpc-engine/src/v2/index.ts b/packages/json-rpc-engine/src/v2/index.ts index 393b4244bd6..a5046e591d5 100644 --- a/packages/json-rpc-engine/src/v2/index.ts +++ b/packages/json-rpc-engine/src/v2/index.ts @@ -1,5 +1,7 @@ export { asLegacyMiddleware } from './asLegacyMiddleware'; export { getUniqueId } from '../getUniqueId'; +export { selectHooks, createMethodMiddleware } from './createMethodMiddleware'; +export type { MethodHandler } from './createMethodMiddleware'; export { createScaffoldMiddleware } from './createScaffoldMiddleware'; export { JsonRpcEngineV2 } from './JsonRpcEngineV2'; export type { diff --git a/packages/json-rpc-engine/tsconfig.build.json b/packages/json-rpc-engine/tsconfig.build.json index 0df910b2151..6b68c1d0498 100644 --- a/packages/json-rpc-engine/tsconfig.build.json +++ b/packages/json-rpc-engine/tsconfig.build.json @@ -5,5 +5,10 @@ "outDir": "./dist", "rootDir": "./src" }, + "references": [ + { + "path": "../messenger/tsconfig.build.json" + } + ], "include": ["../../types", "./src"] } diff --git a/packages/json-rpc-engine/tsconfig.json b/packages/json-rpc-engine/tsconfig.json index 4e929aca4ee..b6e79d715bd 100644 --- a/packages/json-rpc-engine/tsconfig.json +++ b/packages/json-rpc-engine/tsconfig.json @@ -9,5 +9,10 @@ "noUncheckedIndexedAccess": true, "target": "es2020" }, + "references": [ + { + "path": "../messenger/tsconfig.json" + } + ], "include": ["../../types", "../../tests", "./src", "./tests"] } diff --git a/yarn.lock b/yarn.lock index b5a994e4dbf..f31b52556b2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4106,6 +4106,7 @@ __metadata: "@lavamoat/allow-scripts": "npm:^3.0.4" "@lavamoat/preinstall-always-fail": "npm:^2.1.0" "@metamask/auto-changelog": "npm:^6.1.0" + "@metamask/messenger": "npm:^1.1.1" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/safe-event-emitter": "npm:^3.0.0" "@metamask/utils": "npm:^11.9.0"