@broadcaster/core
Version:
Cross window serverless messaging system based on BroadcastChannel API. Allows to send messages and keep track about instances between browsing contexts (tabs, windows, workers, etc..)
721 lines (711 loc) • 24.9 kB
JavaScript
/**
* **Broadcaster Subscription**
*
* Manages subscriptions and life cycle of all subscribers.
*
* @private
* @param CallbackArguments type definition of a subscriber callback arguments
*/
class BroadcasterSubscription {
constructor(shareReplay) {
/**
* List of all currently active subscribers
*/
this.subscribers = [];
/**
* If true, it will resend the latest message to a new subscriber
*/
this.shareReplay = false;
/**
* Close channel and notify subscribers
*/
this.close = () => {
this.subscribers.forEach(({ onComplete }) => onComplete === null || onComplete === void 0 ? void 0 : onComplete());
this.subscribers = [];
};
/**
* Push a payload to all subscribers.
*
* @param args subscriber callback attributes
*/
this.next = (...args) => {
// update buffer if needed
if (this.shareReplay) {
this.buffer = args;
}
this.subscribers.forEach(({ next }) => next(...args));
};
/**
* Subscribes to a channel
*
* @param callback method called every time new payload occurs.
* @param complete method called, when channel is closed
* @returns subscription info object with teardown function
*/
this.subscribe = (callback, complete) => {
this.subscribers = [...this.subscribers, {
next: callback,
onComplete: complete,
}];
// send the latest value from buffer if exits
if (this.buffer) {
callback(...this.buffer);
}
return {
unsubscribe: () => this.unsubscribe(callback),
};
};
/**
* Unsubscribes from a channel
*
* @param callback
*/
this.unsubscribe = (callback) => {
this.subscribers = this.subscribers.filter(({ next }) => next !== callback);
};
this.shareReplay = !!shareReplay;
}
}
/**
* Allowed state message types
*
* @private
*/
var StateMessageType;
(function (StateMessageType) {
StateMessageType[StateMessageType["CONNECTED"] = 0] = "CONNECTED";
StateMessageType[StateMessageType["UPDATED"] = 1] = "UPDATED";
StateMessageType[StateMessageType["DISCONNECTED"] = 2] = "DISCONNECTED";
StateMessageType[StateMessageType["HEALTH_BEACON"] = 3] = "HEALTH_BEACON";
})(StateMessageType || (StateMessageType = {}));
/**
* **Broadcaster Bridge**
*
* Abstract class responsible for remote communication via serialized messages.
* ____
*
* @private
*/
class BroadcasterBridge {
constructor() {
/**
* Message, state and error subscriptions.
*/
this.subscriptions = {};
/**
* Sends new error to a broadcaster via error subscription
*
* @param error BroadcasterError
*/
this.pushErrorMessage = (error) => {
var _a, _b;
(_b = (_a = this.subscriptions).onError) === null || _b === void 0 ? void 0 : _b.call(_a, error);
};
/**
* Sends new message to a broadcaster via message subscription
*
* @param message
*/
this.pushMessage = (message) => {
var _a, _b;
(_b = (_a = this.subscriptions).messages) === null || _b === void 0 ? void 0 : _b.call(_a, message);
};
/**
* Sends new state to a broadcaster via state subscription
*
* @param state
*/
this.pushState = (state) => {
var _a, _b;
(_b = (_a = this.subscriptions).state) === null || _b === void 0 ? void 0 : _b.call(_a, state);
};
}
/**
* Disconnects from a stream and removes all subscriptions
*/
destroy() {
this.subscriptions = {};
this.disconnect();
}
/**
* Subscribes to a message, state and error stream.
*
* @param subscriptions catalogue of all subscriptions
*/
subscribe(subscriptions) {
this.subscriptions = subscriptions;
}
}
/**
* **BroadcasterError**
*
* Represents expected errors from Broadcaster itself or from a Bridge.
*
* @public
*/
class BroadcasterError extends Error {
constructor(errorType, message, stack) {
super(message);
this.errorType = errorType;
if (stack) {
this.stack = `[BROADCASTER_ERROR:${errorType}]: ${message} \n ${stack}`;
}
}
/**
* Validates whether unknown error is instance of BroadcasterError and has same errorType.
* @param err
* @returns
*/
isSameErrorTypeAs(err) {
return err instanceof BroadcasterError && err.errorType === this.errorType;
}
}
/**
* Bridge received unexpected payload as a response.
*/
class BroadcasterContentTypeMismatchError extends BroadcasterError {
constructor(payload) {
super("CONTENT_TYPE_MISMATCH", "Broadcaster Bridge received invalid message.");
this.payload = payload;
}
}
const UNKNOWN_ERROR = "Unknown error occurred in Broadcaster instance. This means, that something went wrong without proper output.";
/**
* Transforms unknown error into BroadcasterError instance
*
* @private
* @param errorType BroadcasterError error type string
* @param error unknown error
*/
const transformIntoBroadcasterError = (errorType, error) => {
try {
if (!error) {
throw null;
}
else if (typeof error === "string" || typeof error === "number") {
return new BroadcasterError(errorType, `${error}`);
}
else if (typeof error === "object") {
const errorObject = error;
if (errorObject["message"]) {
return new BroadcasterError(errorType, errorObject["message"], errorObject["stack"]);
}
else if (error["toString"]) {
return new BroadcasterError(errorType, error["toString"]());
}
else {
throw error;
}
}
throw error;
}
catch (_) {
return new BroadcasterError(errorType, UNKNOWN_ERROR);
}
};
var MessageTypes;
(function (MessageTypes) {
MessageTypes[MessageTypes["MESSAGE"] = 0] = "MESSAGE";
MessageTypes[MessageTypes["STATE"] = 1] = "STATE";
})(MessageTypes || (MessageTypes = {}));
/**
* **BroadcastChannel Bridge: Serverless browsing context bridge**
*
* Communicates with other Broadcaster instances via
* [BroadcastChannel API](https://developer.mozilla.org/en-US/docs/Web/API/Broadcast_Channel_API).
*
* BroadcastChannel API allows to communicate with different browsing contexts inside a browser
* (tabs, window, workers, etc..) without need of a server.
*
* ____
*
* Minimal requirements:
*
* | Chromium | Edge | Safari | Firefox |
* |:--------:|:----:|:------:|:-------:|
* | 54 | 79 | 15.4 | 38 |
*/
class BroadcastChannelBridge extends BroadcasterBridge {
constructor() {
super(...arguments);
/**
* Sort incoming messages and push it to corresponding stream
*
* @param event BroadcasterChannel Event
*/
this.extractMessageAndPush = (event) => {
const data = event.data;
if (this.isPublicMessage(data)) {
this.pushMessage(data.payload);
}
else if (this.isStateChangeMessage(data)) {
this.pushState(data.payload);
}
else {
this.pushErrorMessage(new BroadcasterContentTypeMismatchError(data));
}
};
/**
* Wraps action to try catch, and in case of an error,
* it will transform it to BroadcastError and push it to subscribers
*
* @param action callback action, which will be called inside try scope
*/
this.guardPostMessageErrors = (action) => {
try {
action();
}
catch (error) {
this.pushErrorMessage(transformIntoBroadcasterError("BROADCAST_CHANNEL_POST_MESSAGE_ERROR", error));
}
};
}
/**
* Connects to a BroadcasterChannel stream
*
* @param channelName communication identifier
*/
connect(channelName) {
this.channelName = channelName;
this.messageChannel = new BroadcastChannel(this.channelName);
// subscribe to BroadcastChannel API
this.messageChannel.addEventListener("message", this.extractMessageAndPush);
this.messageChannel.addEventListener("messageerror", (event) => {
this.pushErrorMessage(new BroadcasterError("BROADCAST_CHANNEL_DESERIALIZE_ERROR", `Broadcast channel cannot deserialize incoming message: \n
Data Type: ${typeof event.data}
`));
});
}
disconnect() {
// Unsubscribe from BroadcasterChannel API
this.messageChannel.removeEventListener("message", this.extractMessageAndPush);
this.messageChannel.close();
}
/**
* Incoming message is a valid BroadcastChannelBridge message
*
* @param data
* @returns
*/
isMessage(data) {
// data is an object
if (typeof data === "object" &&
!Array.isArray(data) &&
data !== null) {
// assert message is a valid SerializedMessage
const message = data;
return (message.type !== undefined);
}
return false;
}
/**
* A BroadcasterChannel message is a public message
*
* @param data
* @returns
*/
isPublicMessage(data) {
return this.isMessage(data) && data.type === MessageTypes.MESSAGE;
}
/**
* A BroadcasterChannel message is a state change message
*
* @param data
* @returns
*/
isStateChangeMessage(data) {
return this.isMessage(data) && data.type === MessageTypes.STATE;
}
/**
* Sends public message to all BroadcasterChannelBridge instances
*
* @param payload
*/
postMessage(payload) {
const message = {
payload,
type: MessageTypes.MESSAGE
};
this.guardPostMessageErrors(() => this.messageChannel.postMessage(message));
}
/**
* Sends new state of this instance to all BroadcasterChannelBridge instances
*
* @param payload
*/
setState(payload) {
const message = {
payload,
type: MessageTypes.STATE
};
this.guardPostMessageErrors(() => this.messageChannel.postMessage(message));
}
}
/**
* Generates pseudo-random IDs
*
* @returns
*/
const generateId = () => {
const timestamp = Date.now().toString(16);
const timestampDerivedRandomNumber = (Date.now() * Math.random()).toString(16).replace(".", "").slice(2);
const salt = Math.random().toString(16).slice(2);
return `${timestamp}-${timestampDerivedRandomNumber}-${salt}`;
};
/**
* @units ms
* @constant
*/
const DEFAULT_BEACON_TIMER = 500;
/**
* @units ms
* @constants
*/
const DEFAULT_GARBAGE_COLLECTOR_TIMER = 500;
/**
* @units ms
* @constants
*/
const DEFAULT_REMOVE_AFTER_TIME = 1500;
/**
* **Broadcaster: Cross Window Serverless Messaging System**
*
* Enables seamless communication across various browsing contexts,
* including tabs, windows, and workers. This system not only preserves
* the state of each instance but also shares the current state with
* remote counterparts.
*
* @public
* @typeParam Payload message shape
* @typeParam Metadata metadata object shape
*/
class Broadcaster {
get broadcasters() {
return this._broadcasters.filter(broadcasters => !broadcasters.inactive);
}
constructor(settings) {
this.settings = settings;
/**
* List of states of all active broadcasters
*/
this._broadcasters = [];
/**
* When Broadcaster instance was created
*/
this.createdAt = Date.now();
/**
* Indicates whether destroy method was called or not
*/
this.closed = false;
/**
* Random ID of an instance
*/
this.id = generateId();
this.intervals = [];
this.messageSubscriptionManager = new BroadcasterSubscription();
this.broadcastersSubscriptionManager = new BroadcasterSubscription(true);
this.broadcastersErrorManager = new BroadcasterSubscription();
/**
* Marks as disabled all broadcasters which are inactive for specific amount of time.
* Removes those who cross removeAfter threshold.
*/
this.collectGarbage = () => {
if (this.settings.disableGarbageCollector) {
return;
}
let changed = false;
const currentTime = Date.now();
const { garbageCollectorThresholdTimer = DEFAULT_REMOVE_AFTER_TIME, } = this.settings;
this._broadcasters = this._broadcasters.map((broadcaster) => {
if (broadcaster.inactive ||
broadcaster.id === this.id ||
(currentTime - broadcaster.lastUpdate) <= garbageCollectorThresholdTimer) {
return broadcaster;
}
else {
changed = true;
return Object.assign(Object.assign({}, broadcaster), { inactive: true });
}
});
if (changed) {
this.broadcastersUpdated();
}
};
/**
* Find Broadcaster instance based on its ID.
*
* @param ownerId Broadcaster instance ID
* @returns Broadcaster instance which id matches ownerId attribute
*/
this.findOwner = (ownerId) => {
return this._broadcasters.find((instance) => instance.id === ownerId) || null;
};
/**
* Send a message to all instances of Broadcaster across browsing context.
*
* @param payload data payload
* _____
* @example```ts
*
* const broadcaster = new Broadcaster<string>({
* channel: "CHANNEL",
* });
*
* broadcaster.postMessage("Hello World");
* ```
*
* @param payload message payload
* @param to a id(s) of receivers
*/
this.postMessage = (payload, to) => {
var _a;
if (!this.isBroadcasterActive("postMessage")) {
return;
}
const applyMiddleware = (_a = this.settings.middlewares) === null || _a === void 0 ? void 0 : _a.before;
this.bridge.postMessage({
from: this.id,
payload: applyMiddleware ? applyMiddleware(payload) : payload,
to,
});
};
/**
* Push new message to all subscribers
*
* @param data Broadcaster message
*/
this.pushMessage = (data) => {
var _a;
const { payload, from, to } = data;
// normalize data
const receiver = to ? Array.isArray(to) ? to : [to] : undefined;
// scratch received message when it is not issued to this broadcaster
if (receiver && !receiver.includes(this.id)) {
return;
}
// filter all messages, where message owner is current instance
if (from !== this.id) {
const applyMiddleware = (_a = this.settings.middlewares) === null || _a === void 0 ? void 0 : _a.after;
this.messageSubscriptionManager.next(Object.assign(Object.assign({}, data), {
// change message if middleware exist
payload: applyMiddleware ?
applyMiddleware(payload) :
payload }));
}
};
/**
* Updates Broadcaster instance metadata and notify other instances about the change.
* ____
*
* @example```ts
* // override metadata
* broadcasterInstance.updateMetadata({name: "John"});
* // update metadata
* broadcasterInstance.updateMetadata((current) => ({...current, lastName: "Doe"}));
*
* // all broadcasters will receive new state with updated metadata
* ```
* @param newMetadata data to override or a method with current state as an attribute
*/
this.updateMetadata = (newMetadata) => {
if (typeof newMetadata === "function") {
this.metadata = newMetadata(this.metadata);
}
else {
this.metadata = newMetadata;
}
const newState = this.prepareStateMessage(StateMessageType.UPDATED);
// update Broadcasters state
this.updateBroadcasterDescriptor(newState, true);
// notify others
this.bridge.setState(newState);
};
this.sendAliveMessage = () => {
this.bridge.setState(this.prepareStateMessage(StateMessageType.HEALTH_BEACON, null, true));
};
/**
* Subscribes to selected channel.
* ____
* @example```ts
* const callback = (message: string) => console.log(message);
*
* const subscription = broadcasterInstance.subscribe.message(callback);
*
* //...later
* subscription.unsubscribe();
* // or
* broadcaster.unsubscribe.message(callback);
* ```
*/
this.subscribe = {
message: this.messageSubscriptionManager.subscribe,
broadcasters: this.broadcastersSubscriptionManager.subscribe,
errors: this.broadcastersErrorManager.subscribe,
};
/**
* Unsubscribes from the selected channel.
* ____
* @example```ts
* const callback = (message: string) => console.log(message);
*
* broadcaster.subscribe.message(callback);
* broadcaster.subscribe.broadcasters(callback);
*
* //...later
* broadcaster.unsubscribe.message(callback);
* broadcaster.unsubscribe.broadcasters(callback);
* ```
*/
this.unsubscribe = {
message: this.messageSubscriptionManager.unsubscribe,
broadcasters: this.broadcastersSubscriptionManager.unsubscribe,
errors: this.broadcastersErrorManager.unsubscribe,
};
/**
* Updates broadcasters based on remote message
*
* @param data remote state message
* @param localUpdate if true, it will always update broadcasters list, but never take any other action
*/
this.updateBroadcasterDescriptor = (data, localUpdate = false) => {
if (!localUpdate && (data.to && data.to !== this.id || data.from === this.id)) {
return;
}
let change = false;
if (data.type === StateMessageType.CONNECTED) {
if (data.state) {
this._broadcasters = [
...this._broadcasters,
this.stateToDescriptor(data.state)
];
change = true;
}
if (!localUpdate) {
this.bridge.setState(this.prepareStateMessage(StateMessageType.UPDATED, data.from));
}
}
else if (data.type === StateMessageType.UPDATED) {
if (data.state) {
this._broadcasters = [
...this._broadcasters.filter((broadcaster) => broadcaster.id !== data.from),
this.stateToDescriptor(data.state),
];
change = true;
}
}
else if (data.type === StateMessageType.DISCONNECTED) {
this._broadcasters = this._broadcasters.filter((broadcaster) => broadcaster.id !== data.from);
change = true;
}
// periodical message sent by Broadcaster, indicates healthy broadcaster instance
else if (data.type === StateMessageType.HEALTH_BEACON) {
this._broadcasters = this._broadcasters.map((broadcaster) => {
if (broadcaster.id === data.from) {
if (broadcaster.inactive === true) {
change = true;
}
return Object.assign(Object.assign({}, broadcaster), { lastUpdate: Date.now(), inactive: false });
}
return broadcaster;
});
}
if (change) {
this.broadcastersUpdated();
}
};
const { bridge, channel, metadata } = this.settings;
this.channel = channel;
this.bridge = bridge || new BroadcastChannelBridge();
this.metadata = metadata;
this.bridge.subscribe({
messages: this.pushMessage,
state: this.updateBroadcasterDescriptor,
onError: this.broadcastersErrorManager.next,
});
this.bridge.connect(this.channel);
this.init();
}
/**
* Propagates changes in metadata descriptors to all subscribers
*/
broadcastersUpdated() {
this.broadcastersSubscriptionManager.next([...this._broadcasters.filter(b => !b.inactive)]);
}
/**
* Cancel a connection to a channel and notify other Broadcasters about it.
*
* @param silent skips multi call detection
*/
close(silent) {
var _a, _b;
if (!silent && !this.isBroadcasterActive("close")) {
return;
}
(_b = (_a = this.settings.on) === null || _a === void 0 ? void 0 : _a.close) === null || _b === void 0 ? void 0 : _b.call(_a, this);
this.bridge.setState(this.prepareStateMessage(StateMessageType.DISCONNECTED, null, false));
this.intervals.map(interval => clearInterval(interval));
this.messageSubscriptionManager.close();
this.broadcastersErrorManager.close();
this.broadcastersSubscriptionManager.close();
this.bridge.destroy();
this.closed = true;
}
/**
* Detects whether Broadcaster method can be triggered or not
*
* @param action
*/
isBroadcasterActive(methodName) {
if (this.closed) {
/**
* We want to notify a developer about this problem, instead of throwing an error and killing a process,
* because this error does not trigger any side effects.
*/
console.error(new Error(`Broadcasters.${methodName} was called, but Broadcaster has already been disconnected. ` +
"This indicates possible memory leak, because program is trying to manipulate with a channel " +
"after calling Broadcaster.close method."));
return false;
}
return true;
}
init() {
var _a, _b;
(_b = (_a = this.settings.on) === null || _a === void 0 ? void 0 : _a.init) === null || _b === void 0 ? void 0 : _b.call(_a, this);
const message = this.prepareStateMessage(StateMessageType.CONNECTED);
this.updateBroadcasterDescriptor(message, true);
this.bridge.setState(message);
this.intervals = [
...this.intervals,
setInterval(this.sendAliveMessage, this.settings.healthBeaconTimer || DEFAULT_BEACON_TIMER),
setInterval(this.collectGarbage, this.settings.garbageCollectorTimer || DEFAULT_GARBAGE_COLLECTOR_TIMER),
];
}
get isClosed() {
return this.closed;
}
/**
* Creates a new state message
*
* @param type message type
* @param to message receiver id
* @param withoutState
* @returns
*/
prepareStateMessage(type, to, withoutState) {
return {
type: type,
from: this.id,
to: to || undefined,
state: !withoutState ? {
createdAt: this.createdAt,
id: this.id,
metadata: this.metadata,
} : undefined,
};
}
stateToDescriptor(state) {
return Object.assign(Object.assign({}, state), { lastUpdate: Date.now() });
}
}
export { Broadcaster, BroadcasterBridge, BroadcasterContentTypeMismatchError, BroadcasterError, StateMessageType };
//# sourceMappingURL=index.js.map