UNPKG

pubnub

Version:

Publish & Subscribe Real-time Messaging with PubNub

566 lines (504 loc) 18.8 kB
import { EventDispatcher, Listener } from '../core/components/event-dispatcher'; import { SubscriptionOptions } from './interfaces/subscription-capable'; import { EventHandleCapable } from './interfaces/event-handle-capable'; import { EventEmitCapable } from './interfaces/event-emit-capable'; import type { PubNubCore as PubNub } from '../core/pubnub-common'; import * as Subscription from '../core/types/api/subscription'; import uuidGenerator from '../core/components/uuid'; /** * Subscription state object. * * State object used across multiple subscription object clones. * * @internal */ export abstract class SubscriptionBaseState { /** * PubNub instance which will perform subscribe / unsubscribe requests. */ client: PubNub<unknown, unknown>; /** * Whether a subscribable object subscribed or not. */ _isSubscribed: boolean = false; /** * User-provided a subscription input object. */ readonly subscriptionInput: Subscription.SubscriptionInput; /** * High-precision timetoken of the moment when subscription was created for entity. * * **Note:** This value can be set only if recently there were active subscription loop. */ readonly referenceTimetoken?: string; /** * Subscription time cursor. */ cursor?: Subscription.SubscriptionCursor; /** * SubscriptionCapable object configuration. */ options?: SubscriptionOptions; /** * The list of references to all {@link SubscriptionBase} clones created for this reference. */ clones: Record<string, SubscriptionBase> = {}; /** * List of a parent subscription state objects list. * * List is used to track usage of a subscription object in other subscription object sets. * * **Important:** Tracking is required to prevent unexpected unsubscriptions if an object still has a parent. */ parents: SubscriptionBaseState[] = []; /** * Unique subscription object identifier. */ protected _id: string = uuidGenerator.createUUID(); /** * Create a base subscription state object. * * @param client - PubNub client which will work with a subscription object. * @param subscriptionInput - User's input to be used with subscribe REST API. * @param options - Subscription behavior options. * @param referenceTimetoken - High-precision timetoken of the moment when subscription was created for entity. */ protected constructor( client: SubscriptionBaseState['client'], subscriptionInput: SubscriptionBaseState['subscriptionInput'], options: SubscriptionBaseState['options'], referenceTimetoken: SubscriptionBaseState['referenceTimetoken'], ) { this.referenceTimetoken = referenceTimetoken; this.subscriptionInput = subscriptionInput; this.options = options; this.client = client; } /** * Get unique subscription object identifier. * * @returns Unique subscription object identifier. */ get id() { return this._id; } /** * Check whether a subscription object is the last clone or not. * * @returns `true` if a subscription object is the last clone. */ get isLastClone() { return Object.keys(this.clones).length === 1; } /** * Get whether a subscribable object subscribed or not. * * **Warning:** This method shouldn't be overridden by {@link SubscriptionSet}. * * @returns Whether a subscribable object subscribed or not. */ get isSubscribed(): boolean { if (this._isSubscribed) return true; // Checking whether any of "parents" is subscribed. return this.parents.length > 0 && this.parents.some((state) => state.isSubscribed); } /** * Update active subscription state. * * @param value - New subscription state. */ set isSubscribed(value: boolean) { if (this.isSubscribed === value) return; this._isSubscribed = value; } /** * Add a parent subscription state object to mark the linkage. * * @param parent - Parent subscription state object. * * @internal */ addParentState(parent: SubscriptionBaseState) { if (!this.parents.includes(parent)) this.parents.push(parent); } /** * Remove a parent subscription state object. * * @param parent - Parent object for which linkage should be broken. * * @internal */ removeParentState(parent: SubscriptionBaseState) { const parentStateIndex = this.parents.indexOf(parent); if (parentStateIndex !== -1) this.parents.splice(parentStateIndex, 1); } /** * Store a clone of a {@link SubscriptionBase} instance with a given instance ID. * * @param id - The instance ID to associate with clone. * @param instance - Reference to the subscription instance to store as a clone. */ storeClone(id: string, instance: SubscriptionBase): void { if (!this.clones[id]) this.clones[id] = instance; } } /** * Base subscribe object. * * Implementation of base functionality used by {@link SubscriptionObject Subscription} and {@link SubscriptionSet}. */ export abstract class SubscriptionBase implements EventEmitCapable, EventHandleCapable { /** * Unique subscription object identifier. * * @internal */ id: string = uuidGenerator.createUUID(); /** * Subscription state. * * State which can be shared between multiple subscription object clones. * * @internal */ private readonly _state: SubscriptionBaseState; /** * Event emitter, which will notify listeners about updates received for channels / groups. * * @internal */ private eventDispatcher: EventDispatcher = new EventDispatcher(); /** * Create a subscription object from the state. * * @param state - Subscription state object. * @param subscriptionType - Actual subscription object type. * * @internal */ protected constructor( state: SubscriptionBaseState, protected readonly subscriptionType: 'Subscription' | 'SubscriptionSet' = 'Subscription', ) { this._state = state; } /** * Subscription state. * * @returns Subscription state object. * * @internal */ get state() { return this._state; } /** * Get a list of channels which is used for subscription. * * @returns List of channel names. */ get channels(): string[] { return this.state.subscriptionInput.channels.slice(0); } /** * Get a list of channel groups which is used for subscription. * * @returns List of channel group names. */ get channelGroups(): string[] { return this.state.subscriptionInput.channelGroups.slice(0); } // -------------------------------------------------------- // -------------------- Event emitter --------------------- // -------------------------------------------------------- // region Event emitter /** * Set a new message handler. * * @param listener - Listener function, which will be called each time when a new message * is received from the real-time network. */ set onMessage(listener: ((event: Subscription.Message) => void) | undefined) { this.eventDispatcher.onMessage = listener; } /** * Set a new presence events handler. * * @param listener - Listener function, which will be called each time when a new * presence event is received from the real-time network. */ set onPresence(listener: ((event: Subscription.Presence) => void) | undefined) { this.eventDispatcher.onPresence = listener; } /** * Set a new signal handler. * * @param listener - Listener function, which will be called each time when a new signal * is received from the real-time network. */ set onSignal(listener: ((event: Subscription.Signal) => void) | undefined) { this.eventDispatcher.onSignal = listener; } /** * Set a new app context event handler. * * @param listener - Listener function, which will be called each time when a new * app context event is received from the real-time network. */ set onObjects(listener: ((event: Subscription.AppContextObject) => void) | undefined) { this.eventDispatcher.onObjects = listener; } /** * Set a new message reaction event handler. * * @param listener - Listener function, which will be called each time when a * new message reaction event is received from the real-time network. */ set onMessageAction(listener: ((event: Subscription.MessageAction) => void) | undefined) { this.eventDispatcher.onMessageAction = listener; } /** * Set a new file handler. * * @param listener - Listener function, which will be called each time when a new file * is received from the real-time network. */ set onFile(listener: ((event: Subscription.File) => void) | undefined) { this.eventDispatcher.onFile = listener; } /** * Set events handler. * * @param listener - Events listener configuration object, which lets specify handlers for multiple * types of events. */ addListener(listener: Listener) { this.eventDispatcher.addListener(listener); } /** * Remove events handler. * * @param listener - Event listener configuration, which should be removed from the list of notified * listeners. **Important:** Should be the same object which has been passed to the {@link addListener}. */ removeListener(listener: Listener) { this.eventDispatcher.removeListener(listener); } /** * Remove all events listeners. */ removeAllListeners() { this.eventDispatcher.removeAllListeners(); } // endregion // -------------------------------------------------------- // -------------------- Event handler --------------------- // -------------------------------------------------------- // region Event handler /** * Subscription input associated with this subscribe capable object * * @param forUnsubscribe - Whether list subscription input created for unsubscription (means entity should be free). * * @returns Subscription input object. * * @internal */ abstract subscriptionInput(forUnsubscribe: boolean): Subscription.SubscriptionInput; /** * Dispatch received a real-time update. * * @param cursor - A time cursor for the next portion of events. * @param event - A real-time event from multiplexed subscription. * * @return `true` if receiver has consumed event. * * @internal */ handleEvent(cursor: Subscription.SubscriptionCursor, event: Subscription.SubscriptionResponse['messages'][0]) { if (!this.state.cursor || cursor > this.state.cursor) this.state.cursor = cursor; // Check whether this is an old `old` event and it should be ignored or not. if (this.state.referenceTimetoken && event.data.timetoken < this.state.referenceTimetoken) { this.state.client.logger.trace(this.subscriptionType, () => ({ messageType: 'text', message: `Event timetoken (${event.data.timetoken}) is older than reference timetoken (${ this.state.referenceTimetoken }) for ${this.id} subscription object. Ignoring event.`, })); return; } // Don't pass events which are filtered out by the user-provided function. if (this.state.options?.filter && !this.state.options.filter(event)) { this.state.client.logger.trace( this.subscriptionType, `Event filtered out by filter function for ${this.id} subscription object. Ignoring event.`, ); return; } const clones = Object.values(this.state.clones); if (clones.length > 0) { this.state.client.logger.trace( this.subscriptionType, `Notify ${this.id} subscription object clones (count: ${clones.length}) about received event.`, ); } clones.forEach((subscription) => subscription.eventDispatcher.handleEvent(event)); } // endregion /** * Make a bare copy of the subscription object. * * Copy won't have any type-specific listeners or added listener objects but will have the same internal state as * the source object. * * @returns Bare copy of a subscription object. */ abstract cloneEmpty(): SubscriptionBase; /** * Graceful object destruction. * * This is an instance destructor, which will properly deinitialize it: * - remove and unset all listeners, * - try to unsubscribe (if subscribed and there are no more instances interested in the same data stream). * * **Important:** {@link SubscriptionBase#dispose dispose} won't have any effect if a subscription object is part of * set. To gracefully dispose an object, it should be removed from the set using * {@link SubscriptionSet#removeSubscription removeSubscription} (in this case call of * {@link SubscriptionBase#dispose dispose} not required. * * **Note:** Disposed instance won't call the dispatcher to deliver updates to the listeners. */ dispose(): void { const keys = Object.keys(this.state.clones); if (keys.length > 1) { this.state.client.logger.debug(this.subscriptionType, `Remove subscription object clone on dispose: ${this.id}`); delete this.state.clones[this.id]; } else if (keys.length === 1 && this.state.clones[this.id]) { this.state.client.logger.debug(this.subscriptionType, `Unsubscribe subscription object on dispose: ${this.id}`); this.unsubscribe(); } } /** * Invalidate subscription object. * * Clean up resources used by a subscription object. * * **Note:** An invalidated instance won't call the dispatcher to deliver updates to the listeners. * * @param forDestroy - Whether subscription object invalidated as part of PubNub client destroy process or not. * * @internal */ invalidate(forDestroy: boolean = false) { this.state._isSubscribed = false; if (forDestroy) { delete this.state.clones[this.id]; if (Object.keys(this.state.clones).length === 0) { this.state.client.logger.trace(this.subscriptionType, 'Last clone removed. Reset shared subscription state.'); this.state.subscriptionInput.removeAll(); this.state.parents = []; } } } /** * Start receiving real-time updates. * * @param parameters - Additional subscription configuration options which should be used * for request. */ subscribe(parameters?: { timetoken?: string }) { if (this.state.isSubscribed) { this.state.client.logger.trace(this.subscriptionType, 'Already subscribed. Ignoring subscribe request.'); return; } this.state.client.logger.debug(this.subscriptionType, () => { if (!parameters) return { messageType: 'text', message: 'Subscribe' }; return { messageType: 'object', message: parameters, details: 'Subscribe with parameters:' }; }); this.state.isSubscribed = true; this.updateSubscription({ subscribing: true, timetoken: parameters?.timetoken }); } /** * Stop real-time events processing. * * **Important:** {@link SubscriptionBase#unsubscribe unsubscribe} won't have any effect if a subscription object * is part of active (subscribed) set. To unsubscribe an object, it should be removed from the set using * {@link SubscriptionSet#removeSubscription removeSubscription} (in this case call of * {@link SubscriptionBase#unsubscribe unsubscribe} not required. * * **Note:** Unsubscribed instance won't call the dispatcher to deliver updates to the listeners. */ unsubscribe() { // Check whether an instance-level subscription flag not set or parent has active subscription. if (!this.state._isSubscribed || this.state.isSubscribed) { // Warn if a user tries to unsubscribe using specific subscription which subscribed as part of a subscription set. if (!this.state._isSubscribed && this.state.parents.length > 0 && this.state.isSubscribed) { this.state.client.logger.warn(this.subscriptionType, () => ({ messageType: 'object', details: 'Subscription is subscribed as part of a subscription set. Remove from active sets to unsubscribe:', message: this.state.parents.filter((subscriptionSet) => subscriptionSet.isSubscribed), })); return; } else if (!this.state._isSubscribed) { this.state.client.logger.trace(this.subscriptionType, 'Not subscribed. Ignoring unsubscribe request.'); return; } } this.state.client.logger.debug(this.subscriptionType, 'Unsubscribe'); this.state.isSubscribed = false; delete this.state.cursor; this.updateSubscription({ subscribing: false }); } /** * Update channels and groups used by subscription loop. * * @param parameters - Subscription loop update parameters. * @param parameters.subscribing - Whether subscription updates as part of subscription or unsubscription. * @param [parameters.timetoken] - Subscription catch-up timetoken. * @param [parameters.subscriptions] - List of subscriptions which should be used to modify a subscription loop * object. * * @internal */ updateSubscription(parameters: { subscribing: boolean; timetoken?: string; subscriptions?: EventHandleCapable[] }) { if (parameters?.timetoken) { if (this.state.cursor?.timetoken && this.state.cursor?.timetoken !== '0') { if (parameters.timetoken !== '0' && parameters.timetoken > this.state.cursor.timetoken) this.state.cursor.timetoken = parameters.timetoken; } else this.state.cursor = { timetoken: parameters.timetoken }; } const subscriptions = parameters.subscriptions && parameters.subscriptions.length > 0 ? parameters.subscriptions : undefined; if (parameters.subscribing) { this.register({ ...(parameters.timetoken ? { cursor: this.state.cursor } : {}), ...(subscriptions ? { subscriptions } : {}), }); } else this.unregister(subscriptions); } /** * Register a subscription object for real-time events' retrieval. * * @param parameters - Object registration parameters. * @param [parameters.cursor] - Subscription real-time events catch-up cursor. * @param [parameters.subscriptions] - List of subscription objects which should be registered (in case of partial * modification). * * @internal */ protected abstract register(parameters: { cursor?: Subscription.SubscriptionCursor; subscriptions?: EventHandleCapable[]; }): void; /** * Unregister subscription object from real-time events' retrieval. * * @param [subscriptions] - List of subscription objects which should be unregistered (in case of partial * modification). * * **Note:** Unregistered instance won't call the dispatcher to deliver updates to the listeners. * * @internal */ protected abstract unregister(subscriptions?: EventHandleCapable[]): void; }