pubnub
Version:
Publish & Subscribe Real-time Messaging with PubNub
566 lines (504 loc) • 18.8 kB
text/typescript
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;
}