pubnub
Version:
Publish & Subscribe Real-time Messaging with PubNub
453 lines (389 loc) • 16.6 kB
text/typescript
import {
RequestErrorEvent,
RequestSuccessEvent,
PubNubSharedWorkerRequestEvents,
} from './custom-events/request-processing-event';
import { HeartbeatStateHeartbeatEvent, HeartbeatStateInvalidateEvent } from './custom-events/heartbeat-state-event';
import { RequestSendingSuccess } from '../subscription-worker-types';
import { HeartbeatRequest } from './heartbeat-request';
import { Payload } from '../../../core/types/api';
import { PubNubClient } from './pubnub-client';
import { AccessToken } from './access-token';
export class HeartbeatState extends EventTarget {
// --------------------------------------------------------
// ---------------------- Information ---------------------
// --------------------------------------------------------
// region Information
/**
* Map of client identifiers to their portion of data (received from the explicit `heartbeat` requests), which affects
* heartbeat state.
*
* **Note:** This information is removed only with the {@link removeClient} function call.
*/
private clientsState: Record<
string,
{ channels: string[]; channelGroups: string[]; state?: Record<string, Payload> }
> = {};
/**
* Map of explicitly set `userId` presence state.
*
* This is the final source of truth, which is applied on the aggregated `state` object.
*
* **Note:** This information is removed only with the {@link removeClient} function call.
*/
private clientsPresenceState: Record<string, { update: number; state: Record<string, Payload> }> = {};
/**
* Map of client to its requests which is pending for service request processing results.
*/
private requests: Record<string, HeartbeatRequest> = {};
/**
* Backout timer timeout.
*/
private timeout?: ReturnType<typeof setTimeout>;
/**
* Time when previous heartbeat request has been done.
*/
private lastHeartbeatTimestamp: number = 0;
/**
* Reference to the most suitable access token to access {@link HeartbeatState#channels|channels} and
* {@link HeartbeatState#channelGroups|channelGroups}.
*/
private _accessToken?: AccessToken;
/**
* Stores response from the previous heartbeat request.
*/
private previousRequestResult?: RequestSendingSuccess;
/**
* Stores whether automated _backup_ timer can fire or not.
*/
private canSendBackupHeartbeat = true;
/**
* Whether previous call failed with `Access Denied` error or not.
*/
private isAccessDeniedError = false;
/**
* Presence heartbeat interval.
*
* Value used to decide whether new request should be handled right away or should wait for _backup_ timer in state
* to send aggregated request.
*/
private _interval: number = 0;
// endregion
// --------------------------------------------------------
// --------------------- Constructor ----------------------
// --------------------------------------------------------
// region Constructor
/**
* Create heartbeat state management object.
*
* @param identifier - Similar {@link SubscribeRequest|subscribe} requests aggregation identifier.
*/
constructor(public readonly identifier: string) {
super();
}
// endregion
// --------------------------------------------------------
// --------------------- Properties -----------------------
// --------------------------------------------------------
// region Properties
/**
* Update presence heartbeat interval.
*
* @param value - New heartbeat interval.
*/
set interval(value: number) {
const changed = this._interval !== value;
this._interval = value;
if (!changed) return;
// Restart timer if required.
if (value === 0) this.stopTimer();
else this.startTimer();
}
/**
* Update access token which should be used for aggregated heartbeat requests.
*
* @param value - New access token for heartbeat requests.
*/
set accessToken(value: AccessToken | undefined) {
if (!value) {
this._accessToken = value;
return;
}
const accessTokens = Object.values(this.requests)
.filter((request) => !!request.accessToken)
.map((request) => request.accessToken!);
accessTokens.push(value);
const latestAccessToken = accessTokens.sort(AccessToken.compare).pop();
if (!this._accessToken || (latestAccessToken && latestAccessToken.isNewerThan(this._accessToken)))
this._accessToken = latestAccessToken;
// Restart _backup_ heartbeat if previous call failed because of permissions error.
if (this.isAccessDeniedError) {
this.canSendBackupHeartbeat = true;
this.startTimer(this.presenceTimerTimeout());
}
}
// endregion
// --------------------------------------------------------
// ---------------------- Accessors -----------------------
// --------------------------------------------------------
// region Accessors
/**
* Retrieve portion of heartbeat state which is related to the specific client.
*
* @param client - Reference to the PubNub client for which state should be retrieved.
* @returns PubNub client's state in heartbeat.
*/
stateForClient(client: PubNubClient):
| {
channels: string[];
channelGroups: string[];
state?: Record<string, Payload>;
}
| undefined {
if (!this.clientsState[client.identifier]) return undefined;
const clientState = this.clientsState[client.identifier];
return clientState
? { channels: [...clientState.channels], channelGroups: [...clientState.channelGroups], state: clientState.state }
: { channels: [], channelGroups: [] };
}
/**
* Retrieve recent heartbeat request for the client.
*
* @param client - Reference to the client for which request should be retrieved.
* @returns List of client's ongoing requests.
*/
requestForClient(client: PubNubClient): HeartbeatRequest | undefined {
return this.requests[client.identifier];
}
// endregion
// --------------------------------------------------------
// --------------------- Aggregation ----------------------
// --------------------------------------------------------
// region Aggregation
/**
* Add new client's request to the state.
*
* @param client - Reference to PubNub client which is adding new requests for processing.
* @param request - New client-provided heartbeat request for processing.
*/
addClientRequest(client: PubNubClient, request: HeartbeatRequest) {
this.requests[client.identifier] = request;
this.clientsState[client.identifier] = { channels: request.channels, channelGroups: request.channelGroups };
if (request.state) this.clientsState[client.identifier].state = { ...request.state };
const presenceState = this.clientsPresenceState[client.identifier];
const cachedPresenceStateKeys = presenceState ? Object.keys(presenceState.state) : [];
if (presenceState && cachedPresenceStateKeys.length) {
cachedPresenceStateKeys.forEach((key) => {
if (!request.channels.includes(key) && !request.channelGroups.includes(key)) delete presenceState.state[key];
});
if (Object.keys(presenceState.state).length === 0) delete this.clientsPresenceState[client.identifier];
}
// Update access token information (use the one which will provide permissions for longer period).
const sortedTokens = Object.values(this.requests)
.filter((request) => !!request.accessToken)
.map((request) => request.accessToken!)
.sort(AccessToken.compare);
if (sortedTokens && sortedTokens.length > 0) {
const latestAccessToken = sortedTokens.pop();
if (!this._accessToken || (latestAccessToken && latestAccessToken.isNewerThan(this._accessToken)))
this._accessToken = latestAccessToken;
}
this.sendAggregatedHeartbeat(request);
}
/**
* Remove client and requests associated with it from the state.
*
* @param client - Reference to the PubNub client which should be removed.
*/
removeClient(client: PubNubClient) {
delete this.clientsPresenceState[client.identifier];
delete this.clientsState[client.identifier];
delete this.requests[client.identifier];
// Stop backup timer if there is no more channels and groups left.
if (!Object.keys(this.clientsState).length) {
this.stopTimer();
this.dispatchEvent(new HeartbeatStateInvalidateEvent());
}
}
/**
* Remove channels and groups associated with specific client.
*
* @param client - Reference to the PubNub client for which internal state should be updated.
* @param channels - List of channels that should be removed from the client's state (won't be used for "backup"
* heartbeat).
* @param channelGroups - List of channel groups that should be removed from the client's state (won't be used for
* "backup" heartbeat).
*/
removeFromClientState(client: PubNubClient, channels: string[], channelGroups: string[]) {
const presenceState = this.clientsPresenceState[client.identifier];
const clientState = this.clientsState[client.identifier];
if (!clientState) return;
clientState.channelGroups = clientState.channelGroups.filter((group) => !channelGroups.includes(group));
clientState.channels = clientState.channels.filter((channel) => !channels.includes(channel));
if (presenceState && Object.keys(presenceState.state).length) {
channelGroups.forEach((group) => delete presenceState.state[group]);
channels.forEach((channel) => delete presenceState.state[channel]);
if (Object.keys(presenceState.state).length === 0) delete this.clientsPresenceState[client.identifier];
}
if (clientState.channels.length === 0 && clientState.channelGroups.length === 0) {
this.removeClient(client);
return;
}
// Clean up user's presence state from removed channels and groups.
if (!clientState.state) return;
Object.keys(clientState.state).forEach((key) => {
if (!clientState.channels.includes(key) && !clientState.channelGroups.includes(key))
delete clientState.state![key];
});
}
/**
* Update presence associated with `client`'s `userId` with channels and groups.
* @param client - Reference to the {@link PubNubClient|PubNub} client for which `userId` presence state has been
* changed.
* @param state - Payloads that are associated with `userId` at specified (as keys) channels and groups.
*/
updateClientPresenceState(client: PubNubClient, state: Record<string, Payload>) {
const presenceState = this.clientsPresenceState[client.identifier];
state ??= {};
if (!presenceState) this.clientsPresenceState[client.identifier] = { update: Date.now(), state };
else {
Object.assign(presenceState.state, state);
presenceState.update = Date.now();
}
}
/**
* Start "backup" presence heartbeat timer.
*
* @param targetInterval - Interval after which heartbeat request should be sent.
*/
private startTimer(targetInterval?: number) {
this.stopTimer();
if (Object.keys(this.clientsState).length === 0) return;
this.timeout = setTimeout(() => this.handlePresenceTimer(), (targetInterval ?? this._interval) * 1000);
}
/**
* Stop "backup" presence heartbeat timer.
*/
private stopTimer() {
if (this.timeout) clearTimeout(this.timeout);
this.timeout = undefined;
}
/**
* Send aggregated heartbeat request (if possible).
*
* @param [request] - Client provided request which tried to announce presence.
*/
private sendAggregatedHeartbeat(request?: HeartbeatRequest) {
if (this.lastHeartbeatTimestamp !== 0) {
// Check whether it is too soon to send request or not.
const expected = this.lastHeartbeatTimestamp + this._interval * 1000;
let leeway = this._interval * 0.05;
if (this._interval - leeway < 3) leeway = 0;
const current = Date.now();
if (expected - current > leeway * 1000) {
if (request && !!this.previousRequestResult) {
const fetchRequest = request.asFetchRequest;
const result = {
...this.previousRequestResult,
clientIdentifier: request.client.identifier,
identifier: request.identifier,
url: fetchRequest.url,
};
request.handleProcessingStarted();
request.handleProcessingSuccess(fetchRequest, result);
return;
} else if (!request) return;
}
}
const requests = Object.values(this.requests);
const baseRequest = requests[Math.floor(Math.random() * requests.length)];
const aggregatedRequest = { ...baseRequest.request };
const targetState: Record<string, Payload> = {};
const channelGroups = new Set<string>();
const channels = new Set<string>();
Object.entries(this.clientsState).forEach(([clientIdentifier, clientState]) => {
if (clientState.state) Object.assign(targetState, clientState.state);
clientState.channelGroups.forEach(channelGroups.add, channelGroups);
clientState.channels.forEach(channels.add, channels);
});
if (Object.keys(this.clientsPresenceState).length) {
Object.values(this.clientsPresenceState)
.sort((lhs, rhs) => lhs.update - rhs.update)
.forEach(({ state }) => Object.assign(targetState, state));
}
this.lastHeartbeatTimestamp = Date.now();
const serviceRequest = HeartbeatRequest.fromCachedState(
aggregatedRequest,
requests[0].subscribeKey,
[...channelGroups],
[...channels],
Object.keys(targetState).length > 0 ? targetState : undefined,
this._accessToken,
);
// Set service request for all client-provided requests without response.
Object.values(this.requests).forEach(
(request) => !request.serviceRequest && (request.serviceRequest = serviceRequest),
);
this.addListenersForRequest(serviceRequest);
this.dispatchEvent(new HeartbeatStateHeartbeatEvent(serviceRequest));
// Restart _backup_ timer after regular client-provided request triggered heartbeat.
if (request) this.startTimer();
}
// endregion
// --------------------------------------------------------
// ------------------- Event Handlers ---------------------
// --------------------------------------------------------
// region Event handlers
/**
* Add listeners to the service request.
*
* Listeners used to capture last service success response and mark whether further _backup_ requests possible or not.
*
* @param request - Service `heartbeat` request for which events will be listened once.
*/
private addListenersForRequest(request: HeartbeatRequest) {
const ac = new AbortController();
const callback = (evt: Event) => {
// Clean up service request listeners.
ac.abort();
if (evt instanceof RequestSuccessEvent) {
const { response } = evt as RequestSuccessEvent;
this.previousRequestResult = response;
} else if (evt instanceof RequestErrorEvent) {
const { error } = evt as RequestErrorEvent;
this.canSendBackupHeartbeat = true;
this.isAccessDeniedError = false;
if (error.response && error.response.status >= 400 && error.response.status < 500) {
this.isAccessDeniedError = error.response.status === 403;
this.canSendBackupHeartbeat = false;
}
}
};
request.addEventListener(PubNubSharedWorkerRequestEvents.Success, callback, { signal: ac.signal, once: true });
request.addEventListener(PubNubSharedWorkerRequestEvents.Error, callback, { signal: ac.signal, once: true });
request.addEventListener(PubNubSharedWorkerRequestEvents.Canceled, callback, { signal: ac.signal, once: true });
}
/**
* Handle periodic _backup_ heartbeat timer.
*/
private handlePresenceTimer() {
if (Object.keys(this.clientsState).length === 0 || !this.canSendBackupHeartbeat) return;
const targetInterval = this.presenceTimerTimeout();
this.sendAggregatedHeartbeat();
this.startTimer(targetInterval);
}
/**
* Compute timeout for _backup_ heartbeat timer.
*
* @returns Number of seconds after which new aggregated heartbeat request should be sent.
*/
private presenceTimerTimeout() {
const timePassed = (Date.now() - this.lastHeartbeatTimestamp) / 1000;
let targetInterval = this._interval;
if (timePassed < targetInterval) targetInterval -= timePassed;
if (targetInterval === this._interval) targetInterval += 0.05;
targetInterval = Math.max(targetInterval, 3);
return targetInterval;
}
// endregion
}