diff --git a/packages/messenger/CHANGELOG.md b/packages/messenger/CHANGELOG.md index c299165f2de..4e06f9d0739 100644 --- a/packages/messenger/CHANGELOG.md +++ b/packages/messenger/CHANGELOG.md @@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - These allow delegating or revoking capabilities (actions or events) from one `Messenger` instance to another. - This allows passing capabilities through chains of messengers of arbitrary length - See this ADR for details: https://github.com/MetaMask/decisions/blob/main/decisions/core/0012-messenger-delegation.md +- Add `parent` constructor parameter and type parameter to `Messenger` ([#6142](https://github.com/MetaMask/core/pull/6142)) + - All capabilities registered under this messenger's namespace are delegated to the parent automatically. This is similar to how the `RestrictedMessenger` would automatically delegate all capabilities to the messenger it was created from. ### Changed @@ -25,7 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed - **BREAKING:** Remove `RestrictedMessenger` class ([#6132](https://github.com/MetaMask/core/pull/6132)) - - Existing `RestrictedMessenger` instances should be replaced with a `Messenger`. We can now use the same class everywhere, passing capabilities using `delegate`. + - Existing `RestrictedMessenger` instances should be replaced with a `Messenger` with the `parent` constructor parameter set to the global messenger. We can now use the same class everywhere, passing capabilities using `delegate`. - See this ADR for details: https://github.com/MetaMask/decisions/blob/main/decisions/core/0012-messenger-delegation.md ### Fixed diff --git a/packages/messenger/src/Messenger.test.ts b/packages/messenger/src/Messenger.test.ts index 89df3813a52..7b8341231c1 100644 --- a/packages/messenger/src/Messenger.test.ts +++ b/packages/messenger/src/Messenger.test.ts @@ -26,6 +26,33 @@ describe('Messenger', () => { expect(count).toBe(1); }); + it('automatically delegates actions to parent upon registration', () => { + type CountAction = { + type: 'Fixture:count'; + handler: (increment: number) => void; + }; + const parentMessenger = new Messenger<'Parent', CountAction, never>({ + namespace: 'Parent', + }); + const messenger = new Messenger< + 'Fixture', + CountAction, + never, + typeof parentMessenger + >({ + namespace: 'Fixture', + parent: parentMessenger, + }); + + let count = 0; + messenger.registerActionHandler('Fixture:count', (increment: number) => { + count += increment; + }); + parentMessenger.call('Fixture:count', 1); + + expect(count).toBe(1); + }); + it('should allow registering and calling multiple different action handlers', () => { // These 'Other' types are included to demonstrate that messenger generics can indeed be unions // of actions and events from different modules. @@ -225,6 +252,29 @@ describe('Messenger', () => { expect(handler.callCount).toBe(1); }); + it('automatically delegates events to parent upon first publish', () => { + type MessageEvent = { type: 'Fixture:message'; payload: [string] }; + const parentMessenger = new Messenger<'Parent', never, MessageEvent>({ + namespace: 'Parent', + }); + const messenger = new Messenger< + 'Fixture', + never, + MessageEvent, + typeof parentMessenger + >({ + namespace: 'Fixture', + parent: parentMessenger, + }); + + const handler = sinon.stub(); + parentMessenger.subscribe('Fixture:message', handler); + messenger.publish('Fixture:message', 'hello'); + + expect(handler.calledWithExactly('hello')).toBe(true); + expect(handler.callCount).toBe(1); + }); + it('should allow publishing multiple different events to subscriber', () => { type MessageEvent = | { type: 'Fixture:message'; payload: [string] } @@ -513,6 +563,47 @@ describe('Messenger', () => { }); }); + it('automatically delegates to parent when an initial payload is registered', () => { + const state = { + propA: 1, + propB: 1, + }; + type MessageEvent = { + type: 'Fixture:complexMessage'; + payload: [typeof state]; + }; + const parentMessenger = new Messenger<'Parent', never, MessageEvent>({ + namespace: 'Parent', + }); + const messenger = new Messenger< + 'Fixture', + never, + MessageEvent, + typeof parentMessenger + >({ + namespace: 'Fixture', + parent: parentMessenger, + }); + const handler = sinon.stub(); + + messenger.registerInitialEventPayload({ + eventType: 'Fixture:complexMessage', + getPayload: () => [state], + }); + + parentMessenger.subscribe( + 'Fixture:complexMessage', + handler, + (obj) => obj.propA, + ); + messenger.publish('Fixture:complexMessage', state); + expect(handler.callCount).toBe(0); + state.propA += 1; + messenger.publish('Fixture:complexMessage', state); + expect(handler.getCall(0)?.args).toStrictEqual([2, 1]); + expect(handler.callCount).toBe(1); + }); + it('should publish event to many subscribers with the same selector', () => { type MessageEvent = { type: 'Fixture:complexMessage'; @@ -1205,6 +1296,32 @@ describe('Messenger', () => { }); describe('revoke', () => { + it('throws when attempting to revoke from parent', () => { + type ExampleEvent = { + type: 'Source:event'; + payload: ['test']; + }; + const parentMessenger = new Messenger<'Parent', never, ExampleEvent>({ + namespace: 'Parent', + }); + const sourceMessenger = new Messenger< + 'Source', + never, + ExampleEvent, + typeof parentMessenger + >({ + namespace: 'Source', + parent: parentMessenger, + }); + + expect(() => + sourceMessenger.revoke({ + messenger: parentMessenger, + events: ['Source:event'], + }), + ).toThrow('Cannot revoke from parent'); + }); + it('allows revoking a delegated event', () => { type ExampleEvent = { type: 'Source:event'; diff --git a/packages/messenger/src/Messenger.ts b/packages/messenger/src/Messenger.ts index a23e283fdc8..a67a1d71e3d 100644 --- a/packages/messenger/src/Messenger.ts +++ b/packages/messenger/src/Messenger.ts @@ -181,9 +181,24 @@ export class Messenger< Namespace extends string, Action extends ActionConstraint, Event extends EventConstraint, + Parent extends Messenger< + string, + ActionConstraint, + EventConstraint, + // Use `any` to avoid preventing a parent from having a parent. `any` is harmless in a type + // constraint anyway, it's the one totally safe place to use it. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any + > = never, > { readonly #namespace: Namespace; + /** + * The parent messenger. All actions/events under this namespace are automatically delegated to + * the parent messenger. + */ + readonly #parent?: DelegatedMessenger; + readonly #actions = new Map(); readonly #events = new Map>(); @@ -225,11 +240,26 @@ export class Messenger< /** * Construct a messenger. * + * If a parent messenger is given, all actions and events under this messenger's namespace will + * be delegated to the parent automatically. + * * @param args - Constructor arguments * @param args.namespace - The messenger namespace. + * @param args.parent - The parent messenger. */ - constructor({ namespace }: { namespace: Namespace }) { + constructor({ + namespace, + parent, + }: { + namespace: Namespace; + parent?: Action['type'] extends MessengerActions['type'] + ? Event['type'] extends MessengerEvents['type'] + ? Parent + : never + : never; + }) { this.#namespace = namespace; + this.#parent = parent; } /** @@ -257,6 +287,11 @@ export class Messenger< ); } this.#registerActionHandler(actionType, handler); + if (this.#parent) { + // @ts-expect-error The parent type isn't constructed in a way that proves it supports this + // action, but this is OK because it's validated in the constructor. + this.delegate({ actions: [actionType], messenger: this.#parent }); + } } #registerActionHandler( @@ -400,6 +435,14 @@ export class Messenger< }:'`, ); } + if ( + this.#parent && + !this.#subscriptionDelegationTargets.get(eventType)?.has(this.#parent) + ) { + // @ts-expect-error The parent type isn't constructed in a way that proves it supports this + // event, but this is OK because it's validated in the constructor. + this.delegate({ events: [eventType], messenger: this.#parent }); + } this.#registerInitialEventPayload({ eventType, getPayload }); } @@ -449,6 +492,14 @@ export class Messenger< `Only allowed publishing events prefixed by '${this.#namespace}:'`, ); } + if ( + this.#parent && + !this.#subscriptionDelegationTargets.get(eventType)?.has(this.#parent) + ) { + // @ts-expect-error The parent type isn't constructed in a way that proves it supports this + // event, but this is OK because it's validated in the constructor. + this.delegate({ events: [eventType], messenger: this.#parent }); + } this.#publish(eventType, ...payload); } @@ -805,6 +856,9 @@ export class Messenger< events?: DelegatedEvents; messenger: Delegatee; }) { + if (messenger === this.#parent) { + throw new Error('Cannot revoke from parent'); + } for (const actionType of actions || []) { const delegationTargets = this.#actionDelegationTargets.get(actionType); if (!delegationTargets || !delegationTargets.has(messenger)) {