UNPKG

@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..)

726 lines (715 loc) 25.1 kB
'use strict'; /** * **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 */ exports.StateMessageType = void 0; (function (StateMessageType) { StateMessageType[StateMessageType["CONNECTED"] = 0] = "CONNECTED"; StateMessageType[StateMessageType["UPDATED"] = 1] = "UPDATED"; StateMessageType[StateMessageType["DISCONNECTED"] = 2] = "DISCONNECTED"; StateMessageType[StateMessageType["HEALTH_BEACON"] = 3] = "HEALTH_BEACON"; })(exports.StateMessageType || (exports.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(exports.StateMessageType.UPDATED); // update Broadcasters state this.updateBroadcasterDescriptor(newState, true); // notify others this.bridge.setState(newState); }; this.sendAliveMessage = () => { this.bridge.setState(this.prepareStateMessage(exports.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 === exports.StateMessageType.CONNECTED) { if (data.state) { this._broadcasters = [ ...this._broadcasters, this.stateToDescriptor(data.state) ]; change = true; } if (!localUpdate) { this.bridge.setState(this.prepareStateMessage(exports.StateMessageType.UPDATED, data.from)); } } else if (data.type === exports.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 === exports.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 === exports.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(exports.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(exports.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() }); } } exports.Broadcaster = Broadcaster; exports.BroadcasterBridge = BroadcasterBridge; exports.BroadcasterContentTypeMismatchError = BroadcasterContentTypeMismatchError; exports.BroadcasterError = BroadcasterError; //# sourceMappingURL=index.js.map