pubnub
Version:
Publish & Subscribe Real-time Messaging with PubNub
282 lines (242 loc) • 11 kB
text/typescript
import {
PubNubClientManagerRegisterEvent,
PubNubClientManagerUnregisterEvent,
} from './custom-events/client-manager-event';
import { PubNubClientEvent, PubNubClientUnregisterEvent } from './custom-events/client-event';
import { RegisterEvent } from '../subscription-worker-types';
import { PubNubClient } from './pubnub-client';
/**
* Registered {@link PubNubClient|PubNub} client instances manager.
*
* Manager responsible for keeping track and interaction with registered {@link PubNubClient|PubNub}.
*/
export class PubNubClientsManager extends EventTarget {
// --------------------------------------------------------
// ---------------------- Information ---------------------
// --------------------------------------------------------
// region Information
/**
* Map of started `PING` timeouts per subscription key.
*/
private timeouts: {
[subKey: string]: { timeout?: ReturnType<typeof setTimeout>; interval: number; unsubscribeOffline: boolean };
} = {};
/**
* Map of previously created {@link PubNubClient|PubNub} clients.
*/
private clients: Record<string, { client: PubNubClient; abortController: AbortController }> = {};
/**
* Map of previously created {@link PubNubClient|PubNub} clients to the corresponding subscription key.
*/
private clientBySubscribeKey: Record<string, PubNubClient[]> = {};
// endregion
// --------------------------------------------------------
// --------------------- Constructors ---------------------
// --------------------------------------------------------
// region Constructors
/**
* Create {@link PubNubClient|PubNub} clients manager.
*
* @param sharedWorkerIdentifier - Unique `Subscription` worker identifier that will work with clients.
*/
constructor(private readonly sharedWorkerIdentifier: string) {
super();
}
// endregion
// --------------------------------------------------------
// ----------------- Client registration ------------------
// --------------------------------------------------------
// region Client registration
/**
* Create {@link PubNubClient|PubNub} client.
*
* Function called in response to the `client-register` from the core {@link PubNubClient|PubNub} client module.
*
* @param event - Registration event with base {@link PubNubClient|PubNub} client information.
* @param port - Message port for two-way communication with core {@link PubNubClient|PubNub} client module.
* @returns New {@link PubNubClient|PubNub} client or existing one from the cache.
*/
createClient(event: RegisterEvent, port: MessagePort) {
if (this.clients[event.clientIdentifier]) return this.clients[event.clientIdentifier];
const client = new PubNubClient(
event.clientIdentifier,
event.subscriptionKey,
event.userId,
port,
event.workerLogLevel,
event.heartbeatInterval,
);
this.registerClient(client);
// Start offline PubNub clients checks (ping-pong).
if (event.workerOfflineClientsCheckInterval) {
this.startClientTimeoutCheck(
event.subscriptionKey,
event.workerOfflineClientsCheckInterval,
event.workerUnsubscribeOfflineClients ?? false,
);
}
return client;
}
/**
* Store {@link PubNubClient|PubNub} client in manager's internal state.
*
* @param client - Freshly created {@link PubNubClient|PubNub} client which should be registered.
*/
private registerClient(client: PubNubClient) {
this.clients[client.identifier] = { client, abortController: new AbortController() };
// Associate client with subscription key.
if (!this.clientBySubscribeKey[client.subKey]) this.clientBySubscribeKey[client.subKey] = [client];
else this.clientBySubscribeKey[client.subKey].push(client);
this.forEachClient(client.subKey, (subKeyClient) =>
subKeyClient.logger.debug(
`'${client.identifier}' client registered with '${this.sharedWorkerIdentifier}' shared worker (${
this.clientBySubscribeKey[client.subKey].length
} active clients).`,
),
);
this.subscribeOnClientEvents(client);
this.dispatchEvent(new PubNubClientManagerRegisterEvent(client));
}
/**
* Remove {@link PubNubClient|PubNub} client from manager's internal state.
*
* @param client - Previously created {@link PubNubClient|PubNub} client which should be removed.
* @param [withLeave=false] - Whether `leave` request should be sent or not.
* @param [onClientInvalidation=false] - Whether client removal caused by its invalidation (event from the
* {@link PubNubClient|PubNub} client) or as result of timeout check.
*/
private unregisterClient(client: PubNubClient, withLeave = false, onClientInvalidation = false) {
if (!this.clients[client.identifier]) return;
// Make sure to detach all listeners for this `client`.
if (this.clients[client.identifier].abortController) this.clients[client.identifier].abortController.abort();
delete this.clients[client.identifier];
const clientsBySubscribeKey = this.clientBySubscribeKey[client.subKey];
if (clientsBySubscribeKey) {
const clientIdx = clientsBySubscribeKey.indexOf(client);
clientsBySubscribeKey.splice(clientIdx, 1);
if (clientsBySubscribeKey.length === 0) {
delete this.clientBySubscribeKey[client.subKey];
this.stopClientTimeoutCheck(client);
}
}
this.forEachClient(client.subKey, (subKeyClient) =>
subKeyClient.logger.debug(
`'${this.sharedWorkerIdentifier}' shared worker unregistered '${client.identifier}' client (${
this.clientBySubscribeKey[client.subKey].length
} active clients).`,
),
);
if (!onClientInvalidation) client.invalidate();
this.dispatchEvent(new PubNubClientManagerUnregisterEvent(client, withLeave));
}
// endregion
// --------------------------------------------------------
// ----------------- Availability check -------------------
// --------------------------------------------------------
// region Availability check
/**
* Start timer for _timeout_ {@link PubNubClient|PubNub} client checks.
*
* @param subKey - Subscription key to get list of {@link PubNubClient|PubNub} clients that should be checked.
* @param interval - Interval at which _timeout_ check should be done.
* @param unsubscribeOffline - Whether _timeout_ (or _offline_) {@link PubNubClient|PubNub} clients should send
* `leave` request before invalidation or not.
*/
private startClientTimeoutCheck(subKey: string, interval: number, unsubscribeOffline: boolean) {
if (this.timeouts[subKey]) return;
this.forEachClient(subKey, (client) =>
client.logger.debug(`Setup PubNub client ping for every ${interval} seconds.`),
);
this.timeouts[subKey] = {
interval,
unsubscribeOffline,
timeout: setTimeout(() => this.handleTimeoutCheck(subKey), interval * 500 - 1),
};
}
/**
* Stop _timeout_ (or _offline_) {@link PubNubClient|PubNub} clients pinging.
*
* **Note:** This method is used only when all clients for a specific subscription key have been unregistered.
*
* @param client - {@link PubNubClient|PubNub} client with which the last client related by subscription key has been
* removed.
*/
private stopClientTimeoutCheck(client: PubNubClient) {
if (!this.timeouts[client.subKey]) return;
if (this.timeouts[client.subKey].timeout) clearTimeout(this.timeouts[client.subKey].timeout);
delete this.timeouts[client.subKey];
}
/**
* Handle periodic {@link PubNubClient|PubNub} client timeout checks.
*
* @param subKey - Subscription key to get list of {@link PubNubClient|PubNub} clients that should be checked.
*/
private handleTimeoutCheck(subKey: string) {
if (!this.timeouts[subKey]) return;
const interval = this.timeouts[subKey].interval;
[...this.clientBySubscribeKey[subKey]].forEach((client) => {
// Handle potential SharedWorker timers throttling and early eviction of the PubNub core client.
// If timer fired later than specified interval - it has been throttled and shouldn't unregister client.
if (client.lastPingRequest && Date.now() / 1000 - client.lastPingRequest - 0.2 > interval * 0.5) {
client.logger.warn('PubNub clients timeout timer fired after throttling past due time.');
client.lastPingRequest = undefined;
}
if (
client.lastPingRequest &&
(!client.lastPongEvent || Math.abs(client.lastPongEvent - client.lastPingRequest) > interval)
) {
this.unregisterClient(client, this.timeouts[subKey].unsubscribeOffline);
// Notify other clients with same subscription key that one of them became inactive.
this.forEachClient(subKey, (subKeyClient) => {
if (subKeyClient.identifier !== client.identifier)
subKeyClient.logger.debug(`'${client.identifier}' client is inactive. Invalidating...`);
});
}
if (this.clients[client.identifier]) {
client.lastPingRequest = Date.now() / 1000;
client.postEvent({ type: 'shared-worker-ping' });
}
});
// Restart PubNub clients timeout check timer.
if (this.timeouts[subKey])
this.timeouts[subKey].timeout = setTimeout(() => this.handleTimeoutCheck(subKey), interval * 500);
}
// endregion
// --------------------------------------------------------
// ------------------- Event Handlers ---------------------
// --------------------------------------------------------
// region Event handlers
/**
* Listen for {@link PubNubClient|PubNub} client events that affect aggregated subscribe/heartbeat requests.
*
* @param client - {@link PubNubClient|PubNub} client for which event should be listened.
*/
private subscribeOnClientEvents(client: PubNubClient) {
client.addEventListener(
PubNubClientEvent.Unregister,
() =>
this.unregisterClient(
client,
this.timeouts[client.subKey] ? this.timeouts[client.subKey].unsubscribeOffline : false,
true,
),
{ signal: this.clients[client.identifier].abortController.signal, once: true },
);
}
// endregion
// --------------------------------------------------------
// ----------------------- Helpers ------------------------
// --------------------------------------------------------
// region Helpers
/**
* Call callback function for all {@link PubNubClient|PubNub} clients that have similar `subscribeKey`.
*
* @param subKey - Subscription key for which list of clients should be retrieved.
* @param callback - Function that will be called for each client list entry.
*/
private forEachClient(subKey: string, callback: (client: PubNubClient) => void) {
if (!this.clientBySubscribeKey[subKey]) return;
this.clientBySubscribeKey[subKey].forEach(callback);
}
// endregion
}