Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/messenger/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add `delegateAll` method for exhaustive delegation with compile-time checking ([#8338](https://github.com/MetaMask/core/pull/8338))
- Unlike `delegate`, this method requires all external actions and events to be listed, producing a TypeScript error showing exactly which items are missing.
- Add `MessengerNamespace` utility type to extract the namespace from a Messenger type ([#8338](https://github.com/MetaMask/core/pull/8338))

## [2.0.0]

### Added
Expand Down
3 changes: 3 additions & 0 deletions packages/messenger/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ module.exports = merge(baseConfig, {
// The display name when running multiple projects
displayName,

// Exclude TSTyche type test files from coverage collection
collectCoverageFrom: ['./src/**/*.ts', '!**/*.tst.ts'],

// An object that configures minimum threshold enforcement for coverage results
coverageThreshold: {
global: {
Expand Down
5 changes: 4 additions & 1 deletion packages/messenger/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,10 @@
"lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts",
"lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix",
"since-latest-release": "../../scripts/since-latest-release.sh",
"test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter",
"test": "yarn test:unit && yarn test:types",
"test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache",
"test:types": "tstyche",
"test:unit": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter",
"test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose",
"test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch"
},
Expand All @@ -63,6 +65,7 @@
"immer": "^9.0.6",
"jest": "^30.4.2",
"ts-jest": "^29.4.11",
"tstyche": "5.0.2",
"tsx": "^4.20.5",
"typedoc": "^0.25.13",
"typedoc-plugin-missing-exports": "^2.0.0",
Expand Down
71 changes: 71 additions & 0 deletions packages/messenger/src/Messenger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1931,6 +1931,77 @@ describe('Messenger', () => {
});
});

describe('delegateAll', () => {
it('delegates all listed actions and events', () => {
type SourceAction = {
type: 'Source:getValue';
handler: () => number;
};
type ChildOwnAction = {
type: 'Child:doStuff';
handler: () => void;
};
type SourceEvent = {
type: 'Source:stateChange';
payload: [{ value: number }];
};

const sourceMessenger = new Messenger<
'Source',
SourceAction | ChildOwnAction,
SourceEvent
>({ namespace: 'Source' });

const childMessenger = new Messenger<
'Child',
SourceAction | ChildOwnAction,
SourceEvent
>({ namespace: 'Child' });

sourceMessenger.registerActionHandler('Source:getValue', () => 42);

sourceMessenger.delegateAll({
messenger: childMessenger,
actions: ['Source:getValue'],
events: ['Source:stateChange'],
});

// Child can now call the delegated action
expect(childMessenger.call('Source:getValue')).toBe(42);

// Child can now subscribe to the delegated event
const subscriber = jest.fn();
// eslint-disable-next-line no-restricted-syntax
childMessenger.subscribe('Source:stateChange', subscriber);
sourceMessenger.publish('Source:stateChange', { value: 1 });
expect(subscriber).toHaveBeenCalledWith({ value: 1 });
});

it('delegates actions with an empty events array', () => {
type SourceAction = {
type: 'Source:getValue';
handler: () => number;
};

const sourceMessenger = new Messenger<'Source', SourceAction, never>({
namespace: 'Source',
});
const childMessenger = new Messenger<'Child', SourceAction, never>({
namespace: 'Child',
});

sourceMessenger.registerActionHandler('Source:getValue', () => 99);

sourceMessenger.delegateAll({
messenger: childMessenger,
actions: ['Source:getValue'],
events: [],
});

expect(childMessenger.call('Source:getValue')).toBe(99);
});
});

describe('revoke', () => {
it('throws when attempting to revoke from parent', () => {
type ExampleEvent = {
Expand Down
100 changes: 100 additions & 0 deletions packages/messenger/src/Messenger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,47 @@ export type MessengerEvents<
? Event
: never;

/**
* Extract the namespace from a Messenger type.
*
* @template Subject - The messenger type to extract from.
*/
export type MessengerNamespace<
Subject extends Messenger<string, ActionConstraint, EventConstraint>,
> =
Subject extends Messenger<infer N, ActionConstraint, EventConstraint>
? N
: never;

/**
* Validate that all members of a union are present in a tuple.
*
* When all required members are present, evaluates to the input tuple unchanged.
* When members are missing, evaluates to a branded intersection type that
* produces a clear compile error showing exactly which items are missing via
* the `__MISSING_DELEGATIONS__` property.
*
* @template Required - The union of all required string types.
* @template Provided - The readonly tuple of provided string types.
* @example
* ```typescript
* // OK — all required items present
* type T1 = RequireExhaustive<'A' | 'B', readonly ['A', 'B']>;
* // => readonly ['A', 'B']
*
* // Error — 'C' is missing
* type T2 = RequireExhaustive<'A' | 'B' | 'C', readonly ['A', 'B']>;
* // => readonly ['A', 'B'] & { __MISSING_DELEGATIONS__: 'C' }
* ```
*/
type RequireExhaustive<
Required extends string,
Provided extends readonly string[],
> = [Exclude<Required, Provided[number]>] extends [never]
? Provided
: // eslint-disable-next-line @typescript-eslint/naming-convention
Provided & { __MISSING_DELEGATIONS__: Exclude<Required, Provided[number]> };

/**
* Messenger namespace checks can be disabled by using this as the `namespace` constructor
* parameter, and using `MockAnyNamespace` as the Namespace type parameter.
Expand Down Expand Up @@ -1129,6 +1170,65 @@ export class Messenger<
}
}

/**
* Delegate all external actions and events to another messenger, with
* compile-time exhaustiveness checking.
*
* Unlike {@link delegate}, which accepts a partial list of actions/events,
* this method requires that **every** action and event the delegatee needs
* from outside its own namespace is included. If any are missing, TypeScript
* produces a type error showing the missing items.
*
* The source messenger's action/event types must include every required
* external item. Items the source cannot provide still appear in the
* missing set, so incomplete source typing fails loudly instead of being
* silently skipped.
*
* Use this when a single source messenger provides all external
* actions/events for a child messenger (the common pattern in controller
* initialisation).
*
* @param args - Arguments.
* @param args.actions - The action types to delegate. Must include every
* action type defined on the delegatee that is **not** under its own
* namespace.
* @param args.events - The event types to delegate. Must include every event
* type defined on the delegatee that is **not** under its own namespace.
* @param args.messenger - The messenger to delegate to.
* @template Delegatee - The messenger the actions/events are delegated to.
* @template DelegatedActions - An array of delegated action type strings.
* @template DelegatedEvents - An array of delegated event type strings.
*/
delegateAll<

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see that you moved all of the tests to Messenger.tst.ts. Maybe we should still have one test which verifies that the behavior of delegateAll is as we expect (i.e., it delegates the requested actions and events to the given messenger)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We still have those in Messenger.test.ts under describe('delegateAll') https://github.com/MetaMask/core/pull/8338/changes#diff-7c70b8b38fb189e7496767fa9487bf0bbafe014e2fa5f9efb3224fda03fdea27R1934: one for actions + events, and one for actions with an empty events list. Only the compile-time cases moved to Messenger.tst.ts

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, you're right. Thanks.

Delegatee extends Messenger<string, ActionConstraint, EventConstraint>,
DelegatedActions extends (MessengerActions<Delegatee>['type'] &
Action['type'])[],
DelegatedEvents extends (MessengerEvents<Delegatee>['type'] &
Event['type'])[],
>({
actions,
events,
messenger,
}: {
messenger: Delegatee;
actions: RequireExhaustive<
NotNamespacedBy<
MessengerNamespace<Delegatee>,
MessengerActions<Delegatee>['type']
>,
DelegatedActions
>;
events: RequireExhaustive<
NotNamespacedBy<
MessengerNamespace<Delegatee>,
MessengerEvents<Delegatee>['type']
>,
DelegatedEvents
>;
Comment thread
cursor[bot] marked this conversation as resolved.
}): void {
this.delegate({ actions, events, messenger });
}

/**
* Revoke delegated actions and/or events from another messenger.
*
Expand Down
108 changes: 108 additions & 0 deletions packages/messenger/src/Messenger.tst.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { describe, expect, test } from 'tstyche';

import { Messenger } from './Messenger.js';

describe('Messenger', () => {
describe('delegateAll', () => {
type ActionA = { type: 'A:getValue'; handler: () => number };
type ActionB = { type: 'B:getName'; handler: () => string };
type ChildOwnAction = { type: 'Child:doStuff'; handler: () => void };
type EventA = {
type: 'A:stateChange';
payload: [{ value: number }];
};
type EventB = {
type: 'B:nameChange';
payload: [{ name: string }];
};

test('accepts a complete list of external actions and events', () => {
const source = new Messenger<
'Source',
ActionA | ActionB | ChildOwnAction,
EventA | EventB
>({ namespace: 'Source' });
const child = new Messenger<
'Child',
ActionA | ActionB | ChildOwnAction,
EventA | EventB
>({ namespace: 'Child' });

expect(
source.delegateAll({
messenger: child,
actions: ['A:getValue', 'B:getName'],
events: ['A:stateChange', 'B:nameChange'],
}),
).type.not.toRaiseError();
});

test('excludes the delegatee own-namespace actions from the exhaustiveness check', () => {
const source = new Messenger<'Source', ActionA | ChildOwnAction, never>({
namespace: 'Source',
});
const child = new Messenger<'Child', ActionA | ChildOwnAction, never>({
namespace: 'Child',
});

expect(
source.delegateAll({
messenger: child,
actions: ['A:getValue'],
events: [],
}),
).type.not.toRaiseError();
});

test('raises a type error when an external action is missing', () => {
const source = new Messenger<'Source', ActionA | ActionB, never>({
namespace: 'Source',
});
const child = new Messenger<'Child', ActionA | ActionB, never>({
namespace: 'Child',
});

expect(
source.delegateAll({
messenger: child,
actions: ['A:getValue'],
events: [],
}),
).type.toRaiseError();
});

test('raises a type error when an external event is missing', () => {
const source = new Messenger<'Source', never, EventA | EventB>({
namespace: 'Source',
});
const child = new Messenger<'Child', never, EventA | EventB>({
namespace: 'Child',
});

expect(
source.delegateAll({
messenger: child,
actions: [],
events: ['A:stateChange'],
}),
).type.toRaiseError();
});

test('raises a type error when the source cannot provide a required external action', () => {
const source = new Messenger<'Source', ActionA, never>({
namespace: 'Source',
});
const child = new Messenger<'Child', ActionA | ActionB, never>({
namespace: 'Child',
});

expect(
source.delegateAll({
messenger: child,
actions: ['A:getValue'],
events: [],
}),
).type.toRaiseError();
});
});
});
1 change: 1 addition & 0 deletions packages/messenger/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type {
EventConstraint,
MessengerActions,
MessengerEvents,
MessengerNamespace,
MockAnyNamespace,
NamespacedBy,
NotNamespacedBy,
Expand Down
3 changes: 2 additions & 1 deletion packages/messenger/tsconfig.build.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@
"rootDir": "./src"
},
"references": [],
"include": ["../../types", "./src"]
"include": ["../../types", "./src"],
"exclude": ["**/*.test.ts", "**/*.tst.ts"]
}
3 changes: 2 additions & 1 deletion packages/messenger/tsconfig.lint.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
"compilerOptions": {
"outDir": "./.tsc-lint-cache",
"tsBuildInfoFile": "./.tsc-lint-cache/tsconfig.tsbuildinfo"
}
},
"exclude": ["**/*.tst.ts"]
}
9 changes: 8 additions & 1 deletion yarn.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,14 @@ module.exports = defineConfig({
// @metamask/wallet-cli prepends a better-sqlite3 prebuild fetch to its
// "test" script because the native addon isn't built during
// `yarn install` (Yarn runs with `enableScripts: false`).
if (workspace.ident !== '@metamask/wallet-cli') {
// @metamask/messenger also runs TSTyche compile-time tests after Jest.
if (workspace.ident === '@metamask/messenger') {
expectWorkspaceField(
workspace,
'scripts.test',
'yarn test:unit && yarn test:types',
);
} else if (workspace.ident !== '@metamask/wallet-cli') {
expectWorkspaceField(
workspace,
'scripts.test',
Expand Down
Loading