pubnub
Version:
Publish & Subscribe Real-time Messaging with PubNub
1,551 lines (1,320 loc) • 99.5 kB
text/typescript
/// <reference lib="webworker" />
/**
* Subscription Service Worker Transport provider.
*
* Service worker provides support for PubNub subscription feature to give better user experience across
* multiple opened pages.
*
* @internal
*/
import { TransportMethod, TransportRequest } from '../../core/types/transport-request';
import { TransportResponse } from '../../core/types/transport-response';
import uuidGenerator from '../../core/components/uuid';
import { Payload, Query } from '../../core/types/api';
// --------------------------------------------------------
// ------------------------ Types -------------------------
// --------------------------------------------------------
// region Types
// region Client-side
/**
* Basic information for client and request group identification.
*/
type BasicEvent = {
/**
* Unique PubNub SDK client identifier for which setup is done.
*/
clientIdentifier: string;
/**
* Subscribe REST API access key.
*/
subscriptionKey: string;
/**
* Interval at which Shared Worker should check whether PubNub instances which used it still active or not.
*/
workerOfflineClientsCheckInterval?: number;
/**
* Whether `leave` request should be sent for _offline_ PubNub client or not.
*/
workerUnsubscribeOfflineClients?: boolean;
/**
* Whether verbose logging should be enabled for `Subscription` worker should print debug messages or not.
*/
workerLogVerbosity?: boolean;
};
/**
* PubNub client registration event.
*/
export type RegisterEvent = BasicEvent & {
type: 'client-register';
/**
* Unique identifier of the user for which PubNub SDK client has been created.
*/
userId: string;
/**
* How often the client will announce itself to server. The value is in seconds.
*
* @default `not set`
*/
heartbeatInterval?: number;
/**
* Specific PubNub client instance communication port.
*/
port?: MessagePort;
};
/**
* PubNub client update event.
*/
export type UpdateEvent = BasicEvent & {
type: 'client-update';
/**
* `userId` currently used by the client.
*/
userId: string;
/**
* How often the client will announce itself to server. The value is in seconds.
*
* @default `not set`
*/
heartbeatInterval?: number;
/**
* Access token which is used to access provided list of channels and channel groups.
*
* **Note:** Value can be missing, but it shouldn't reset it in the state.
*/
accessToken?: string;
/**
* Pre-processed access token (If set).
*
* **Note:** Value can be missing, but it shouldn't reset it in the state.
*/
preProcessedToken?: PubNubClientState['accessToken'];
};
/**
* Send HTTP request event.
*
* Request from Web Worker to schedule {@link Request} using provided {@link SendRequestSignal#request|request} data.
*/
export type SendRequestEvent = BasicEvent & {
type: 'send-request';
/**
* Instruction to construct actual {@link Request}.
*/
request: TransportRequest;
/**
* Pre-processed access token (If set).
*/
preProcessedToken?: PubNubClientState['accessToken'];
};
/**
* Cancel HTTP request event.
*/
export type CancelRequestEvent = BasicEvent & {
type: 'cancel-request';
/**
* Identifier of request which should be cancelled.
*/
identifier: string;
};
/**
* Client response on PING request.
*/
export type PongEvent = BasicEvent & {
type: 'client-pong';
};
/**
* PubNub client remove registration event.
*
* On registration removal ongoing long-long poll request will be cancelled.
*/
export type UnRegisterEvent = BasicEvent & {
type: 'client-unregister';
};
/**
* List of known events from the PubNub Core.
*/
export type ClientEvent =
| RegisterEvent
| UpdateEvent
| PongEvent
| SendRequestEvent
| CancelRequestEvent
| UnRegisterEvent;
// endregion
// region Subscription Worker
/**
* Shared subscription worker connected event.
*
* Event signal shared worker client that worker can be used.
*/
export type SharedWorkerConnected = {
type: 'shared-worker-connected';
};
/**
* Request processing error.
*
* Object may include either service error response or client-side processing error object.
*/
export type RequestSendingError = {
type: 'request-process-error';
/**
* Receiving PubNub client unique identifier.
*/
clientIdentifier: string;
/**
* Failed request identifier.
*/
identifier: string;
/**
* Url which has been used to perform request.
*/
url: string;
/**
* Service error response.
*/
response?: RequestSendingSuccess['response'];
/**
* Client side request processing error.
*/
error?: {
/**
* Name of error object which has been received.
*/
name: string;
/**
* Available client-side errors.
*/
type: 'NETWORK_ISSUE' | 'ABORTED' | 'TIMEOUT';
/**
* Triggered error message.
*/
message: string;
};
};
/**
* Request processing success.
*/
export type RequestSendingSuccess = {
type: 'request-process-success';
/**
* Receiving PubNub client unique identifier.
*/
clientIdentifier: string;
/**
* Processed request identifier.
*/
identifier: string;
/**
* Url which has been used to perform request.
*/
url: string;
/**
* Service success response.
*/
response: {
/**
* Received {@link RequestSendingSuccess#response.body|body} content type.
*/
contentType: string;
/**
* Received {@link RequestSendingSuccess#response.body|body} content length.
*/
contentLength: number;
/**
* Response headers key / value pairs.
*/
headers: Record<string, string>;
/**
* Response status code.
*/
status: number;
/**
* Service response.
*/
body?: ArrayBuffer;
};
};
/**
* Request processing results.
*/
export type RequestSendingResult = RequestSendingError | RequestSendingSuccess;
/**
* Send message to debug console.
*/
export type SharedWorkerConsoleLog = {
type: 'shared-worker-console-log';
/**
* Message which should be printed into the console.
*/
message: Payload;
};
/**
* Send message to debug console.
*/
export type SharedWorkerConsoleDir = {
type: 'shared-worker-console-dir';
/**
* Message which should be printed into the console before {@link data}.
*/
message?: string;
/**
* Data which should be printed into the console.
*/
data: Payload;
};
/**
* Shared worker console output request.
*/
export type SharedWorkerConsole = SharedWorkerConsoleLog | SharedWorkerConsoleDir;
/**
* Shared worker client ping request.
*
* Ping used to discover disconnected PubNub instances.
*/
export type SharedWorkerPing = {
type: 'shared-worker-ping';
};
/**
* List of known events from the PubNub Subscription Service Worker.
*/
export type SubscriptionWorkerEvent =
| SharedWorkerConnected
| SharedWorkerConsole
| SharedWorkerPing
| RequestSendingResult;
/**
* PubNub client state representation in Shared Worker.
*/
type PubNubClientState = {
/**
* Unique PubNub client identifier.
*/
clientIdentifier: string;
/**
* Subscribe REST API access key.
*/
subscriptionKey: string;
/**
* Unique identifier of the user currently configured for the PubNub client.
*/
userId: string;
/**
* Authorization key or access token which is used to access provided list of
* {@link subscription.channels|channels} and {@link subscription.channelGroups|channelGroups}.
*/
authKey?: string;
/**
* Aggregateable {@link authKey} representation.
*
* Representation based on information stored in `resources`, `patterns`, and `authorized_uuid`.
*/
accessToken?: {
token: string;
expiration: number;
};
/**
* Origin which is used to access PubNub REST API.
*/
origin?: string;
/**
* PubNub JS SDK identification string.
*/
pnsdk?: string;
/**
* How often the client will announce itself to server. The value is in seconds.
*
* @default `not set`
*/
heartbeatInterval?: number;
/**
* Whether instance registered for the first time or not.
*/
newlyRegistered: boolean;
/**
* Interval at which Shared Worker should check whether PubNub instances which used it still active or not.
*/
offlineClientsCheckInterval?: number;
/**
* Whether `leave` request should be sent for _offline_ PubNub client or not.
*/
unsubscribeOfflineClients?: boolean;
/**
* Whether client should log Shared Worker logs or not.
*/
workerLogVerbosity?: boolean;
/**
* Last time when PING request has been sent.
*/
lastPingRequest?: number;
/**
* Last time when PubNub client respond with PONG event.
*/
lastPongEvent?: number;
/**
* Current subscription session information.
*
* **Note:** Information updated each time when PubNub client instance schedule `subscribe` or
* `unsubscribe` requests.
*/
subscription?: {
/**
* Date time when subscription object has been updated.
*/
refreshTimestamp: number;
/**
* Subscription REST API uri path.
*
* **Note:** Keeping it for faster check whether client state should be updated or not.
*/
path: string;
/**
* Channel groups list representation from request query parameters.
*
* **Note:** Keeping it for faster check whether client state should be updated or not.
*/
channelGroupQuery: string;
/**
* List of channels used in current subscription session.
*/
channels: string[];
/**
* List of channel groups used in current subscription session.
*/
channelGroups: string[];
/**
* Timetoken which used has been used with previous subscription session loop.
*/
previousTimetoken: string;
/**
* Timetoken which used in current subscription session loop.
*/
timetoken: string;
/**
* Timetoken region which used in current subscription session loop.
*/
region?: string;
/**
* List of channel and / or channel group names for which state has been assigned.
*
* Information used during client information update to identify entries which should be removed.
*/
objectsWithState: string[];
/**
* Subscribe request which has been emitted by PubNub client.
*
* Value will be reset when current request processing completed or client "disconnected" (not interested in
* real-time updates).
*/
request?: TransportRequest;
/**
* Identifier of subscribe request which has been actually sent by Service Worker.
*
* **Note:** Value not set if client not interested in any real-time updates.
*/
serviceRequestId?: string;
/**
* Real-time events filtering expression.
*/
filterExpression?: string;
};
heartbeat?: {
/**
* Previous heartbeat send event.
*/
heartbeatEvent?: SendRequestEvent;
/**
* List of channels for which user's presence has been announced by the PubNub client.
*/
channels: string[];
/**
* List of channel groups for which user's presence has been announced by the PubNub client.
*/
channelGroups: string[];
/**
* Presence state associated with user at specified list of channels and groups.
*
* Per-channel/group state associated with specific user.
*/
presenceState?: Record<string, Payload | undefined>;
/**
* Backup presence heartbeat loop managed by the `SharedWorker`.
*/
loop?: {
/**
* Heartbeat timer.
*
* Timer which is started with first heartbeat request and repeat inside SharedWorker to bypass browser's
* timers throttling.
*
* **Note:** Timer will be restarted each time when core client request to send a request (still "alive").
*/
timer: ReturnType<typeof setTimeout>;
/**
* Interval which has been used for the timer.
*/
heartbeatInterval: number;
/**
* Timestamp when time has been started.
*
* **Note:** Information needed to compute active timer restart with new interval value.
*/
startTimestamp: number;
};
};
};
// endregion
// endregion
// --------------------------------------------------------
// ------------------- Service Worker ---------------------
// --------------------------------------------------------
// region Service Worker
declare const self: SharedWorkerGlobalScope;
/**
* Aggregation timer timeout.
*
* Timeout used by the timer to postpone `handleSendSubscribeRequestEvent` function call and let other clients for
* same subscribe key send next subscribe loop request (to make aggregation more efficient).
*/
const subscribeAggregationTimeout = 50;
/**
* Map of clients aggregation keys to the started aggregation timeout timers with client and event information.
*/
const aggregationTimers: Map<string, [[PubNubClientState, SendRequestEvent][], NodeJS.Timeout]> = new Map();
// region State
/**
* Per-subscription key map of "offline" clients detection timeouts.
*/
const pingTimeouts: { [subscriptionKey: string]: number | undefined } = {};
/**
* Unique shared worker instance identifier.
*/
const sharedWorkerIdentifier = uuidGenerator.createUUID();
/**
* Map of identifiers, scheduled by the Service Worker, to their abort controllers.
*
* **Note:** Because of message-based nature of interaction it will be impossible to pass actual {@link AbortController}
* to the transport provider code.
*/
const abortControllers: Map<string, AbortController> = new Map();
/**
* Map of PubNub client identifiers to their state in the current Service Worker.
*/
const pubNubClients: Record<string, PubNubClientState | undefined> = {};
/**
* Per-subscription key list of PubNub client state.
*/
const pubNubClientsBySubscriptionKey: { [subscriptionKey: string]: PubNubClientState[] | undefined } = {};
/**
* Per-subscription key map of heartbeat request configurations recently used for user.
*/
const serviceHeartbeatRequests: {
[subscriptionKey: string]:
| {
[userId: string]:
| {
createdByActualRequest: boolean;
channels: string[];
channelGroups: string[];
timestamp: number;
clientIdentifier?: string;
response?: [Response, ArrayBuffer];
}
| undefined;
}
| undefined;
} = {};
/**
* Per-subscription key presence state associated with unique user identifiers with which {@link pubNubClients|clients}
* scheduled subscription request.
*/
const presenceState: {
[subscriptionKey: string]: { [userId: string]: Record<string, Payload | undefined> | undefined } | undefined;
} = {};
/**
* Per-subscription key map of client identifiers to the Shared Worker {@link MessagePort}.
*
* Shared Worker {@link MessagePort} represent specific PubNub client which connected to the Shared Worker.
*/
const sharedWorkerClients: {
[subscriptionKey: string]: { [clientId: string]: MessagePort | undefined } | undefined;
} = {};
/**
* List of ongoing subscription requests.
*
* **Node:** Identifiers differ from request identifiers received in {@link SendRequestEvent} object.
*/
const serviceRequests: {
[requestId: string]: {
/**
* Unique active request identifier.
*/
requestId: string;
/**
* Timetoken which is used for subscription loop.
*/
timetoken: string;
/**
* Timetoken region which is used for subscription loop.
*/
region?: string;
/**
* Timetoken override which is used after initial subscription to catch up on previous messages.
*/
timetokenOverride?: string;
/**
* Timetoken region override which is used after initial subscription to catch up on previous messages.
*/
regionOverride?: string;
/**
* List of channels used in current subscription session.
*/
channels: string[];
/**
* List of channel groups used in current subscription session.
*/
channelGroups: string[];
};
} = {};
// endregion
// --------------------------------------------------------
// ------------------- Event Handlers ---------------------
// --------------------------------------------------------
// region Event Handlers
/**
* Handle new PubNub client 'connection'.
*
* Echo listeners to let `SharedWorker` users that it is ready.
*
* @param event - Remote `SharedWorker` client connection event.
*/
self.onconnect = (event) => {
consoleLog('New PubNub Client connected to the Subscription Shared Worker.');
event.ports.forEach((receiver) => {
receiver.start();
receiver.onmessage = (event: MessageEvent<ClientEvent>) => {
// Ignoring unknown event payloads.
if (!validateEventPayload(event)) return;
const data = event.data as ClientEvent;
if (data.type === 'client-register') {
// Appending information about messaging port for responses.
data.port = receiver;
registerClientIfRequired(data);
consoleLog(`Client '${data.clientIdentifier}' registered with '${sharedWorkerIdentifier}' shared worker`);
} else if (data.type === 'client-update') updateClientInformation(data);
else if (data.type === 'client-unregister') unRegisterClient(data);
else if (data.type === 'client-pong') handleClientPong(data);
else if (data.type === 'send-request') {
if (data.request.path.startsWith('/v2/subscribe')) {
const changedSubscription = updateClientSubscribeStateIfRequired(data);
const client = pubNubClients[data.clientIdentifier];
if (client) {
// Check whether there are more clients which may schedule next subscription loop and they need to be
// aggregated or not.
const timerIdentifier = aggregateTimerId(client);
let enqueuedClients: [PubNubClientState, SendRequestEvent][] = [];
if (aggregationTimers.has(timerIdentifier)) enqueuedClients = aggregationTimers.get(timerIdentifier)![0];
enqueuedClients.push([client, data]);
// Clear existing aggregation timer if subscription list changed.
if (aggregationTimers.has(timerIdentifier) && changedSubscription) {
clearTimeout(aggregationTimers.get(timerIdentifier)![1]);
aggregationTimers.delete(timerIdentifier);
}
// Check whether we need to start new aggregation timer or not.
if (!aggregationTimers.has(timerIdentifier)) {
const aggregationTimer = setTimeout(() => {
handleSendSubscribeRequestEventForClients(enqueuedClients, data);
aggregationTimers.delete(timerIdentifier);
}, subscribeAggregationTimeout);
aggregationTimers.set(timerIdentifier, [enqueuedClients, aggregationTimer]);
}
}
} else if (data.request.path.endsWith('/heartbeat')) {
updateClientHeartbeatState(data);
handleHeartbeatRequestEvent(data);
} else handleSendLeaveRequestEvent(data);
} else if (data.type === 'cancel-request') handleCancelRequestEvent(data);
};
receiver.postMessage({ type: 'shared-worker-connected' });
});
};
/**
* Handle aggregated clients request to send subscription request.
*
* @param clients - List of aggregated clients which would like to send subscription requests.
* @param event - Subscription event details.
*/
const handleSendSubscribeRequestEventForClients = (
clients: [PubNubClientState, SendRequestEvent][],
event: SendRequestEvent,
) => {
const requestOrId = subscribeTransportRequestFromEvent(event);
const client = pubNubClients[event.clientIdentifier];
if (!client) return;
// Getting rest of aggregated clients.
clients = clients.filter((aggregatedClient) => aggregatedClient[0].clientIdentifier !== client.clientIdentifier);
handleSendSubscribeRequestForClient(client, event, requestOrId, true);
clients.forEach(([aggregatedClient, clientEvent]) =>
handleSendSubscribeRequestForClient(aggregatedClient, clientEvent, requestOrId, false),
);
};
/**
* Handle subscribe request by single client.
*
* @param client - Client which processes `request`.
* @param event - Subscription event details.
* @param requestOrId - New aggregated request object or its identifier (if already scheduled).
* @param requestOrigin - Whether `client` is the one who triggered subscribe request or not.
*/
const handleSendSubscribeRequestForClient = (
client: PubNubClientState,
event: SendRequestEvent,
requestOrId: ReturnType<typeof subscribeTransportRequestFromEvent>,
requestOrigin: boolean,
) => {
let isInitialSubscribe = false;
if (!requestOrigin && typeof requestOrId !== 'string') requestOrId = requestOrId.identifier;
if (client.subscription) isInitialSubscribe = client.subscription.timetoken === '0';
if (typeof requestOrId === 'string') {
const scheduledRequest = serviceRequests[requestOrId];
if (client) {
if (client.subscription) {
// Updating client timetoken information.
client.subscription.refreshTimestamp = Date.now();
client.subscription.timetoken = scheduledRequest.timetoken;
client.subscription.region = scheduledRequest.region;
client.subscription.serviceRequestId = requestOrId;
}
if (!isInitialSubscribe) return;
const body = new TextEncoder().encode(
`{"t":{"t":"${scheduledRequest.timetoken}","r":${scheduledRequest.region ?? '0'}},"m":[]}`,
);
const headers = new Headers({
'Content-Type': 'text/javascript; charset="UTF-8"',
'Content-Length': `${body.length}`,
});
const response = new Response(body, { status: 200, headers });
const result = requestProcessingSuccess([response, body]);
result.url = `${event.request.origin}${event.request.path}`;
result.clientIdentifier = event.clientIdentifier;
result.identifier = event.request.identifier;
publishClientEvent(client, result);
}
return;
}
if (event.request.cancellable) abortControllers.set(requestOrId.identifier, new AbortController());
const scheduledRequest = serviceRequests[requestOrId.identifier];
const { timetokenOverride, regionOverride } = scheduledRequest;
const expectingInitialSubscribeResponse = scheduledRequest.timetoken === '0';
consoleLog(`'${Object.keys(serviceRequests).length}' subscription request currently active.`);
// Notify about request processing start.
for (const client of clientsForRequest(requestOrId.identifier))
consoleLog({ messageType: 'network-request', message: requestOrId as unknown as Payload }, client);
sendRequest(
requestOrId,
() => clientsForRequest(requestOrId.identifier),
(clients, fetchRequest, response) => {
// Notify each PubNub client which awaited for response.
notifyRequestProcessingResult(clients, fetchRequest, response, event.request);
// Clean up scheduled request and client references to it.
markRequestCompleted(clients, requestOrId.identifier);
},
(clients, fetchRequest, error) => {
// Notify each PubNub client which awaited for response.
notifyRequestProcessingResult(clients, fetchRequest, null, event.request, requestProcessingError(error));
// Clean up scheduled request and client references to it.
markRequestCompleted(clients, requestOrId.identifier);
},
(response) => {
let serverResponse = response;
if (expectingInitialSubscribeResponse && timetokenOverride && timetokenOverride !== '0')
serverResponse = patchInitialSubscribeResponse(serverResponse, timetokenOverride, regionOverride);
return serverResponse;
},
);
};
const patchInitialSubscribeResponse = (
serverResponse: [Response, ArrayBuffer],
timetoken?: string,
region?: string,
): [Response, ArrayBuffer] => {
if (timetoken === undefined || timetoken === '0' || serverResponse[0].status >= 400) {
return serverResponse;
}
let json: { t: { t: string; r: number }; m: Record<string, unknown>[] };
const response = serverResponse[0];
let decidedResponse = response;
let body = serverResponse[1];
try {
json = JSON.parse(new TextDecoder().decode(body));
} catch (error) {
consoleLog(`Subscribe response parse error: ${error}`);
return serverResponse;
}
// Replace server-provided timetoken.
json.t.t = timetoken;
if (region) json.t.r = parseInt(region, 10);
try {
body = new TextEncoder().encode(JSON.stringify(json)).buffer;
if (body.byteLength) {
const headers = new Headers(response.headers);
headers.set('Content-Length', `${body.byteLength}`);
// Create a new response with the original response options and modified headers
decidedResponse = new Response(body, {
status: response.status,
statusText: response.statusText,
headers: headers,
});
}
} catch (error) {
consoleLog(`Subscribe serialization error: ${error}`);
return serverResponse;
}
return body.byteLength > 0 ? [decidedResponse, body] : serverResponse;
};
/**
* Handle client heartbeat request.
*
* @param event - Heartbeat event details.
* @param [actualRequest] - Whether handling actual request from the core-part of the client and not backup heartbeat in
* the `SharedWorker`.
* @param [outOfOrder] - Whether handling request which is sent on irregular basis (setting update).
*/
const handleHeartbeatRequestEvent = (event: SendRequestEvent, actualRequest = true, outOfOrder = false) => {
const client = pubNubClients[event.clientIdentifier];
const request = heartbeatTransportRequestFromEvent(event, actualRequest, outOfOrder);
if (!client) return;
const heartbeatRequestKey = `${client.userId}_${clientAggregateAuthKey(client) ?? ''}`;
const hbRequestsBySubscriptionKey = serviceHeartbeatRequests[client.subscriptionKey];
const hbRequests = (hbRequestsBySubscriptionKey ?? {})[heartbeatRequestKey];
if (!request) {
let message = `Previous heartbeat request has been sent less than ${
client.heartbeatInterval
} seconds ago. Skipping...`;
if (!client.heartbeat || (client.heartbeat.channels.length === 0 && client.heartbeat.channelGroups.length === 0))
message = `${client.clientIdentifier} doesn't have subscriptions to non-presence channels. Skipping...`;
consoleLog(message, client);
let response: Response | undefined;
let body: ArrayBuffer | undefined;
// Pulling out previous response.
if (hbRequests && hbRequests.response) [response, body] = hbRequests.response;
if (!response) {
body = new TextEncoder().encode('{ "status": 200, "message": "OK", "service": "Presence" }').buffer;
const headers = new Headers({
'Content-Type': 'text/javascript; charset="UTF-8"',
'Content-Length': `${body.byteLength}`,
});
response = new Response(body, { status: 200, headers });
}
const result = requestProcessingSuccess([response, body!]);
result.url = `${event.request.origin}${event.request.path}`;
result.clientIdentifier = event.clientIdentifier;
result.identifier = event.request.identifier;
publishClientEvent(client, result);
return;
}
consoleLog(`Started heartbeat request.`, client);
// Notify about request processing start.
for (const client of clientsForSendHeartbeatRequestEvent(event))
consoleLog({ messageType: 'network-request', message: request as unknown as Payload }, client);
sendRequest(
request,
() => [client],
(clients, fetchRequest, response) => {
if (hbRequests) hbRequests.response = response;
// Notify each PubNub client which awaited for response.
notifyRequestProcessingResult(clients, fetchRequest, response, event.request);
// Stop heartbeat timer on client error status codes.
if (response[0].status >= 400 && response[0].status < 500) stopHeartbeatTimer(client);
},
(clients, fetchRequest, error) => {
// Notify each PubNub client which awaited for response.
notifyRequestProcessingResult(clients, fetchRequest, null, event.request, requestProcessingError(error));
},
);
// Start "backup" heartbeat timer.
if (!outOfOrder) startHeartbeatTimer(client);
};
/**
* Handle client request to leave request.
*
* @param data - Leave event details.
* @param [invalidatedClient] - Specific client to handle leave request.
* @param [invalidatedClientServiceRequestId] - Identifier of the service request ID for which the invalidated
* client waited for a subscribe response.
*/
const handleSendLeaveRequestEvent = (
data: SendRequestEvent,
invalidatedClient?: PubNubClientState,
invalidatedClientServiceRequestId?: string,
) => {
const client = invalidatedClient ?? pubNubClients[data.clientIdentifier];
const request = leaveTransportRequestFromEvent(data, invalidatedClient);
if (!client) return;
// Clean up client subscription information if there is no more channels / groups to use.
const { subscription, heartbeat } = client;
const serviceRequestId = invalidatedClientServiceRequestId ?? subscription?.serviceRequestId;
if (subscription && subscription.channels.length === 0 && subscription.channelGroups.length === 0) {
subscription.channelGroupQuery = '';
subscription.path = '';
subscription.previousTimetoken = '0';
subscription.refreshTimestamp = Date.now();
subscription.timetoken = '0';
delete subscription.region;
delete subscription.serviceRequestId;
delete subscription.request;
}
if (serviceHeartbeatRequests[client.subscriptionKey]) {
if (heartbeat && heartbeat.channels.length === 0 && heartbeat.channelGroups.length === 0) {
const hbRequestsBySubscriptionKey = (serviceHeartbeatRequests[client.subscriptionKey] ??= {});
const heartbeatRequestKey = `${client.userId}_${clientAggregateAuthKey(client) ?? ''}`;
if (
hbRequestsBySubscriptionKey[heartbeatRequestKey] &&
hbRequestsBySubscriptionKey[heartbeatRequestKey].clientIdentifier === client.clientIdentifier
)
delete hbRequestsBySubscriptionKey[heartbeatRequestKey]!.clientIdentifier;
delete heartbeat.heartbeatEvent;
stopHeartbeatTimer(client);
}
}
if (!request) {
const body = new TextEncoder().encode('{"status": 200, "action": "leave", "message": "OK", "service":"Presence"}');
const headers = new Headers({
'Content-Type': 'text/javascript; charset="UTF-8"',
'Content-Length': `${body.length}`,
});
const response = new Response(body, { status: 200, headers });
const result = requestProcessingSuccess([response, body]);
result.url = `${data.request.origin}${data.request.path}`;
result.clientIdentifier = data.clientIdentifier;
result.identifier = data.request.identifier;
publishClientEvent(client, result);
return;
}
consoleLog(`Started leave request.`, client);
// Notify about request processing start.
for (const client of clientsForSendLeaveRequestEvent(data, invalidatedClient))
consoleLog({ messageType: 'network-request', message: request as unknown as Payload }, client);
sendRequest(
request,
() => [client],
(clients, fetchRequest, response) => {
// Notify each PubNub client which awaited for response.
notifyRequestProcessingResult(clients, fetchRequest, response, data.request);
},
(clients, fetchRequest, error) => {
// Notify each PubNub client which awaited for response.
notifyRequestProcessingResult(clients, fetchRequest, null, data.request, requestProcessingError(error));
},
);
// Check whether there were active subscription with channels from this client or not.
if (serviceRequestId === undefined) return;
// Update ongoing clients
const clients = clientsForRequest(serviceRequestId);
clients.forEach((client) => {
if (client && client.subscription) delete client.subscription.serviceRequestId;
});
cancelRequest(serviceRequestId);
restartSubscribeRequestForClients(clients);
};
/**
* Handle cancel request event.
*
* Try cancel request if there is no other observers.
*
* @param event - Request cancellation event details.
*/
const handleCancelRequestEvent = (event: CancelRequestEvent) => {
const client = pubNubClients[event.clientIdentifier];
if (!client || !client.subscription) return;
const serviceRequestId = client.subscription.serviceRequestId;
if (!client || !serviceRequestId) return;
// Unset awaited requests.
delete client.subscription.serviceRequestId;
if (client.subscription.request && client.subscription.request.identifier === event.identifier) {
delete client.subscription.request;
}
cancelRequest(serviceRequestId);
};
// endregion
// --------------------------------------------------------
// --------------------- Subscription ---------------------
// --------------------------------------------------------
// region Subscription
/**
* Try restart subscribe request for the list of clients.
*
* Subscribe restart will use previous timetoken information to schedule new subscription loop.
*
* **Note:** This function mimics behaviour when SharedWorker receives request from PubNub SDK.
*
* @param clients List of PubNub client states for which new aggregated request should be sent.
*/
const restartSubscribeRequestForClients = (clients: PubNubClientState[]) => {
let clientWithRequest: PubNubClientState | undefined;
let request: TransportRequest | undefined;
for (const client of clients) {
if (client.subscription && client.subscription.request) {
request = client.subscription.request;
clientWithRequest = client;
break;
}
}
if (!request || !clientWithRequest) return;
const sendRequest: SendRequestEvent = {
type: 'send-request',
clientIdentifier: clientWithRequest.clientIdentifier,
subscriptionKey: clientWithRequest.subscriptionKey,
request,
};
handleSendSubscribeRequestEventForClients([[clientWithRequest, sendRequest]], sendRequest);
};
// endregion
// --------------------------------------------------------
// ------------------------ Common ------------------------
// --------------------------------------------------------
// region Common
/**
* Process transport request.
*
* @param request - Transport request with required information for {@link Request} creation.
* @param getClients - Request completion PubNub client observers getter.
* @param success - Request success completion handler.
* @param failure - Request failure handler.
* @param responsePreProcess - Raw response pre-processing function which is used before calling handling callbacks.
*/
const sendRequest = (
request: TransportRequest,
getClients: () => PubNubClientState[],
success: (clients: PubNubClientState[], fetchRequest: Request, response: [Response, ArrayBuffer]) => void,
failure: (clients: PubNubClientState[], fetchRequest: Request, error: unknown) => void,
responsePreProcess?: (response: [Response, ArrayBuffer]) => [Response, ArrayBuffer],
) => {
(async () => {
const fetchRequest = requestFromTransportRequest(request);
Promise.race([
fetch(fetchRequest, {
signal: abortControllers.get(request.identifier)?.signal,
keepalive: true,
}),
requestTimeoutTimer(request.identifier, request.timeout),
])
.then((response): Promise<[Response, ArrayBuffer]> | [Response, ArrayBuffer] =>
response.arrayBuffer().then((buffer) => [response, buffer]),
)
.then((response) => (responsePreProcess ? responsePreProcess(response) : response))
.then((response) => {
const clients = getClients();
if (clients.length === 0) return;
success(clients, fetchRequest, response);
})
.catch((error) => {
const clients = getClients();
if (clients.length === 0) return;
let fetchError = error;
if (typeof error === 'string') {
const errorMessage = error.toLowerCase();
fetchError = new Error(error);
if (!errorMessage.includes('timeout') && errorMessage.includes('cancel')) fetchError.name = 'AbortError';
}
failure(clients, fetchRequest, fetchError);
});
})();
};
/**
* Cancel (abort) service request by ID.
*
* @param requestId - Unique identifier of request which should be cancelled.
*/
const cancelRequest = (requestId: string) => {
if (clientsForRequest(requestId).length === 0) {
const controller = abortControllers.get(requestId);
abortControllers.delete(requestId);
// Clean up scheduled requests.
delete serviceRequests[requestId];
// Abort request if possible.
if (controller) controller.abort('Cancel request');
}
};
/**
* Create request timeout timer.
*
* **Note:** Native Fetch API doesn't support `timeout` out-of-box and {@link Promise} used to emulate it.
*
* @param requestId - Unique identifier of request which will time out after {@link requestTimeout} seconds.
* @param requestTimeout - Number of seconds after which request with specified identifier will time out.
*
* @returns Promise which rejects after time out will fire.
*/
const requestTimeoutTimer = (requestId: string, requestTimeout: number) =>
new Promise<Response>((_, reject) => {
const timeoutId = setTimeout(() => {
// Clean up.
abortControllers.delete(requestId);
clearTimeout(timeoutId);
reject(new Error('Request timeout'));
}, requestTimeout * 1000);
});
/**
* Retrieve list of PubNub clients which is pending for service worker request completion.
*
* @param identifier - Identifier of the subscription request which has been scheduled by the Service Worker.
*
* @returns List of PubNub client state objects for Service Worker.
*/
const clientsForRequest = (identifier: string) => {
return Object.values(pubNubClients).filter(
(client): client is PubNubClientState =>
client !== undefined && client.subscription !== undefined && client.subscription.serviceRequestId === identifier,
);
};
/**
* Clean up PubNub client states from ongoing request.
*
* Reset requested and scheduled request information to make PubNub client "free" for next requests.
*
* @param clients - List of PubNub clients which awaited for scheduled request completion.
* @param requestId - Unique subscribe request identifier for which {@link clients} has been provided.
*/
const markRequestCompleted = (clients: PubNubClientState[], requestId: string) => {
delete serviceRequests[requestId];
clients.forEach((client) => {
if (client.subscription) {
delete client.subscription.request;
delete client.subscription.serviceRequestId;
}
});
};
/**
* Creates a Request object from a given {@link TransportRequest} object.
*
* @param req - The {@link TransportRequest} object containing request information.
*
* @returns `Request` object generated from the {@link TransportRequest} object or `undefined` if no request
* should be sent.
*/
const requestFromTransportRequest = (req: TransportRequest): Request => {
let headers: Record<string, string> | undefined = undefined;
const queryParameters = req.queryParameters;
let path = req.path;
if (req.headers) {
headers = {};
for (const [key, value] of Object.entries(req.headers)) headers[key] = value;
}
if (queryParameters && Object.keys(queryParameters).length !== 0)
path = `${path}?${queryStringFromObject(queryParameters)}`;
return new Request(`${req.origin!}${path}`, {
method: req.method,
headers,
redirect: 'follow',
});
};
/**
* Construct transport request from send subscription request event.
*
* Update transport request to aggregate channels and groups if possible.
*
* @param event - Client's send subscription event request.
*
* @returns Final transport request or identifier from active request which will provide response to required
* channels and groups.
*/
const subscribeTransportRequestFromEvent = (event: SendRequestEvent): TransportRequest | string => {
const client = pubNubClients[event.clientIdentifier]!;
const subscription = client.subscription!;
const clients = clientsForSendSubscribeRequestEvent(subscription.timetoken, event);
const serviceRequestId = uuidGenerator.createUUID();
const request = { ...event.request };
let previousSubscribeTimetokenRefreshTimestamp: number | undefined;
let previousSubscribeTimetoken: string | undefined;
let previousSubscribeRegion: string | undefined;
if (clients.length > 1) {
const activeRequestId = activeSubscriptionForEvent(clients, event);
// Return identifier of the ongoing request.
if (activeRequestId) {
const scheduledRequest = serviceRequests[activeRequestId];
const { channels, channelGroups } = client.subscription ?? { channels: [], channelGroups: [] };
if (
(channels.length > 0 ? includesStrings(scheduledRequest.channels, channels) : true) &&
(channelGroups.length > 0 ? includesStrings(scheduledRequest.channelGroups, channelGroups) : true)
) {
return activeRequestId;
}
}
const state = (presenceState[client.subscriptionKey] ?? {})[client.userId];
const aggregatedState: Record<string, Payload> = {};
const channelGroups = new Set(subscription.channelGroups);
const channels = new Set(subscription.channels);
if (state && subscription.objectsWithState.length) {
subscription.objectsWithState.forEach((name) => {
const objectState = state[name];
if (objectState) aggregatedState[name] = objectState;
});
}
for (const _client of clients) {
const { subscription: _subscription } = _client;
// Skip clients which doesn't have active subscription request.
if (!_subscription) continue;
// Keep track of timetoken from previous call to use it for catchup after initial subscribe.
if (_subscription.timetoken) {
let shouldSetPreviousTimetoken = !previousSubscribeTimetoken;
if (!shouldSetPreviousTimetoken && _subscription.timetoken !== '0') {
if (previousSubscribeTimetoken === '0') shouldSetPreviousTimetoken = true;
else if (_subscription.timetoken < previousSubscribeTimetoken!)
shouldSetPreviousTimetoken = _subscription.refreshTimestamp > previousSubscribeTimetokenRefreshTimestamp!;
}
if (shouldSetPreviousTimetoken) {
previousSubscribeTimetokenRefreshTimestamp = _subscription.refreshTimestamp;
previousSubscribeTimetoken = _subscription.timetoken;
previousSubscribeRegion = _subscription.region;
}
}
_subscription.channelGroups.forEach(channelGroups.add, channelGroups);
_subscription.channels.forEach(channels.add, channels);
const activeServiceRequestId = _subscription.serviceRequestId;
_subscription.serviceRequestId = serviceRequestId;
// Set awaited service worker request identifier.
if (activeServiceRequestId && serviceRequests[activeServiceRequestId]) {
cancelRequest(activeServiceRequestId);
}
if (!state) continue;
_subscription.objectsWithState.forEach((name) => {
const objectState = state[name];
if (objectState && !aggregatedState[name]) aggregatedState[name] = objectState;
});
}
const serviceRequest = (serviceRequests[serviceRequestId] ??= {
requestId: serviceRequestId,
timetoken: (request.queryParameters!.tt as string) ?? '0',
channelGroups: [],
channels: [],
});
// Update request channels list (if required).
if (channels.size) {
serviceRequest.channels = Array.from(channels).sort();
const pathComponents = request.path.split('/');
pathComponents[4] = serviceRequest.channels.join(',');
request.path = pathComponents.join('/');
}
// Update request channel groups list (if required).
if (channelGroups.size) {
serviceRequest.channelGroups = Array.from(channelGroups).sort();
request.queryParameters!['channel-group'] = serviceRequest.channelGroups.join(',');
}
// Update request `state` (if required).
if (Object.keys(aggregatedState).length) request.queryParameters!['state'] = JSON.stringify(aggregatedState);
// Update `auth` key (if required).
if (request.queryParameters && request.queryParameters.auth) {
const authKey = authKeyForAggregatedClientsRequest(clients);
if (authKey) request.queryParameters.auth = authKey;
}
} else {
serviceRequests[serviceRequestId] = {
requestId: serviceRequestId,
timetoken: (request.queryParameters!.tt as string) ?? '0',
channelGroups: subscription.channelGroups,
channels: subscription.channels,
};
}
if (serviceRequests[serviceRequestId]) {
if (
request.queryParameters &&
request.queryParameters.tt !== undefined &&
request.queryParameters.tr !== undefined
) {
serviceRequests[serviceRequestId].region = request.queryParameters.tr as string;
}
if (
!serviceRequests[serviceRequestId].timetokenOverride ||
(serviceRequests[serviceRequestId].timetokenOverride !== '0' &&
previousSubscribeTimetoken &&
previousSubscribeTimetoken !== '0')
) {
serviceRequests[serviceRequestId].timetokenOverride = previousSubscribeTimetoken;
serviceRequests[serviceRequestId].regionOverride = previousSubscribeRegion;
}
}
subscription.serviceRequestId = serviceRequestId;
request.identifier = serviceRequestId;
const clientIds = clients
.reduce((identifiers: string[], { clientIdentifier }) => {
identifiers.push(clientIdentifier);
return identifiers;
}, [])
.join(', ');
if (clientIds.length > 0) {
for (const _client of clients)
consoleDir(serviceRequests[serviceRequestId], `Started aggregated request for clients: ${clientIds}`, _client);
}
return request;
};
/**
* Construct transport request from send heartbeat request event.
*
* Update transport request to aggregate channels and groups if possible.
*
* @param event - Client's send heartbeat event request.
* @param [actualRequest] - Whether handling actual request from the core-part of the client and not backup heartbeat in
* the `SharedWorker`.
* @param [outOfOrder] - Whether handling request which is sent on irregular basis (setting update).
*
* @returns Final transport request or identifier from active request which will provide response to required
* channels and groups.
*/
const heartbeatTransportRequestFromEvent = (
event: SendRequestEvent,
actualRequest: boolean,
outOfOrder: boolean,
): TransportRequest | undefined => {
const client = pubNubClients[event.clientIdentifier];
const clients = clientsForSendHeartbeatRequestEvent(event);
const request = { ...event.request };
if (!client || !client.heartbeat) return undefined;
const hbRequestsBySubscriptionKey = (serviceHeartbeatRequests[client.subscriptionKey] ??= {});
const heartbeatRequestKey = `${client.userId}_${clientAggregateAuthKey(client) ?? ''}`;
const channelGroupsForAnnouncement: string[] = [...client.heartbeat.channelGroups];
const channelsForAnnouncement: string[] = [...client.heartbeat.channels];
let aggregatedState: Record<string, Payload | undefined>;
let failedPreviousRequest = false;
let aggregated: boolean;
if (!hbRequestsBySubscriptionKey[heartbeatRequestKey]) {
hbRequestsBySubscriptionKey[heartbeatRequestKey] = {
createdByActualRequest: actualRequest,
channels: channelsForAnnouncement,
channelGroups: channelGroupsForAnnouncement,
clientIdentifier: client.clientIdentifier,
timestamp: Date.now(),
};
aggregatedState = client.heartbeat.presenceState ?? {};
aggregated = false;
} else {
const { createdByActualRequest, channels, channelGroups, response } =
hbRequestsBySubscriptionKey[heartbeatRequestKey];
// Allow out-of-order call from the client for heartbeat initiated by the `SharedWorker`.
if (!createdByActualRequest && actualRequest) {
hbRequestsBySubscriptionKey[heartbeatRequestKey].createdByActualRequest = true;
hbRequestsBySubscriptionKey[heartbeatRequestKey].timestamp = Date.now();
outOfOrder = true;
}
aggregatedState = client.heartbeat.presenceState ?? {};
aggregated =
includesStrings(channels, channelsForAnnouncement) &&
includesStrings(channelGroups, channelGroupsForAnnouncement);
if (response) failedPreviousRequest = response[0].status >= 400;
}
// Find minimum heartbeat interval which maybe required to use.
let minimumHeartbeatInterval = client.heartbeatInterval!;
for (const client of clients) {
if (client.heartbeatInterval)
minimumHeartbeatInterval = Math.min(minimumHeartbeatInterval, client.heartbeatInterval);
}
// Check whether multiple instance aggregate heartbeat and there is previous sender known.
// `clientIdentifier` maybe empty in case if client which triggered heartbeats before has been invalidated and new
// should handle heartbeat unconditionally.
if (aggregated && hbRequestsBySubscriptionKey[heartbeatRequestKey].clientIdentifier) {
const expectedTimestamp =
hbRequestsBySubscriptionKey[heartbeatRequestKey].timestamp + minimumHeartbeatInterval * 1000;
const currentTimestamp = Date.now();
// Request should be sent if a previous attempt failed.
if (!outOfOrder && !failedPreviousRequest && currentTimestamp < expectedTimestamp) {
// Check whether it is too soon to send request or not.
const leeway = minimumHeartbeatInterval * 0.05 * 1000;
// Leeway can't be