UNPKG

pubnub

Version:

Publish & Subscribe Real-time Messaging with PubNub

497 lines (432 loc) 18 kB
import { SubscriptionCapable, SubscriptionOptions } from './interfaces/subscription-capable'; import { SubscriptionBase, SubscriptionBaseState } from './subscription-base'; import { EventHandleCapable } from './interfaces/event-handle-capable'; import { Subscription as SubscriptionObject } from './subscription'; import type { PubNubCore as PubNub } from '../core/pubnub-common'; import { EntityInterface } from './interfaces/entity-interface'; import * as Subscription from '../core/types/api/subscription'; /** * {@link SubscriptionSet} state object. * * State object used across multiple {@link SubscriptionSet} object clones. * * @internal */ class SubscriptionSetState extends SubscriptionBaseState { /** * Subscription-capable entities' subscription objects. */ readonly subscriptions: SubscriptionObject[]; /** * Create a subscription state object. * * @param parameters - State configuration options * @param parameters.client - PubNub client which will work with a subscription object. * @param parameters.subscriptions - List of subscriptions managed by set. * @param [parameters.options] - Subscription behavior options. */ constructor(parameters: { client: PubNub<unknown, unknown>; subscriptions: SubscriptionObject[]; options?: SubscriptionOptions; }) { const subscriptionInput = new Subscription.SubscriptionInput({}); parameters.subscriptions.forEach((subscription) => subscriptionInput.add(subscription.state.subscriptionInput)); super(parameters.client, subscriptionInput, parameters.options, parameters.client.subscriptionTimetoken); this.subscriptions = parameters.subscriptions; } /** * Add a single subscription object to the set. * * @param subscription - Another entity's subscription object, which should be added. */ addSubscription(subscription: SubscriptionObject) { if (this.subscriptions.includes(subscription)) return; subscription.state.addParentState(this); this.subscriptions.push(subscription); // Update subscription input. this.subscriptionInput.add(subscription.state.subscriptionInput); } /** * Remove a single subscription object from the set. * * @param subscription - Another entity's subscription object, which should be removed. * @param clone - Whether a target subscription is a clone. */ removeSubscription(subscription: SubscriptionObject, clone: boolean) { const index = this.subscriptions.indexOf(subscription); if (index === -1) return; this.subscriptions.splice(index, 1); if (!clone) subscription.state.removeParentState(this); // Update subscription input. this.subscriptionInput.remove(subscription.state.subscriptionInput); } /** * Remove any registered subscription object. */ removeAllSubscriptions() { this.subscriptions.forEach((subscription) => subscription.state.removeParentState(this)); this.subscriptions.splice(0, this.subscriptions.length); this.subscriptionInput.removeAll(); } } /** * Multiple entities subscription set object which can be used to receive and handle real-time * updates. * * Subscription set object represents a collection of per-entity subscription objects and allows * processing them at once for subscription loop and events handling. */ export class SubscriptionSet extends SubscriptionBase { /** * Create entities' subscription set object. * * Subscription set object represents a collection of per-entity subscription objects and allows * processing them at once for subscription loop and events handling. * * @param parameters - Subscription set object configuration. * * @returns Ready to use entities' subscription set object. * * @internal */ constructor( parameters: | { client: PubNub<unknown, unknown>; subscriptions?: SubscriptionObject[]; entities?: (EntityInterface & SubscriptionCapable)[]; options?: SubscriptionOptions; } | { state: SubscriptionSetState }, ) { let state: SubscriptionSetState; if ('client' in parameters) { let subscriptions: SubscriptionObject[] = []; if (!parameters.subscriptions && parameters.entities) { parameters.entities.forEach((entity) => subscriptions.push(entity.subscription(parameters.options) as SubscriptionObject), ); } else if (parameters.subscriptions) subscriptions = parameters.subscriptions; state = new SubscriptionSetState({ client: parameters.client, subscriptions, options: parameters.options }); subscriptions.forEach((subscription) => subscription.state.addParentState(state)); state.client.logger.debug('SubscriptionSet', () => ({ messageType: 'object', details: 'Create subscription set with parameters:', message: { subscriptions: state.subscriptions, ...(parameters.options ? parameters.options : {}) }, })); } else { state = parameters.state; state.client.logger.debug('SubscriptionSet', 'Create subscription set clone'); } super(state, 'SubscriptionSet'); this.state.storeClone(this.id, this); // Update a parent sets list for original set subscriptions. state.subscriptions.forEach((subscription) => subscription.addParentSet(this)); } /** * Get a {@link SubscriptionSet} object state. * * @returns: {@link SubscriptionSet} object state. * * @internal */ override get state(): SubscriptionSetState { return super.state as SubscriptionSetState; } /** * Get a list of entities' subscription objects registered in a subscription set. * * @returns Entities' subscription objects list. */ get subscriptions(): SubscriptionObject[] { return this.state.subscriptions.slice(0); } // -------------------------------------------------------- // -------------------- Event handler --------------------- // -------------------------------------------------------- // region Event handler /** * 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 */ override handleEvent( cursor: Subscription.SubscriptionCursor, event: Subscription.SubscriptionResponse['messages'][0], ): void { // Check whether an event is not designated for this subscription set. if (!this.state.subscriptionInput.contains(event.data.subscription ?? event.data.channel)) return; // Check whether `event` can be processed or not. if (!this.state._isSubscribed) { this.state.client.logger.trace( this.subscriptionType, `Subscription set ${this.id} is not subscribed. Ignoring event.`, ); return; } super.handleEvent(cursor, event); if (this.state.subscriptions.length > 0) { this.state.client.logger.trace( this.subscriptionType, `Notify ${this.id} subscription set subscriptions (count: ${ this.state.subscriptions.length }) about received event.`, ); } this.state.subscriptions.forEach((subscription) => subscription.handleEvent(cursor, event)); } // endregion /** User-provided subscription input associated with this {@link SubscriptionSet} object. * * @param forUnsubscribe - Whether list subscription input created for unsubscription (means entity should be free). * * @returns Subscription input object. * * @internal */ override subscriptionInput(forUnsubscribe: boolean = false) { let subscriptionInput = this.state.subscriptionInput; this.state.subscriptions.forEach((subscription) => { if (forUnsubscribe && subscription.state.entity.subscriptionsCount > 0) subscriptionInput = subscriptionInput.without(subscription.state.subscriptionInput); }); return subscriptionInput; } /** * Make a bare copy of the {@link SubscriptionSet} 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 {@link SubscriptionSet} object. */ override cloneEmpty(): SubscriptionSet { return new SubscriptionSet({ state: this.state }); } /** * Graceful {@link SubscriptionSet} 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). * * **Note:** Disposed instance won't call the dispatcher to deliver updates to the listeners. */ override dispose(): void { const isLastClone = this.state.isLastClone; this.state.subscriptions.forEach((subscription) => { subscription.removeParentSet(this); if (isLastClone) subscription.state.removeParentState(this.state); }); super.dispose(); } /** * Invalidate {@link SubscriptionSet} object. * * Clean up resources used by a subscription object. All {@link SubscriptionObject subscription} objects will be * removed. * * **Important:** This method is used only when a global subscription set is used (backward compatibility). * * **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 */ override invalidate(forDestroy: boolean = false) { const subscriptions = forDestroy ? this.state.subscriptions.slice(0) : this.state.subscriptions; subscriptions.forEach((subscription) => { if (forDestroy) { subscription.state.entity.decreaseSubscriptionCount(this.state.id); subscription.removeParentSet(this); } subscription.invalidate(forDestroy); }); if (forDestroy) this.state.removeAllSubscriptions(); super.invalidate(); } /** * Add an entity's subscription to the subscription set. * * **Important:** Changes will be effective immediately if {@link SubscriptionSet} already subscribed. * * @param subscription - Another entity's subscription object, which should be added. */ addSubscription(subscription: SubscriptionObject) { this.addSubscriptions([subscription]); } /** * Add an entity's subscriptions to the subscription set. * * **Important:** Changes will be effective immediately if {@link SubscriptionSet} already subscribed. * * @param subscriptions - List of entity's subscription objects, which should be added. */ addSubscriptions(subscriptions: SubscriptionObject[]) { const inactiveSubscriptions: SubscriptionObject[] = []; const activeSubscriptions: SubscriptionObject[] = []; this.state.client.logger.debug(this.subscriptionType, () => { const ignoredSubscriptions: SubscriptionObject[] = []; const subscriptionsToAdd: SubscriptionObject[] = []; subscriptions.forEach((subscription) => { if (!this.state.subscriptions.includes(subscription)) subscriptionsToAdd.push(subscription); else ignoredSubscriptions.push(subscription); }); return { messageType: 'object', details: `Add subscriptions to ${this.id} (subscriptions count: ${ this.state.subscriptions.length + subscriptionsToAdd.length }):`, message: { addedSubscriptions: subscriptionsToAdd, ignoredSubscriptions }, }; }); subscriptions .filter((subscription) => !this.state.subscriptions.includes(subscription)) .forEach((subscription) => { if (subscription.state.isSubscribed) activeSubscriptions.push(subscription); else inactiveSubscriptions.push(subscription); subscription.addParentSet(this); this.state.addSubscription(subscription); }); // Check whether there are any subscriptions for which the subscription loop should be changed or not. if ((activeSubscriptions.length === 0 && inactiveSubscriptions.length === 0) || !this.state.isSubscribed) return; activeSubscriptions.forEach(({ state }) => state.entity.increaseSubscriptionCount(this.state.id)); if (inactiveSubscriptions.length > 0) this.updateSubscription({ subscribing: true, subscriptions: inactiveSubscriptions }); } /** * Remove an entity's subscription object from the set. * * **Important:** Changes will be effective immediately if {@link SubscriptionSet} already subscribed. * * @param subscription - Another entity's subscription object, which should be removed. */ removeSubscription(subscription: SubscriptionObject) { this.removeSubscriptions([subscription]); } /** * Remove an entity's subscription objects from the set. * * **Important:** Changes will be effective immediately if {@link SubscriptionSet} already subscribed. * * @param subscriptions - List entity's subscription objects, which should be removed. */ removeSubscriptions(subscriptions: SubscriptionObject[]) { const activeSubscriptions: SubscriptionObject[] = []; this.state.client.logger.debug(this.subscriptionType, () => { const ignoredSubscriptions: SubscriptionObject[] = []; const subscriptionsToRemove: SubscriptionObject[] = []; subscriptions.forEach((subscription) => { if (this.state.subscriptions.includes(subscription)) subscriptionsToRemove.push(subscription); else ignoredSubscriptions.push(subscription); }); return { messageType: 'object', details: `Remove subscriptions from ${this.id} (subscriptions count: ${this.state.subscriptions.length}):`, message: { removedSubscriptions: subscriptionsToRemove, ignoredSubscriptions }, }; }); subscriptions .filter((subscription) => this.state.subscriptions.includes(subscription)) .forEach((subscription) => { if (subscription.state.isSubscribed) activeSubscriptions.push(subscription); subscription.removeParentSet(this); this.state.removeSubscription(subscription, subscription.parentSetsCount > 1); }); // Check whether there are any subscriptions for which the subscription loop should be changed or not. if (activeSubscriptions.length === 0 || !this.state.isSubscribed) return; this.updateSubscription({ subscribing: false, subscriptions: activeSubscriptions }); } /** * Merge with another {@link SubscriptionSet} object. * * **Important:** Changes will be effective immediately if {@link SubscriptionSet} already subscribed. * * @param subscriptionSet - Other entities' subscription set, which should be joined. */ addSubscriptionSet(subscriptionSet: SubscriptionSet) { this.addSubscriptions(subscriptionSet.subscriptions); } /** * Subtract another {@link SubscriptionSet} object. * * **Important:** Changes will be effective immediately if {@link SubscriptionSet} already subscribed. * * @param subscriptionSet - Other entities' subscription set, which should be subtracted. */ removeSubscriptionSet(subscriptionSet: SubscriptionSet) { this.removeSubscriptions(subscriptionSet.subscriptions); } /** * Register {@link SubscriptionSet} 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 register(parameters: { cursor?: Subscription.SubscriptionCursor; subscriptions?: EventHandleCapable[] }) { const subscriptions: SubscriptionObject[] = (parameters.subscriptions ?? this.state.subscriptions) as SubscriptionObject[]; subscriptions.forEach(({ state }) => state.entity.increaseSubscriptionCount(this.state.id)); this.state.client.logger.trace(this.subscriptionType, () => ({ messageType: 'text', message: `Register subscription for real-time events: ${this}`, })); this.state.client.registerEventHandleCapable(this, parameters.cursor, subscriptions); } /** * Unregister {@link SubscriptionSet} object from real-time events' retrieval. * * @param [subscriptions] - List of subscription objects which should be unregistered (in case of partial * modification). * * @internal */ protected unregister(subscriptions?: SubscriptionObject[]) { const activeSubscriptions: SubscriptionObject[] = (subscriptions ?? this.state.subscriptions) as SubscriptionObject[]; activeSubscriptions.forEach(({ state }) => state.entity.decreaseSubscriptionCount(this.state.id)); this.state.client.logger.trace(this.subscriptionType, () => { if (!subscriptions) { return { messageType: 'text', message: `Unregister subscription from real-time events: ${this}`, }; } else { return { messageType: 'object', message: { subscription: this, subscriptions, }, details: 'Unregister subscriptions of subscription set from real-time events:', }; } }); this.state.client.unregisterEventHandleCapable(this, activeSubscriptions); } /** * Stringify subscription object. * * @returns Serialized subscription object. */ toString(): string { const state = this.state; return `${this.subscriptionType} { id: ${this.id}, stateId: ${state.id}, clonesCount: ${ Object.keys(this.state.clones).length }, isSubscribed: ${state.isSubscribed}, subscriptions: [${state.subscriptions .map((sub) => sub.toString()) .join(', ')}] }`; } }