UNPKG

pubnub

Version:

Publish & Subscribe Real-time Messaging with PubNub

995 lines (976 loc) 104 kB
(function (factory) { typeof define === 'function' && define.amd ? define(factory) : factory(); })((function () { 'use strict'; /****************************************************************************** Copyright (c) Microsoft Corporation. Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ***************************************************************************** */ /* global Reflect, Promise, SuppressedError, Symbol, Iterator */ function __awaiter(thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); } typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) { var e = new Error(message); return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e; }; /** * Enum representing possible transport methods for HTTP requests. * * @enum {number} */ var TransportMethod; (function (TransportMethod) { /** * Request will be sent using `GET` method. */ TransportMethod["GET"] = "GET"; /** * Request will be sent using `POST` method. */ TransportMethod["POST"] = "POST"; /** * Request will be sent using `PATCH` method. */ TransportMethod["PATCH"] = "PATCH"; /** * Request will be sent using `DELETE` method. */ TransportMethod["DELETE"] = "DELETE"; /** * Local request. * * Request won't be sent to the service and probably used to compute URL. */ TransportMethod["LOCAL"] = "LOCAL"; })(TransportMethod || (TransportMethod = {})); var commonjsGlobal = typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {}; function getDefaultExportFromCjs (x) { return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x; } var uuid = {exports: {}}; /*! lil-uuid - v0.1 - MIT License - https://github.com/lil-js/uuid */ uuid.exports; (function (module, exports) { (function (root, factory) { { factory(exports); if (module !== null) { module.exports = exports.uuid; } } }(commonjsGlobal, function (exports) { var VERSION = '0.1.0'; var uuidRegex = { '3': /^[0-9A-F]{8}-[0-9A-F]{4}-3[0-9A-F]{3}-[0-9A-F]{4}-[0-9A-F]{12}$/i, '4': /^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i, '5': /^[0-9A-F]{8}-[0-9A-F]{4}-5[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i, all: /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i }; function uuid() { var uuid = '', i, random; for (i = 0; i < 32; i++) { random = Math.random() * 16 | 0; if (i === 8 || i === 12 || i === 16 || i === 20) uuid += '-'; uuid += (i === 12 ? 4 : (i === 16 ? (random & 3 | 8) : random)).toString(16); } return uuid } function isUUID(str, version) { var pattern = uuidRegex[version || 'all']; return pattern && pattern.test(str) || false } uuid.isUUID = isUUID; uuid.VERSION = VERSION; exports.uuid = uuid; exports.isUUID = isUUID; })); } (uuid, uuid.exports)); var uuidExports = uuid.exports; var uuidGenerator$1 = /*@__PURE__*/getDefaultExportFromCjs(uuidExports); /** * Random identifier generator helper module. * * @internal */ /** @internal */ var uuidGenerator = { createUUID() { if (uuidGenerator$1.uuid) { return uuidGenerator$1.uuid(); } // @ts-expect-error Depending on module type it may be callable. return uuidGenerator$1(); }, }; /// <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 */ /** * 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 = new Map(); // region State /** * Per-subscription key map of "offline" clients detection timeouts. */ const pingTimeouts = {}; /** * 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 = new Map(); /** * Map of PubNub client identifiers to their state in the current Service Worker. */ const pubNubClients = {}; /** * Per-subscription key list of PubNub client state. */ const pubNubClientsBySubscriptionKey = {}; /** * Per-subscription key map of heartbeat request configurations recently used for user. */ const serviceHeartbeatRequests = {}; /** * Per-subscription key presence state associated with unique user identifiers with which {@link pubNubClients|clients} * scheduled subscription request. */ const presenceState = {}; /** * 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 = {}; /** * List of ongoing subscription requests. * * **Node:** Identifiers differ from request identifiers received in {@link SendRequestEvent} object. */ const serviceRequests = {}; // 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) => { // Ignoring unknown event payloads. if (!validateEventPayload(event)) return; const data = event.data; 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 = []; 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, event) => { 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, event, requestOrId, requestOrigin) => { var _a; 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":${(_a = scheduledRequest.region) !== null && _a !== void 0 ? _a : '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 }, 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, timetoken, region) => { if (timetoken === undefined || timetoken === '0' || serverResponse[0].status >= 400) { return serverResponse; } let json; 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, actualRequest = true, outOfOrder = false) => { var _a; const client = pubNubClients[event.clientIdentifier]; const request = heartbeatTransportRequestFromEvent(event, actualRequest, outOfOrder); if (!client) return; const heartbeatRequestKey = `${client.userId}_${(_a = clientAggregateAuthKey(client)) !== null && _a !== void 0 ? _a : ''}`; const hbRequestsBySubscriptionKey = serviceHeartbeatRequests[client.subscriptionKey]; const hbRequests = (hbRequestsBySubscriptionKey !== null && hbRequestsBySubscriptionKey !== void 0 ? 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; let body; // 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 }, 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, invalidatedClient, invalidatedClientServiceRequestId) => { var _a, _b; var _c; const client = invalidatedClient !== null && invalidatedClient !== void 0 ? 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 !== null && invalidatedClientServiceRequestId !== void 0 ? invalidatedClientServiceRequestId : subscription === null || subscription === void 0 ? void 0 : 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 = ((_a = serviceHeartbeatRequests[_c = client.subscriptionKey]) !== null && _a !== void 0 ? _a : (serviceHeartbeatRequests[_c] = {})); const heartbeatRequestKey = `${client.userId}_${(_b = clientAggregateAuthKey(client)) !== null && _b !== void 0 ? _b : ''}`; 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 }, 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) => { 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) => { let clientWithRequest; let request; for (const client of clients) { if (client.subscription && client.subscription.request) { request = client.subscription.request; clientWithRequest = client; break; } } if (!request || !clientWithRequest) return; const sendRequest = { 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, getClients, success, failure, responsePreProcess) => { (() => __awaiter(void 0, void 0, void 0, function* () { var _a; const fetchRequest = requestFromTransportRequest(request); Promise.race([ fetch(fetchRequest, { signal: (_a = abortControllers.get(request.identifier)) === null || _a === void 0 ? void 0 : _a.signal, keepalive: true, }), requestTimeoutTimer(request.identifier, request.timeout), ]) .then((response) => 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) => { 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, requestTimeout) => new Promise((_, 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) => { return Object.values(pubNubClients).filter((client) => 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, requestId) => { 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) => { let headers = 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) => { var _a, _b, _c, _d, _e; const client = pubNubClients[event.clientIdentifier]; const subscription = client.subscription; const clients = clientsForSendSubscribeRequestEvent(subscription.timetoken, event); const serviceRequestId = uuidGenerator.createUUID(); const request = Object.assign({}, event.request); let previousSubscribeTimetokenRefreshTimestamp; let previousSubscribeTimetoken; let previousSubscribeRegion; if (clients.length > 1) { const activeRequestId = activeSubscriptionForEvent(clients, event); // Return identifier of the ongoing request. if (activeRequestId) { const scheduledRequest = serviceRequests[activeRequestId]; const { channels, channelGroups } = (_a = client.subscription) !== null && _a !== void 0 ? _a : { channels: [], channelGroups: [] }; if ((channels.length > 0 ? includesStrings(scheduledRequest.channels, channels) : true) && (channelGroups.length > 0 ? includesStrings(scheduledRequest.channelGroups, channelGroups) : true)) { return activeRequestId; } } const state = ((_b = presenceState[client.subscriptionKey]) !== null && _b !== void 0 ? _b : {})[client.userId]; const aggregatedState = {}; 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 = ((_c = serviceRequests[serviceRequestId]) !== null && _c !== void 0 ? _c : (serviceRequests[serviceRequestId] = { requestId: serviceRequestId, timetoken: (_d = request.queryParameters.tt) !== null && _d !== void 0 ? _d : '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: (_e = request.queryParameters.tt) !== null && _e !== void 0 ? _e : '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; } 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, { 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, actualRequest, outOfOrder) => { var _a, _b, _c, _d; var _e; const client = pubNubClients[event.clientIdentifier]; const clients = clientsForSendHeartbeatRequestEvent(event); const request = Object.assign({}, event.request); if (!client || !client.heartbeat) return undefined; const hbRequestsBySubscriptionKey = ((_a = serviceHeartbeatRequests[_e = client.subscriptionKey]) !== null && _a !== void 0 ? _a : (serviceHeartbeatRequests[_e] = {})); const heartbeatRequestKey = `${client.userId}_${(_b = clientAggregateAuthKey(client)) !== null && _b !== void 0 ? _b : ''}`; const channelGroupsForAnnouncement = [...client.heartbeat.channelGroups]; const channelsForAnnouncement = [...client.heartbeat.channels]; let aggregatedState; let failedPreviousRequest = false; let aggregated; if (!hbRequestsBySubscriptionKey[heartbeatRequestKey]) { hbRequestsBySubscriptionKey[heartbeatRequestKey] = { createdByActualRequest: actualRequest, channels: channelsForAnnouncement, channelGroups: channelGroupsForAnnouncement, clientIdentifier: client.clientIdentifier, timestamp: Date.now(), }; aggregatedState = (_c = client.heartbeat.presenceState) !== null && _c !== void 0 ? _c : {}; 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 = (_d = client.heartbeat.presenceState) !== null && _d !== void 0 ? _d : {}; 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 applied if actual interval between heartbeat requests is smaller // than 3 seconds which derived from the server's threshold. if (minimumHeartbeatInterval - leeway <= 3 || expectedTimestamp - currentTimestamp > leeway) { startHeartbeatTimer(client, true); return undefined; } } } delete hbRequestsBySubscriptionKey[heartbeatRequestKey].response; hbRequestsBySubscriptionKey[heartbeatRequestKey].clientIdentifier = client.clientIdentifier; // Aggregate channels for similar clients which is pending for heartbeat. for (const _client of clients) { const { heartbeat } = _client; if (heartbeat === undefined || _client.clientIdentifier === event.clientIdentifier) continue; // Append presence state from the client (will override previously set value if already set). if (heartbeat.presenceState) aggregatedState = Object.assign(Object.assign({}, aggregatedState), heartbeat.presenceState); channelGroupsForAnnouncement.push(...heartbeat.channelGroups.filter((channel) => !channelGroupsForAnnouncement.includes(channel))); channelsForAnnouncement.push(...heartbeat.channels.filter((channel) => !channelsForAnnouncement.includes(channel))); } hbRequestsBySubscriptionKey[heartbeatRequestKey].channels = channelsForAnnouncement; hbRequestsBySubscriptionKey[heartbeatRequestKey].channelGroups = channelGroupsForAnnouncement; if (!outOfOrder) hbRequestsBySubscriptionKey[heartbeatRequestKey].timestamp = Date.now(); // Remove presence state for objects which is not part of heartbeat. for (const objectName in Object.keys(aggregatedState)) { if (!channelsForAnnouncement.includes(objectName) && !channelGroupsForAnnouncement.includes(objectName)) delete aggregatedState[objectName]; } // No need to try send request with empty list of channels and groups. if (channelsForAnnouncement.length === 0 && channelGroupsForAnnouncement.length === 0) return undefined; // Update request channels list (if required). if (channelsForAnnouncement.length || channelGroupsForAnnouncement.length) { const pathComponents = request.path.split('/'); pathComponents[6] = channelsForAnnouncement.length ? channelsForAnnouncement.join(',') : ','; request.path = pathComponents.join('/'); } // Update request channel groups list (if required). if (channelGroupsForAnnouncement.length) request.queryParameters['channel-group'] = channelGroupsForAnnouncement.join(','); // Update request `state` (if required). if (Object.keys(aggregatedState).length) request.queryParameters['state'] = JSON.stringify(aggregatedState); else delete request.queryParameters['state']; // Update `auth` key (if required). if (clients.length > 1 && request.queryParameters && request.queryParameters.auth) { const aggregatedAuthKey = authKeyForAggregatedClientsRequest(clients); if (aggregatedAuthKey) request.queryParameters.auth = aggregatedAuthKey; } return request; }; /** * Construct transport request from send leave request event. * * Filter out channels and groups, which is still in use by other PubNub client instance