pubnub
Version:
Publish & Subscribe Real-time Messaging with PubNub
444 lines (391 loc) • 16.6 kB
text/typescript
import { TransportRequest } from '../../../core/types/transport-request';
import uuidGenerator from '../../../core/components/uuid';
import { Payload } from '../../../core/types/api';
import { BasePubNubRequest } from './request';
import { AccessToken } from './access-token';
export class SubscribeRequest extends BasePubNubRequest {
// --------------------------------------------------------
// ---------------------- Information ---------------------
// --------------------------------------------------------
// region Information
/**
* Global subscription request creation date tracking.
*
* Tracking is required to handle about rapid requests receive and need to know which of them were earlier.
*/
private static lastCreationDate = 0;
/**
* Presence state associated with `userID` on {@link SubscribeRequest.channels|channels} and
* {@link SubscribeRequest.channelGroups|channelGroups}.
*/
readonly state: Record<string, Payload> | undefined;
/**
* Request creation timestamp.
*/
private readonly _creationDate = Date.now();
/**
* Timetoken region which should be used to patch timetoken origin in initial response.
*/
public timetokenRegionOverride: string = '0';
/**
* Timetoken which should be used to patch timetoken in initial response.
*/
public timetokenOverride?: string;
/**
* Subscription loop timetoken.
*/
private _timetoken: string;
/**
* Subscription loop timetoken's region.
*/
private _region?: string;
/**
* Whether request requires client's cached subscription state reset or not.
*/
private _requireCachedStateReset: boolean;
/**
* Real-time events filtering expression.
*/
private readonly filterExpression?: string;
// endregion
// --------------------------------------------------------
// --------------------- Constructors ---------------------
// --------------------------------------------------------
// region Constructors
/**
* Create subscribe request from received _transparent_ transport request.
*
* @param request - Object with subscribe transport request.
* @param subscriptionKey - Subscribe REST API access key.
* @param [accessToken] - Access token with read permissions on
* {@link SubscribeRequest.channels|channels} and {@link SubscribeRequest.channelGroups|channelGroups}.
* @returns Initialized and ready to use subscribe request.
*/
static fromTransportRequest(request: TransportRequest, subscriptionKey: string, accessToken?: AccessToken) {
return new SubscribeRequest(request, subscriptionKey, accessToken);
}
/**
* Create subscribe request from previously cached data.
*
* @param request - Object with subscribe transport request.
* @param subscriptionKey - Subscribe REST API access key.
* @param [cachedChannelGroups] - Previously cached list of channel groups for subscription.
* @param [cachedChannels] - Previously cached list of channels for subscription.
* @param [cachedState] - Previously cached user's presence state for channels and groups.
* @param [accessToken] - Access token with read permissions on
* {@link PubNubSharedWorkerRequest.channels|channels} and
* {@link PubNubSharedWorkerRequest.channelGroups|channelGroups}.
* @retusns Initialized and ready to use subscribe request.
*/
static fromCachedState(
request: TransportRequest,
subscriptionKey: string,
cachedChannelGroups: string[],
cachedChannels: string[],
cachedState?: Record<string, Payload>,
accessToken?: AccessToken,
): SubscribeRequest {
return new SubscribeRequest(
request,
subscriptionKey,
accessToken,
cachedChannelGroups,
cachedChannels,
cachedState,
);
}
/**
* Create aggregated subscribe request.
*
* @param requests - List of subscribe requests for same the user.
* @param [accessToken] - Access token with permissions to announce presence on
* {@link SubscribeRequest.channels|channels} and {@link SubscribeRequest.channelGroups|channelGroups}.
* @param timetokenOverride - Timetoken which should be used to patch timetoken in initial response.
* @param timetokenRegionOverride - Timetoken origin which should be used to patch timetoken origin in initial
* response.
* @param [cachedState] - Previously cached user's presence state for channels and groups.
* @returns Aggregated subscribe request which will be sent.
*/
static fromRequests(
requests: SubscribeRequest[],
accessToken?: AccessToken,
timetokenOverride?: string,
timetokenRegionOverride?: string,
cachedState?: Record<string, Payload>,
) {
const baseRequest = requests[Math.floor(Math.random() * requests.length)];
const isInitialSubscribe = (baseRequest.request.queryParameters!.tt ?? '0') === '0';
const state: Record<string, Payload> = isInitialSubscribe ? (cachedState ?? {}) : {};
const aggregatedRequest = { ...baseRequest.request };
const channelGroups = new Set<string>();
const channels = new Set<string>();
for (const request of requests) {
if (isInitialSubscribe && !cachedState && request.state) Object.assign(state, request.state);
request.channelGroups.forEach(channelGroups.add, channelGroups);
request.channels.forEach(channels.add, channels);
}
// Update request channels list (if required).
if (channels.size || channelGroups.size) {
const pathComponents = aggregatedRequest.path.split('/');
pathComponents[4] = channels.size ? [...channels].sort().join(',') : ',';
aggregatedRequest.path = pathComponents.join('/');
}
// Update request channel groups list (if required).
if (channelGroups.size) aggregatedRequest.queryParameters!['channel-group'] = [...channelGroups].sort().join(',');
// Update request `state` (if required).
if (Object.keys(state).length) aggregatedRequest.queryParameters!.state = JSON.stringify(state);
else delete aggregatedRequest.queryParameters!.state;
if (accessToken) aggregatedRequest.queryParameters!.auth = accessToken.toString();
aggregatedRequest.identifier = uuidGenerator.createUUID();
// Create service request and link to its result other requests used in aggregation.
const request = new SubscribeRequest(
aggregatedRequest,
baseRequest.subscribeKey,
accessToken,
[...channelGroups],
[...channels],
state,
);
for (const clientRequest of requests) clientRequest.serviceRequest = request;
if (request.isInitialSubscribe && timetokenOverride && timetokenOverride !== '0') {
request.timetokenOverride = timetokenOverride;
if (timetokenRegionOverride) request.timetokenRegionOverride = timetokenRegionOverride;
}
return request;
}
/**
* Create subscribe request from received _transparent_ transport request.
*
* @param request - Object with subscribe transport request.
* @param subscriptionKey - Subscribe REST API access key.
* @param [accessToken] - Access token with read permissions on {@link SubscribeRequest.channels|channels} and
* {@link SubscribeRequest.channelGroups|channelGroups}.
* @param [cachedChannels] - Previously cached list of channels for subscription.
* @param [cachedChannelGroups] - Previously cached list of channel groups for subscription.
* @param [cachedState] - Previously cached user's presence state for channels and groups.
*/
private constructor(
request: TransportRequest,
subscriptionKey: string,
accessToken?: AccessToken,
cachedChannelGroups?: string[],
cachedChannels?: string[],
cachedState?: Record<string, Payload>,
) {
// Retrieve information about request's origin (who initiated it).
const requireCachedStateReset = !!request.queryParameters && 'on-demand' in request.queryParameters;
delete request.queryParameters!['on-demand'];
super(
request,
subscriptionKey,
request.queryParameters!.uuid as string,
cachedChannels ?? SubscribeRequest.channelsFromRequest(request),
cachedChannelGroups ?? SubscribeRequest.channelGroupsFromRequest(request),
accessToken,
);
// Shift on millisecond creation timestamp for two sequential requests.
if (this._creationDate <= SubscribeRequest.lastCreationDate) {
SubscribeRequest.lastCreationDate++;
this._creationDate = SubscribeRequest.lastCreationDate;
} else SubscribeRequest.lastCreationDate = this._creationDate;
this._requireCachedStateReset = requireCachedStateReset;
if (request.queryParameters!['filter-expr'])
this.filterExpression = request.queryParameters!['filter-expr'] as string;
this._timetoken = (request.queryParameters!.tt ?? '0') as string;
if (this._timetoken === '0') {
delete request.queryParameters!.tt;
delete request.queryParameters!.tr;
}
if (request.queryParameters!.tr) this._region = request.queryParameters!.tr as string;
if (cachedState) this.state = cachedState;
// Clean up `state` from objects which is not used with request (if needed).
if (
this.state ||
!request.queryParameters!.state ||
(request.queryParameters!.state as string).length <= 2 ||
this._timetoken !== '0'
)
return;
const state = JSON.parse(request.queryParameters!.state as string) as Record<string, Payload>;
for (const objectName of Object.keys(state))
if (!this.channels.includes(objectName) && !this.channelGroups.includes(objectName)) delete state[objectName];
this.state = state;
}
// endregion
// --------------------------------------------------------
// ---------------------- Properties ----------------------
// --------------------------------------------------------
// region Properties
/**
* Retrieve `subscribe` request creation timestamp.
*
* @returns `Subscribe` request creation timestamp.
*/
get creationDate() {
return this._creationDate;
}
/**
* Represent subscribe request as identifier.
*
* Generated identifier will be identical for requests created for the same user.
*/
get asIdentifier() {
const auth = this.accessToken ? this.accessToken.asIdentifier : undefined;
const id = `${this.userId}-${this.subscribeKey}${auth ? `-${auth}` : ''}`;
return this.filterExpression ? `${id}-${this.filterExpression}` : id;
}
/**
* Retrieve whether this is initial subscribe request or not.
*
* @returns `true` if subscribe REST API called with missing or `tt=0` query parameter.
*/
get isInitialSubscribe() {
return this._timetoken === '0';
}
/**
* Retrieve subscription loop timetoken.
*
* @returns Subscription loop timetoken.
*/
get timetoken() {
return this._timetoken;
}
/**
* Update subscription loop timetoken.
*
* @param value - New timetoken that should be used in PubNub REST API calls.
*/
set timetoken(value: string) {
this._timetoken = value;
// Update value for transport request object.
this.request.queryParameters!.tt = value;
}
/**
* Retrieve subscription loop timetoken's region.
*
* @returns Subscription loop timetoken's region.
*/
get region() {
return this._region;
}
/**
* Update subscription loop timetoken's region.
*
* @param value - New timetoken's region that should be used in PubNub REST API calls.
*/
set region(value: string | undefined) {
this._region = value;
// Update value for transport request object.
if (value) this.request.queryParameters!.tr = value;
else delete this.request.queryParameters!.tr;
}
/**
* Retrieve whether the request requires the client's cached subscription state reset or not.
*
* @returns `true` if a subscribe request has been created on user request (`subscribe()` call) or not.
*/
get requireCachedStateReset() {
return this._requireCachedStateReset;
}
// endregion
// --------------------------------------------------------
// ----------------------- Helpers ------------------------
// --------------------------------------------------------
// region Helpers
/**
* Check whether client's subscription state cache can be used for new request or not.
*
* @param request - Transport request from the core PubNub client module with request origin information.
* @returns `true` if request created not by user (subscription loop).
*/
static useCachedState(request: TransportRequest) {
return !!request.queryParameters && !('on-demand' in request.queryParameters);
}
/**
* Reset the inner state of the `subscribe` request object to the one that `initial` requests.
*/
resetToInitialRequest() {
this._requireCachedStateReset = true;
this._timetoken = '0';
this._region = undefined;
delete this.request.queryParameters!.tt;
}
/**
* Check whether received is a subset of another `subscribe` request.
*
* If the receiver is a subset of another means:
* - list of channels of another `subscribe` request includes all channels from the receiver,
* - list of channel groups of another `subscribe` request includes all channel groups from the receiver,
* - receiver's timetoken equal to `0` or another request `timetoken`.
*
* @param request - Request that should be checked to be a superset of received.
* @retuns `true` in case if the receiver is a subset of another `subscribe` request.
*/
isSubsetOf(request: SubscribeRequest): boolean {
if (request.channelGroups.length && !this.includesStrings(request.channelGroups, this.channelGroups)) return false;
if (request.channels.length && !this.includesStrings(request.channels, this.channels)) return false;
return this.timetoken === '0' || this.timetoken === request.timetoken || request.timetoken === '0';
}
/**
* Serialize request for easier representation in logs.
*
* @returns Stringified `subscribe` request.
*/
toString() {
return `SubscribeRequest { clientIdentifier: ${
this.client ? this.client.identifier : 'service request'
}, requestIdentifier: ${this.identifier}, serviceRequestIdentified: ${
this.client ? (this.serviceRequest ? this.serviceRequest.identifier : "'not set'") : "'is service request"
}, channels: [${
this.channels.length ? this.channels.map((channel) => `'${channel}'`).join(', ') : ''
}], channelGroups: [${
this.channelGroups.length ? this.channelGroups.map((group) => `'${group}'`).join(', ') : ''
}], timetoken: ${this.timetoken}, region: ${this.region}, reset: ${
this._requireCachedStateReset ? "'reset'" : "'do not reset'"
} }`;
}
/**
* Serialize request to "typed" JSON string.
*
* @returns "Typed" JSON string.
*/
toJSON() {
return this.toString();
}
/**
* Extract list of channels for subscription from request URI path.
*
* @param request - Transport request from which should be extracted list of channels for presence announcement.
*
* @returns List of channel names (not percent-decoded) for which `subscribe` has been called.
*/
private static channelsFromRequest(request: TransportRequest): string[] {
const channels = request.path.split('/')[4];
return channels === ',' ? [] : channels.split(',').filter((name) => name.length > 0);
}
/**
* Extract list of channel groups for subscription from request query.
*
* @param request - Transport request from which should be extracted list of channel groups for presence announcement.
*
* @returns List of channel group names (not percent-decoded) for which `subscribe` has been called.
*/
private static channelGroupsFromRequest(request: TransportRequest): string[] {
if (!request.queryParameters || !request.queryParameters['channel-group']) return [];
const group = request.queryParameters['channel-group'] as string;
return group.length === 0 ? [] : group.split(',').filter((name) => name.length > 0);
}
/**
* Check whether {@link main} array contains all entries from {@link sub} array.
*
* @param main - Main array with which `intersection` with {@link sub} should be checked.
* @param sub - Sub-array whose values should be checked in {@link main}.
*
* @returns `true` if all entries from {@link sub} is present in {@link main}.
*/
private includesStrings(main: string[], sub: string[]) {
const set = new Set(main);
return sub.every(set.has, set);
}
// endregion
}