1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-14 07:13:32 +00:00

[PM-7489] Introduce MessageSender & MessageListener (#8709)

* Introduce MessageSender

* Update `messageSenderFactory`

* Remove Comment

* Use BrowserApi

* Update Comment

* Rename to CommandDefinition

* Add More Documentation to MessageSender

* Add `EMPTY` helpers and remove NoopMessageSender

* Calm Down Logging

* Limit Logging On Known Errors

* Use `messageStream` Parameter

Co-authored-by: Matt Gibson <mgibson@bitwarden.com>

* Add eslint rules

* Update Error Handling

Co-authored-by: Cesar Gonzalez <cesar.a.gonzalezcs@gmail.com>

* Delete Lazy Classes In Favor of Observable Factories

* Remove Fido Messages

---------

Co-authored-by: Matt Gibson <mgibson@bitwarden.com>
Co-authored-by: Cesar Gonzalez <cesar.a.gonzalezcs@gmail.com>
This commit is contained in:
Justin Baur
2024-04-19 14:02:40 -05:00
committed by GitHub
parent 9a4279c8bb
commit 395ed3f5d4
44 changed files with 855 additions and 361 deletions

View File

@@ -0,0 +1,46 @@
import { Subject, firstValueFrom } from "rxjs";
import { getCommand, isExternalMessage, tagAsExternal } from "./helpers";
import { Message, CommandDefinition } from "./types";
describe("helpers", () => {
describe("getCommand", () => {
it("can get the command from just a string", () => {
const command = getCommand("myCommand");
expect(command).toEqual("myCommand");
});
it("can get the command from a message definition", () => {
const commandDefinition = new CommandDefinition<object>("myCommand");
const command = getCommand(commandDefinition);
expect(command).toEqual("myCommand");
});
});
describe("tag integration", () => {
it("can tag and identify as tagged", async () => {
const messagesSubject = new Subject<Message<object>>();
const taggedMessages = messagesSubject.asObservable().pipe(tagAsExternal);
const firstValuePromise = firstValueFrom(taggedMessages);
messagesSubject.next({ command: "test" });
const result = await firstValuePromise;
expect(isExternalMessage(result)).toEqual(true);
});
});
describe("isExternalMessage", () => {
it.each([null, { command: "myCommand", test: "object" }, undefined] as Message<
Record<string, unknown>
>[])("returns false when value is %s", (value: Message<object>) => {
expect(isExternalMessage(value)).toBe(false);
});
});
});

View File

@@ -0,0 +1,23 @@
import { MonoTypeOperatorFunction, map } from "rxjs";
import { Message, CommandDefinition } from "./types";
export const getCommand = (commandDefinition: CommandDefinition<object> | string) => {
if (typeof commandDefinition === "string") {
return commandDefinition;
} else {
return commandDefinition.command;
}
};
export const EXTERNAL_SOURCE_TAG = Symbol("externalSource");
export const isExternalMessage = (message: Message<object>) => {
return (message as Record<PropertyKey, unknown>)?.[EXTERNAL_SOURCE_TAG] === true;
};
export const tagAsExternal: MonoTypeOperatorFunction<Message<object>> = map(
(message: Message<object>) => {
return Object.assign(message, { [EXTERNAL_SOURCE_TAG]: true });
},
);

View File

@@ -0,0 +1,4 @@
export { MessageListener } from "./message.listener";
export { MessageSender } from "./message.sender";
export { Message, CommandDefinition } from "./types";
export { isExternalMessage } from "./helpers";

View File

@@ -0,0 +1,5 @@
// Built in implementations
export { SubjectMessageSender } from "./subject-message.sender";
// Helpers meant to be used only by other implementations
export { tagAsExternal, getCommand } from "./helpers";

View File

@@ -0,0 +1,47 @@
import { Subject } from "rxjs";
import { subscribeTo } from "../../../spec/observable-tracker";
import { MessageListener } from "./message.listener";
import { Message, CommandDefinition } from "./types";
describe("MessageListener", () => {
const subject = new Subject<Message<{ test: number }>>();
const sut = new MessageListener(subject.asObservable());
const testCommandDefinition = new CommandDefinition<{ test: number }>("myCommand");
describe("allMessages$", () => {
it("runs on all nexts", async () => {
const tracker = subscribeTo(sut.allMessages$);
const pausePromise = tracker.pauseUntilReceived(2);
subject.next({ command: "command1", test: 1 });
subject.next({ command: "command2", test: 2 });
await pausePromise;
expect(tracker.emissions[0]).toEqual({ command: "command1", test: 1 });
expect(tracker.emissions[1]).toEqual({ command: "command2", test: 2 });
});
});
describe("messages$", () => {
it("runs on only my commands", async () => {
const tracker = subscribeTo(sut.messages$(testCommandDefinition));
const pausePromise = tracker.pauseUntilReceived(2);
subject.next({ command: "notMyCommand", test: 1 });
subject.next({ command: "myCommand", test: 2 });
subject.next({ command: "myCommand", test: 3 });
subject.next({ command: "notMyCommand", test: 4 });
await pausePromise;
expect(tracker.emissions[0]).toEqual({ command: "myCommand", test: 2 });
expect(tracker.emissions[1]).toEqual({ command: "myCommand", test: 3 });
});
});
});

View File

@@ -0,0 +1,41 @@
import { EMPTY, Observable, filter } from "rxjs";
import { Message, CommandDefinition } from "./types";
/**
* A class that allows for listening to messages coming through the application,
* allows for listening of all messages or just the messages you care about.
*
* @note Consider NOT using messaging at all if you can. State Providers offer an observable stream of
* data that is persisted. This can serve messages that might have been used to notify of settings changes
* or vault data changes and those observables should be preferred over messaging.
*/
export class MessageListener {
constructor(private readonly messageStream: Observable<Message<object>>) {}
/**
* A stream of all messages sent through the application. It does not contain type information for the
* other properties on the messages. You are encouraged to instead subscribe to an individual message
* through {@link messages$}.
*/
allMessages$ = this.messageStream;
/**
* Creates an observable stream filtered to just the command given via the {@link CommandDefinition} and typed
* to the generic contained in the CommandDefinition. Be careful using this method unless all your messages are being
* sent through `MessageSender.send`, if that isn't the case you should have lower confidence in the message
* payload being the expected type.
*
* @param commandDefinition The CommandDefinition containing the information about the message type you care about.
*/
messages$<T extends object>(commandDefinition: CommandDefinition<T>): Observable<T> {
return this.allMessages$.pipe(
filter((msg) => msg?.command === commandDefinition.command),
) as Observable<T>;
}
/**
* A helper property for returning a MessageListener that will never emit any messages and will immediately complete.
*/
static readonly EMPTY = new MessageListener(EMPTY);
}

View File

@@ -0,0 +1,62 @@
import { CommandDefinition } from "./types";
class MultiMessageSender implements MessageSender {
constructor(private readonly innerMessageSenders: MessageSender[]) {}
send<T extends object>(
commandDefinition: string | CommandDefinition<T>,
payload: object | T = {},
): void {
for (const messageSender of this.innerMessageSenders) {
messageSender.send(commandDefinition, payload);
}
}
}
export abstract class MessageSender {
/**
* A method for sending messages in a type safe manner. The passed in command definition
* will require you to provide a compatible type in the payload parameter.
*
* @example
* const MY_COMMAND = new CommandDefinition<{ test: number }>("myCommand");
*
* this.messageSender.send(MY_COMMAND, { test: 14 });
*
* @param commandDefinition
* @param payload
*/
abstract send<T extends object>(commandDefinition: CommandDefinition<T>, payload: T): void;
/**
* A legacy method for sending messages in a non-type safe way.
*
* @remarks Consider defining a {@link CommandDefinition} and passing that in for the first parameter to
* get compilation errors when defining an incompatible payload.
*
* @param command The string based command of your message.
* @param payload Extra contextual information regarding the message. Be aware that this payload may
* be serialized and lose all prototype information.
*/
abstract send(command: string, payload?: object): void;
/** Implementation of the other two overloads, read their docs instead. */
abstract send<T extends object>(
commandDefinition: CommandDefinition<T> | string,
payload: T | object,
): void;
/**
* A helper method for combine multiple {@link MessageSender}'s.
* @param messageSenders The message senders that should be combined.
* @returns A message sender that will relay all messages to the given message senders.
*/
static combine(...messageSenders: MessageSender[]) {
return new MultiMessageSender(messageSenders);
}
/**
* A helper property for creating a {@link MessageSender} that sends to nowhere.
*/
static readonly EMPTY: MessageSender = new MultiMessageSender([]);
}

View File

@@ -0,0 +1,65 @@
import { Subject } from "rxjs";
import { subscribeTo } from "../../../spec/observable-tracker";
import { SubjectMessageSender } from "./internal";
import { MessageSender } from "./message.sender";
import { Message, CommandDefinition } from "./types";
describe("SubjectMessageSender", () => {
const subject = new Subject<Message<{ test: number }>>();
const subjectObservable = subject.asObservable();
const sut: MessageSender = new SubjectMessageSender(subject);
describe("send", () => {
it("will send message with command from message definition", async () => {
const commandDefinition = new CommandDefinition<{ test: number }>("myCommand");
const tracker = subscribeTo(subjectObservable);
const pausePromise = tracker.pauseUntilReceived(1);
sut.send(commandDefinition, { test: 1 });
await pausePromise;
expect(tracker.emissions[0]).toEqual({ command: "myCommand", test: 1 });
});
it("will send message with command from normal string", async () => {
const tracker = subscribeTo(subjectObservable);
const pausePromise = tracker.pauseUntilReceived(1);
sut.send("myCommand", { test: 1 });
await pausePromise;
expect(tracker.emissions[0]).toEqual({ command: "myCommand", test: 1 });
});
it("will send message with object even if payload not given", async () => {
const tracker = subscribeTo(subjectObservable);
const pausePromise = tracker.pauseUntilReceived(1);
sut.send("myCommand");
await pausePromise;
expect(tracker.emissions[0]).toEqual({ command: "myCommand" });
});
it.each([null, undefined])(
"will send message with object even if payload is null-ish (%s)",
async (payloadValue) => {
const tracker = subscribeTo(subjectObservable);
const pausePromise = tracker.pauseUntilReceived(1);
sut.send("myCommand", payloadValue);
await pausePromise;
expect(tracker.emissions[0]).toEqual({ command: "myCommand" });
},
);
});
});

View File

@@ -0,0 +1,17 @@
import { Subject } from "rxjs";
import { getCommand } from "./internal";
import { MessageSender } from "./message.sender";
import { Message, CommandDefinition } from "./types";
export class SubjectMessageSender implements MessageSender {
constructor(private readonly messagesSubject: Subject<Message<object>>) {}
send<T extends object>(
commandDefinition: string | CommandDefinition<T>,
payload: object | T = {},
): void {
const command = getCommand(commandDefinition);
this.messagesSubject.next(Object.assign(payload ?? {}, { command: command }));
}
}

View File

@@ -0,0 +1,13 @@
declare const tag: unique symbol;
/**
* A class for defining information about a message, this is helpful
* alonside `MessageSender` and `MessageListener` for providing a type
* safe(-ish) way of sending and receiving messages.
*/
export class CommandDefinition<T extends object> {
[tag]: T;
constructor(readonly command: string) {}
}
export type Message<T extends object> = { command: string } & T;