UNPKG

pubnub

Version:

Publish & Subscribe Real-time Messaging with PubNub

1,551 lines (1,320 loc) 99.5 kB
/// <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