Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Preliminary Scopes Implementation #102

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
34 changes: 34 additions & 0 deletions main/contracts/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,19 @@ export interface IMessage<T = any> {
* Represents a messagebroker and provides access to the core features which includes publishing/subscribing to messages and RSVP.
*/
export interface IMessageBroker<T> {
/**
* A unique identifier for this instance of the MessageBroker. This is useful for identifying an instance within a tree of scopes.
*/
readonly name: string;
Copy link
Member Author

Choose a reason for hiding this comment

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

I've introduced the name property on the messagebroker instance itself to keep track. The root broker has the name 'root'

/**
* A reference to the parent scope if this is not the root node in the tree of scopes. If this is the root, it's undefined.
*/
readonly parent?: IMessageBroker<T>;

Choose a reason for hiding this comment

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

I think the parent may have different generic params. We are assuming all the tree is uniform. Its fine but something to considerr.

Copy link
Member Author

Choose a reason for hiding this comment

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

If I'm understand right, do you mean supporting something like this?

const broker1 = messagebroker<Contract1>();
const broker2 = broker1.createScope<Contract2>('my-scope');

in this case, would Contract2 be required to extend Contract 1? Otherwise a message sent to broker1 could not necessarily be passed down to broker2.

Copy link

@Davidhanson90 Davidhanson90 Nov 14, 2024

Choose a reason for hiding this comment

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

Yeah so I think this is governed by the propergation logic you plan to implement. For example say create scope and that scope extends the parents channels type, then it would mean my scope would essentially be a superset of the parent and messages subsribed or publishes essentially propergate upwards.

interface Contract1{
 "foo": boolean;
}

const broker1 = messagebroker<Contract1>();
const broker2 = broker1.createScope<Contract2 extends Contract1>('my-scope');

broker2.publish('foo') //Works

However if you do not extend the parents type then you could it this way

const broker1 = messagebroker<Contract1>();
const broker2 = broker1.createScope<Contract2 extends Contract1>('my-scope');

broker2.publish('foo') //Compile error
broker2.parent.publish('foo') //Works

I prefer the extended types option as it makes more sense that channels delegate upwards

/**
* A list of all child scopes that have been created on this instance of the broker.
*/
children: IMessageBroker<T>[];

/**
* Creates a new channel with the provided channelName. An optional config object can be passed that specifies how many messages to cache.
* No caching is set by default
Expand Down Expand Up @@ -96,6 +109,27 @@ export interface IMessageBroker<T> {
* This RSVP function is used by responders and is analogous to the 'Get' function. Responders when invoked must return the required response value type.
*/
rsvp<K extends keyof RSVPOf<T>>(channelName: K, handler: RSVPHandler<T>): IResponderRef;

/**
* Creates a new scope with the given scopeName with this instance of the MessageBroker as its parent.
* If a scope with this name already exists, it returns that instance instead of creating a new one.
* @param scopeName The name to use for the scope to create
* @returns An instance of the messagebroker that matches the scopeName provided
*/
createScope(scopeName: string): IMessageBroker<T>;

/*
* Destroys all children scopes, disposes of all message channels on
* this instance and removes itself from its parents children.
*/
destroy(): void;

/**
* Returns true if this is root node of the tree of MessageBrokers.
* The root MessageBroker will not have a parent MessageBroker.
* @returns A boolean indicating whether this is the root or not
*/
isRoot(): boolean;
}

/**
Expand Down
62 changes: 61 additions & 1 deletion main/core/messagebroker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,13 @@ export function messagebroker<T = any>(): IMessageBroker<T> {
export class MessageBroker<T = any> implements IMessageBroker<T> {
private channelLookup: ChannelModelLookup<T> = {};
private messagePublisher = new Subject<IMessage<any>>();
private _children: IMessageBroker<T>[] = [];

constructor(private rsvpMediator: RSVPMediator<T>) {}
constructor(
private rsvpMediator: RSVPMediator<T>,
private _parent?: IMessageBroker<T>,
private _name: string = 'root',
) {}

/**
* Creates a new channel with the provided channelName. An optional config object can be passed that specifies how many messages to cache.
Expand Down Expand Up @@ -92,13 +97,47 @@ export class MessageBroker<T = any> implements IMessageBroker<T> {
* @param channelName Name of the messagebroker channel
*/
public dispose<K extends keyof T>(channelName: K): void {
this._children.forEach((scope) => scope.dispose(channelName));
const channel = this.channelLookup[channelName];
if (this.isChannelConfiguredWithCaching(channel)) {
channel.subscription.unsubscribe();
}
delete this.channelLookup[channelName];
}

/**
* Creates a new scope with the given scopeName with this instance of the MessageBroker as its parent.
* If a scope with this name already exists, it returns that instance instead of creating a new one.
* @param scopeName The name to use for the scope to create
* @returns An instance of the messagebroker that matches the scopeName provided
*/
public createScope(scopeName: string): IMessageBroker<T> {
const existingScope = this._children.find((scope) => scope.name === scopeName);
if (existingScope) {
return existingScope;
}

const instance = new MessageBroker<T>(this.rsvpMediator, this, scopeName);
this._children.push(instance);
return instance;
}

/*
* Destroys all children scopes, disposes of all message channels on
* this instance and removes itself from its parents children.
*/
public destroy(): void {
this._children.forEach((childScope) => childScope.destroy());

type Channels = (keyof typeof this.channelLookup)[];
(Object.keys(this.channelLookup) as Channels).forEach((channelName) => this.dispose(channelName));

if (this._parent) {
this._parent.children = this._parent.children.filter((child) => child !== this); // remove itself from the parent
this._parent = undefined;
}
}

/**
* Return a deferred observable as the channel config may have been updated before the subscription
* @param channelName name of channel to subscribe to
Expand Down Expand Up @@ -143,6 +182,7 @@ export class MessageBroker<T = any> implements IMessageBroker<T> {
}

const publishFunction = (data?: T[K], type?: string): void => {
this._children.forEach((scope) => scope.create(channelName).publish(data), type); // propagate messages to children
this.messagePublisher.next(this.createMessage(channelName, data, type));
};

Expand Down Expand Up @@ -180,4 +220,24 @@ export class MessageBroker<T = any> implements IMessageBroker<T> {
): channel is RequiredPick<IChannelModel<T[K]>, 'config' | 'subscription'> {
return channel != null && channel.subscription != null;
}

public isRoot(): boolean {
return this._parent === undefined;
}

public get parent(): IMessageBroker<T> | undefined {
aidanm3341 marked this conversation as resolved.
Show resolved Hide resolved
return this._parent;
}

public get children(): IMessageBroker<T>[] {
return this._children;
}

protected set children(children: IMessageBroker<T>[]) {
this._children = children;
}

public get name(): string {
return this._name;
}
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@morgan-stanley/message-broker",
"version": "1.0.0",
"version": "1.1.0",
"description": "Framework agnostic messagebroker for decoupled communication.",
"main": "dist/main/index.js",
"types": "dist/main/index.d.ts",
Expand Down
99 changes: 99 additions & 0 deletions site/content/documentation/1.0.0/scopes.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
---
order: 5
title: Scopes
---

Message scoping is a mechanism for restricting which subscribers will receive a given message.
You can create a new scope on a messagebroker by calling `createScope`.

```typescript
const parent: IMessageBroker<IContract>
= messagebroker<IContract>();

const child: IMessageBroker<IContract>
= parent.createScope('my-scope');

parent.children.contains(child) // expect: true
```

A scope is just another instance of an `IMessageBroker` on which you can perform all of the same operations that you'd expect on the base messagebroker.
The main thing to note about this feature is how messages are shared across scopes.

### Scope Hierarchies

Any message that is published to a broker is also published down through the hierarchy of children scopes belonging that broker.

```typescript
parent.get('x').subscribe(message => console.log('parent received'));
child.get('x').subscribe(message => console.log('child received'));

parent.create('x').publish({});

// expect: child received
// expect: parent received
```

However messages are not sent **up** the hierarchy to the parent of that broker.

```typescript
parent.get('x').subscribe(message => console.log('parent received'));
child.get('x').subscribe(message => console.log('child received'));

child.create('x').publish({});

// expect: child received
```

Messages are also not published to "sibling" scopes, where the brokers share a parent.

```typescript
const sibling: IMessageBroker<IContract>
= parent.createScope('sibling-scope');

parent.get('x').subscribe(message => console.log('parent received'));
child.get('x').subscribe(message => console.log('child received'));
sibling.get('x').subscribe(message => console.log('sibling received'));

sibling.create('x').publish({});

// expect: sibling received
```

### Scope Depth

Scope hierarchies can be arbitrarily deep, and messages will make their way all the way down to the bottom.

```typescript
const distantChild = parent
.createScope('scope1')
.createScope('scope2')
...
.createScope('scopeX');

distantChild.get('x').subscribe(message => console.log('child received'));

parent.create('x').publish({});

// expect: child received
```

### Naming

Scopes under the same parent cannot have the same name.
An attempt to create a scope with a name that already exists on a broker will just return the original scope.

If they are different parts of the hierarchy (i.e. don't share a parent), then you can have multiple scopes with the same name.

### Disposal

Disposing of a channel in a broker will also dispose of that channel in its children scopes.

### Destroy

A MessageBroker instance can be destroyed.
Destroying a MessageBroker will first destroy all of its children, it will dispose of all its channels, and finally remove itself from its parent.

```typescript
child.destroy();
parent.children.contains(child); // expect: false
```
Loading