UNPKG

pubnub

Version:

Publish & Subscribe Real-time Messaging with PubNub

627 lines (551 loc) 25.5 kB
import { PubNubClientEvent, PubNubClientSendLeaveEvent, PubNubClientAuthChangeEvent, PubNubClientSendSubscribeEvent, PubNubClientIdentityChangeEvent, PubNubClientCancelSubscribeEvent, PubNubClientPresenceStateChangeEvent, } from './custom-events/client-event'; import { PubNubClientsManagerEvent, PubNubClientManagerRegisterEvent, PubNubClientManagerUnregisterEvent, } from './custom-events/client-manager-event'; import { SubscriptionStateChangeEvent, SubscriptionStateEvent } from './custom-events/subscription-state-event'; import { SubscriptionState, SubscriptionStateChange } from './subscription-state'; import { PubNubClientsManager } from './pubnub-clients-manager'; import { SubscribeRequest } from './subscribe-request'; import { RequestsManager } from './requests-manager'; import { PubNubClient } from './pubnub-client'; import { LeaveRequest } from './leave-request'; import { leaveRequest } from './helpers'; /** * Aggregation timer timeout. * * Timeout used by the timer to postpone enqueued `subscribe` requests processing and let other clients for the same * subscribe key send next subscribe loop request (to make aggregation more efficient). */ const aggregationTimeout = 50; /** * Sent {@link SubscribeRequest|subscribe} requests manager. * * Manager responsible for requests enqueue for batch processing and aggregated `service`-provided requests scheduling. */ export class SubscribeRequestsManager extends RequestsManager { // -------------------------------------------------------- // ---------------------- Information --------------------- // -------------------------------------------------------- // region Information /** * Service response binary data decoder. */ private static textDecoder = new TextDecoder(); /** * Stringified to binary data encoder. */ private static textEncoder = new TextEncoder(); /** * Map of change aggregation identifiers to the requests which should be processed at once. * * `requests` key contains a map of {@link PubNubClient|PubNub} client identifiers to requests created by it (usually * there is only one at a time). */ private requestsChangeAggregationQueue: { [key: string]: { timeout: ReturnType<typeof setTimeout>; changes: Set<SubscriptionStateChange> }; } = {}; /** * Map of client identifiers to {@link AbortController} instances which is used to detach added listeners when * {@link PubNubClient|PubNub} client unregisters. */ private readonly clientAbortControllers: Record<string, AbortController> = {}; /** * Map of unique user identifier (composed from multiple request object properties) to the aggregated subscription * {@link SubscriptionState|state}. */ private readonly subscriptionStates: Record<string, SubscriptionState> = {}; // endregion // -------------------------------------------------------- // --------------------- Constructors --------------------- // -------------------------------------------------------- // region Constructors /** * Create a {@link SubscribeRequest|subscribe} requests manager. * * @param clientsManager - Reference to the {@link PubNubClient|PubNub} clients manager as an events source for new * clients for which {@link SubscribeRequest|subscribe} request sending events should be listened. */ constructor(private readonly clientsManager: PubNubClientsManager) { super(); this.addEventListenersForClientsManager(clientsManager); } // endregion // -------------------------------------------------------- // ----------------- Changes aggregation ------------------ // -------------------------------------------------------- // region Changes aggregation /** * Retrieve {@link SubscribeRequest|requests} changes aggregation queue for specific {@link PubNubClient|PubNub} * client. * * @param client - Reference to {@link PubNubClient|PubNub} client for which {@link SubscribeRequest|subscribe} * requests queue should be retrieved. * @returns Tuple with aggregation key and aggregated changes of client's {@link SubscribeRequest|subscribe} requests * that are enqueued for aggregation/removal. */ private requestsChangeAggregationQueueForClient( client: PubNubClient, ): [string | undefined, Set<SubscriptionStateChange>] { for (const aggregationKey of Object.keys(this.requestsChangeAggregationQueue)) { const { changes } = this.requestsChangeAggregationQueue[aggregationKey]; if (Array.from(changes).some((change) => change.clientIdentifier === client.identifier)) return [aggregationKey, changes]; } return [undefined, new Set<SubscriptionStateChange>()]; } /** * Move {@link PubNubClient|PubNub} client to new subscription set. * * This function used when PubNub client changed its identity (`userId`) or auth (`access token`) and can't be * aggregated with previous requests. * * **Note:** Previous `service`-provided `subscribe` request won't be canceled. * * @param client - Reference to the {@link PubNubClient|PubNub} client which should be moved to new state. */ private moveClient(client: PubNubClient) { // Retrieve a list of client's requests that have been enqueued for further aggregation. const [queueIdentifier, enqueuedChanges] = this.requestsChangeAggregationQueueForClient(client); // Retrieve list of client's requests from active subscription state. let state = this.subscriptionStateForClient(client); const request = state?.requestForClient(client); // Check whether PubNub client has any activity prior removal or not. if (!state && !enqueuedChanges.size) return; // Make sure that client will be removed from its previous subscription state. if (state) state.invalidateClient(client); // Requests aggregation identifier. let identifier = request?.asIdentifier; if (!identifier && enqueuedChanges.size) { const [change] = enqueuedChanges; identifier = change.request.asIdentifier; } if (!identifier) return; if (request) { // Unset `service`-provided request because we can't receive a response with new `userId`. request.serviceRequest = undefined; state!.processChanges([new SubscriptionStateChange(client.identifier, request, true, false, true)]); state = this.subscriptionStateForIdentifier(identifier); // Force state refresh (because we are putting into new subscription set). request.resetToInitialRequest(); state!.processChanges([new SubscriptionStateChange(client.identifier, request, false, false)]); } // Check whether there is enqueued request changes which should be removed from previous queue and added to the new // one. if (!enqueuedChanges.size || !this.requestsChangeAggregationQueue[queueIdentifier!]) return; // Start the changes aggregation timer if required (this also prepares the queue for `identifier`). this.startAggregationTimer(identifier); // Remove from previous aggregation queue. const oldChangesQueue = this.requestsChangeAggregationQueue[queueIdentifier!].changes; SubscriptionStateChange.squashedChanges([...enqueuedChanges]) .filter((change) => change.clientIdentifier !== client.identifier || change.remove) .forEach(oldChangesQueue.delete, oldChangesQueue); // Add previously scheduled for aggregation requests to the new subscription set target. const { changes } = this.requestsChangeAggregationQueue[identifier]; SubscriptionStateChange.squashedChanges([...enqueuedChanges]) .filter( (change) => change.clientIdentifier === client.identifier && !change.request.completed && change.request.canceled && !change.remove, ) .forEach(changes.add, changes); } /** * Remove unregistered/disconnected {@link PubNubClient|PubNub} client from manager's {@link SubscriptionState|state}. * * @param client - Reference to the {@link PubNubClient|PubNub} client which should be removed from * {@link SubscriptionState|state}. * @param useChangeAggregation - Whether {@link PubNubClient|client} removal should be processed using an aggregation * queue or change should be done on-the-fly by removing from both the aggregation queue and subscription state. * @param sendLeave - Whether the {@link PubNubClient|client} should send a presence `leave` request for _free_ * channels and groups or not. * @param [invalidated=false] - Whether the {@link PubNubClient|PubNub} client and its request were removed as part of * client invalidation (unregister) or not. */ private removeClient(client: PubNubClient, useChangeAggregation: boolean, sendLeave: boolean, invalidated = false) { // Retrieve a list of client's requests that have been enqueued for further aggregation. const [queueIdentifier, enqueuedChanges] = this.requestsChangeAggregationQueueForClient(client); // Retrieve list of client's requests from active subscription state. const state = this.subscriptionStateForClient(client); const request = state?.requestForClient(client, invalidated); // Check whether PubNub client has any activity prior removal or not. if (!state && !enqueuedChanges.size) return; const identifier = (state && state.identifier) ?? queueIdentifier!; // Remove the client's subscription requests from the active aggregation queue. if (enqueuedChanges.size && this.requestsChangeAggregationQueue[identifier]) { const { changes } = this.requestsChangeAggregationQueue[identifier]; enqueuedChanges.forEach(changes.delete, changes); this.stopAggregationTimerIfEmptyQueue(identifier); } if (!request) return; // Detach `client`-provided request to avoid unexpected response processing. request.serviceRequest = undefined; if (useChangeAggregation) { // Start the changes aggregation timer if required (this also prepares the queue for `identifier`). this.startAggregationTimer(identifier); // Enqueue requests into the aggregated state change queue (delayed). this.enqueueForAggregation(client, request, true, sendLeave, invalidated); } else if (state) state.processChanges([new SubscriptionStateChange(client.identifier, request, true, sendLeave, invalidated)]); } /** * Enqueue {@link SubscribeRequest|subscribe} requests for aggregation after small delay. * * @param client - Reference to the {@link PubNubClient|PubNub} client which created * {@link SubscribeRequest|subscribe} request. * @param enqueuedRequest - {@link SubscribeRequest|Subscribe} request which should be placed into the queue. * @param removing - Whether requests enqueued for removal or not. * @param sendLeave - Whether on remove it should leave "free" channels and groups or not. * @param [clientInvalidate=false] - Whether the `subscription` state change was caused by the * {@link PubNubClient|PubNub} client invalidation (unregister) or not. */ private enqueueForAggregation( client: PubNubClient, enqueuedRequest: SubscribeRequest, removing: boolean, sendLeave: boolean, clientInvalidate = false, ) { const identifier = enqueuedRequest.asIdentifier; // Start the changes aggregation timer if required (this also prepares the queue for `identifier`). this.startAggregationTimer(identifier); // Enqueue requests into the aggregated state change queue. const { changes } = this.requestsChangeAggregationQueue[identifier]; changes.add(new SubscriptionStateChange(client.identifier, enqueuedRequest, removing, sendLeave, clientInvalidate)); } /** * Start requests change aggregation timer. * * @param identifier - Similar {@link SubscribeRequest|subscribe} requests aggregation identifier. */ private startAggregationTimer(identifier: string) { if (this.requestsChangeAggregationQueue[identifier]) return; this.requestsChangeAggregationQueue[identifier] = { timeout: setTimeout(() => this.handleDelayedAggregation(identifier), aggregationTimeout), changes: new Set<SubscriptionStateChange>(), }; } /** * Stop request changes aggregation timer if there is no changes left in queue. * * @param identifier - Similar {@link SubscribeRequest|subscribe} requests aggregation identifier. */ private stopAggregationTimerIfEmptyQueue(identifier: string) { const queue = this.requestsChangeAggregationQueue[identifier]; if (!queue) return; if (queue.changes.size === 0) { if (queue.timeout) clearTimeout(queue.timeout); delete this.requestsChangeAggregationQueue[identifier]; } } /** * Handle delayed {@link SubscribeRequest|subscribe} requests aggregation. * * @param identifier - Similar {@link SubscribeRequest|subscribe} requests aggregation identifier. */ private handleDelayedAggregation(identifier: string) { if (!this.requestsChangeAggregationQueue[identifier]) return; const state = this.subscriptionStateForIdentifier(identifier); // Squash self-excluding change entries. const changes = [...this.requestsChangeAggregationQueue[identifier].changes]; delete this.requestsChangeAggregationQueue[identifier]; // Apply final changes to the subscription state. state.processChanges(changes); } /** * Retrieve existing or create new `subscription` {@link SubscriptionState|state} object for id. * * @param identifier - Similar {@link SubscribeRequest|subscribe} requests aggregation identifier. * @returns Existing or create new `subscription` {@link SubscriptionState|state} object for id. */ private subscriptionStateForIdentifier(identifier: string) { let state = this.subscriptionStates[identifier]; if (!state) { state = this.subscriptionStates[identifier] = new SubscriptionState(identifier); // Make sure to receive updates from subscription state. this.addListenerForSubscriptionStateEvents(state); } return state; } // endregion // -------------------------------------------------------- // ------------------- Event Handlers --------------------- // -------------------------------------------------------- // region Event handlers /** * Listen for {@link PubNubClient|PubNub} clients {@link PubNubClientsManager|manager} events that affect aggregated * subscribe/heartbeat requests. * * @param clientsManager - Clients {@link PubNubClientsManager|manager} for which change in * {@link PubNubClient|clients} should be tracked. */ private addEventListenersForClientsManager(clientsManager: PubNubClientsManager) { clientsManager.addEventListener(PubNubClientsManagerEvent.Registered, (evt) => { const { client } = evt as PubNubClientManagerRegisterEvent; // Keep track of the client's listener abort controller. const abortController = new AbortController(); this.clientAbortControllers[client.identifier] = abortController; client.addEventListener( PubNubClientEvent.IdentityChange, (event) => { if (!(event instanceof PubNubClientIdentityChangeEvent)) return; // Make changes into state only if `userId` actually changed. if ( !!event.oldUserId !== !!event.newUserId || (event.oldUserId && event.newUserId && event.newUserId !== event.oldUserId) ) this.moveClient(client); }, { signal: abortController.signal, }, ); client.addEventListener( PubNubClientEvent.AuthChange, (event) => { if (!(event instanceof PubNubClientAuthChangeEvent)) return; // Check whether the client should be moved to another state because of a permissions change or whether the // same token with the same permissions should be used for the next requests. if ( !!event.oldAuth !== !!event.newAuth || (event.oldAuth && event.newAuth && !event.oldAuth.equalTo(event.newAuth)) ) this.moveClient(client); else if (event.oldAuth && event.newAuth && event.oldAuth.equalTo(event.newAuth)) this.subscriptionStateForClient(client)?.updateClientAccessToken(event.newAuth); }, { signal: abortController.signal, }, ); client.addEventListener( PubNubClientEvent.PresenceStateChange, (event) => { if (!(event instanceof PubNubClientPresenceStateChangeEvent)) return; this.subscriptionStateForClient(event.client)?.updateClientPresenceState(event.client, event.state); }, { signal: abortController.signal }, ); client.addEventListener( PubNubClientEvent.SendSubscribeRequest, (event) => { if (!(event instanceof PubNubClientSendSubscribeEvent)) return; this.enqueueForAggregation(event.client, event.request, false, false); }, { signal: abortController.signal }, ); client.addEventListener( PubNubClientEvent.CancelSubscribeRequest, (event) => { if (!(event instanceof PubNubClientCancelSubscribeEvent)) return; this.enqueueForAggregation(event.client, event.request, true, false); }, { signal: abortController.signal }, ); client.addEventListener( PubNubClientEvent.SendLeaveRequest, (event) => { if (!(event instanceof PubNubClientSendLeaveEvent)) return; const request = this.patchedLeaveRequest(event.request); if (!request) return; this.sendRequest( request, (fetchRequest, response) => request.handleProcessingSuccess(fetchRequest, response), (fetchRequest, errorResponse) => request.handleProcessingError(fetchRequest, errorResponse), ); }, { signal: abortController.signal }, ); }); clientsManager.addEventListener(PubNubClientsManagerEvent.Unregistered, (event) => { const { client, withLeave } = event as PubNubClientManagerUnregisterEvent; // Remove all listeners added for the client. const abortController = this.clientAbortControllers[client.identifier]; delete this.clientAbortControllers[client.identifier]; if (abortController) abortController.abort(); // Update manager's state. this.removeClient(client, false, withLeave, true); }); } /** * Listen for subscription {@link SubscriptionState|state} events. * * @param state - Reference to the subscription object for which listeners should be added. */ private addListenerForSubscriptionStateEvents(state: SubscriptionState) { const abortController = new AbortController(); state.addEventListener( SubscriptionStateEvent.Changed, (event) => { const { requestsWithInitialResponse, canceledRequests, newRequests, leaveRequest } = event as SubscriptionStateChangeEvent; // Cancel outdated ongoing `service`-provided subscribe requests. canceledRequests.forEach((request) => request.cancel('Cancel request')); // Schedule new `service`-provided subscribe requests processing. newRequests.forEach((request) => { this.sendRequest( request, (fetchRequest, response) => request.handleProcessingSuccess(fetchRequest, response), (fetchRequest, error) => request.handleProcessingError(fetchRequest, error), request.isInitialSubscribe && request.timetokenOverride !== '0' ? (response) => this.patchInitialSubscribeResponse( response, request.timetokenOverride, request.timetokenRegionOverride, ) : undefined, ); }); requestsWithInitialResponse.forEach((response) => { const { request, timetoken, region } = response; request.handleProcessingStarted(); this.makeResponseOnHandshakeRequest(request, timetoken, region); }); if (leaveRequest) { this.sendRequest( leaveRequest, (fetchRequest, response) => leaveRequest.handleProcessingSuccess(fetchRequest, response), (fetchRequest, error) => leaveRequest.handleProcessingError(fetchRequest, error), ); } }, { signal: abortController.signal }, ); state.addEventListener( SubscriptionStateEvent.Invalidated, () => { delete this.subscriptionStates[state.identifier]; abortController.abort(); }, { signal: abortController.signal, once: true, }, ); } // endregion // -------------------------------------------------------- // ----------------------- Helpers ------------------------ // -------------------------------------------------------- // region Helpers /** * Retrieve subscription {@link SubscriptionState|state} with which specific client is working. * * @param client - Reference to the {@link PubNubClient|PubNub} client for which subscription * {@link SubscriptionState|state} should be found. * @returns Reference to the subscription {@link SubscriptionState|state} if the client has ongoing * {@link SubscribeRequest|requests}. */ private subscriptionStateForClient(client: PubNubClient) { return Object.values(this.subscriptionStates).find((state) => state.hasStateForClient(client)); } /** * Create `service`-provided `leave` request from a `client`-provided {@link LeaveRequest|request} with channels and * groups for removal. * * @param request - Original `client`-provided `leave` {@link LeaveRequest|request}. * @returns `service`-provided `leave` request. */ private patchedLeaveRequest(request: LeaveRequest) { const subscriptionState = this.subscriptionStateForClient(request.client); // Something is wrong. Client doesn't have any active subscriptions. if (!subscriptionState) { request.cancel(); return; } // Filter list from channels and groups which is still in use. const clientStateForLeave = subscriptionState.uniqueStateForClient( request.client, request.channels, request.channelGroups, ); const serviceRequest = leaveRequest( request.client, clientStateForLeave.channels, clientStateForLeave.channelGroups, ); if (serviceRequest) request.serviceRequest = serviceRequest; return serviceRequest; } /** * Return "response" from PubNub service with initial timetoken data. * * @param request - Client-provided handshake/initial request for which response should be provided. * @param timetoken - Timetoken from currently active service request. * @param region - Region from currently active service request. */ private makeResponseOnHandshakeRequest(request: SubscribeRequest, timetoken: string, region: string) { const body = new TextEncoder().encode(`{"t":{"t":"${timetoken}","r":${region ?? '0'}},"m":[]}`); request.handleProcessingSuccess(request.asFetchRequest, { type: 'request-process-success', clientIdentifier: '', identifier: '', url: '', response: { contentType: 'text/javascript; charset="UTF-8"', contentLength: body.length, headers: { 'content-type': 'text/javascript; charset="UTF-8"', 'content-length': `${body.length}` }, status: 200, body, }, }); } /** * Patch `service`-provided subscribe response with new timetoken and region. * * @param serverResponse - Original service response for patching. * @param timetoken - Original timetoken override value. * @param region - Original timetoken region override value. * @returns Patched subscribe REST API response. */ private 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(SubscribeRequestsManager.textDecoder.decode(body)); } catch (error) { console.error(`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 = SubscribeRequestsManager.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) { console.error(`Subscribe serialization error: ${error}`); return serverResponse; } return body.byteLength > 0 ? [decidedResponse, body] : serverResponse; } // endregion }