pubnub
Version:
Publish & Subscribe Real-time Messaging with PubNub
781 lines (681 loc) • 27.6 kB
text/typescript
import {
RequestErrorEvent,
RequestStartEvent,
RequestCancelEvent,
RequestSuccessEvent,
PubNubSharedWorkerRequestEvents,
} from './custom-events/request-processing-event';
import { RequestSendingError, RequestSendingResult, RequestSendingSuccess } from '../subscription-worker-types';
import { TransportRequest } from '../../../core/types/transport-request';
import { Query } from '../../../core/types/api';
import { PubNubClient } from './pubnub-client';
import { AccessToken } from './access-token';
/**
* Base shared worker request implementation.
*
* In the `SharedWorker` context, this base class is used both for `client`-provided (they won't be used for actual
* request) and those that are created by `SharedWorker` code (`service` request, which will be used in actual
* requests).
*
* **Note:** The term `service` request in inline documentation will mean request created by `SharedWorker` and used to
* call PubNub REST API.
*/
export class BasePubNubRequest extends EventTarget {
// --------------------------------------------------------
// ---------------------- Information ---------------------
// --------------------------------------------------------
// region Information
/**
* Starter request processing timeout timer.
*/
private _fetchTimeoutTimer: ReturnType<typeof setTimeout> | undefined;
/**
* Map of attached to the service request `client`-provided requests by their request identifiers.
*
* **Context:** `service`-provided requests only.
*/
private dependents: Record<string, BasePubNubRequest> = {};
/**
* Controller, which is used to cancel ongoing `service`-provided request by signaling {@link fetch}.
*/
private _fetchAbortController?: AbortController;
/**
* Service request (aggregated/modified) which will actually be used to call the REST API endpoint.
*
* This is used only by `client`-provided requests to be notified on service request (aggregated/modified) processing
* stages.
*
* **Context:** `client`-provided requests only.
*/
private _serviceRequest?: BasePubNubRequest;
/**
* Controller, which is used to clean up any event listeners added by `client`-provided request on `service`-provided
* request.
*
* **Context:** `client`-provided requests only.
*/
private abortController?: AbortController;
/**
* Whether the request already received a service response or an error.
*
* **Important:** Any interaction with completed requests except requesting properties is prohibited.
*/
private _completed: boolean = false;
/**
* Whether request has been cancelled or not.
*
* **Important:** Any interaction with canceled requests except requesting properties is prohibited.
*/
private _canceled: boolean = false;
/**
* Access token with permissions to access provided `channels`and `channelGroups` on behalf of `userId`.
*/
private _accessToken?: AccessToken;
/**
* Reference to {@link PubNubClient|PubNub} client instance which created this request.
*
* **Context:** `client`-provided requests only.
*/
private _client?: PubNubClient;
/**
* Unique user identifier from the name of which request will be made.
*/
private _userId: string;
// endregion
// --------------------------------------------------------
// --------------------- Constructors ---------------------
// --------------------------------------------------------
// region Constructors
/**
* Create request object.
*
* @param request - Transport request.
* @param subscribeKey - Subscribe REST API access key.
* @param userId - Unique user identifier from the name of which request will be made.
* @param channels - List of channels used in request.
* @param channelGroups - List of channel groups used in request.
* @param [accessToken] - Access token with permissions to access provided `channels` and `channelGroups` on behalf of
* `userId`.
*/
constructor(
readonly request: TransportRequest,
readonly subscribeKey: string,
userId: string,
readonly channels: string[],
readonly channelGroups: string[],
accessToken?: AccessToken,
) {
super();
this._accessToken = accessToken;
this._userId = userId;
}
// endregion
// --------------------------------------------------------
// ---------------------- Properties ----------------------
// --------------------------------------------------------
// region Properties
/**
* Get the request's unique identifier.
*
* @returns Request's unique identifier.
*/
get identifier() {
return this.request.identifier;
}
/**
* Retrieve the origin that is used to access PubNub REST API.
*
* @returns Origin, which is used to access PubNub REST API.
*/
get origin() {
return this.request.origin;
}
/**
* Retrieve the unique user identifier from the name of which request will be made.
*
* @returns Unique user identifier from the name of which request will be made.
*/
get userId() {
return this._userId;
}
/**
* Update the unique user identifier from the name of which request will be made.
*
* @param value - New unique user identifier.
*/
set userId(value: string) {
this._userId = value;
// Patch underlying transport request query parameters to use new value.
this.request.queryParameters!.uuid = value;
}
/**
* Retrieve access token with permissions to access provided `channels` and `channelGroups`.
*
* @returns Access token with permissions for {@link userId} or `undefined` if not set.
*/
get accessToken() {
return this._accessToken;
}
/**
* Update the access token which should be used to access provided `channels` and `channelGroups` by the user with
* {@link userId}.
*
* @param [value] - Access token with permissions for {@link userId}.
*/
set accessToken(value: AccessToken | undefined) {
this._accessToken = value;
// Patch underlying transport request query parameters to use new value.
if (value) this.request.queryParameters!.auth = value.toString();
else delete this.request.queryParameters!.auth;
}
/**
* Retrieve {@link PubNubClient|PubNub} client associates with request.
*
* **Context:** `client`-provided requests only.
*
* @returns Reference to the {@link PubNubClient|PubNub} client that is sending the request.
*/
get client() {
return this._client!;
}
/**
* Associate request with PubNub client.
*
* **Context:** `client`-provided requests only.
*
* @param value - {@link PubNubClient|PubNub} client that created request in `SharedWorker` context.
*/
set client(value: PubNubClient) {
this._client = value;
}
/**
* Retrieve whether the request already received a service response or an error.
*
* @returns `true` if request already completed processing (not with {@link cancel}).
*/
get completed() {
return this._completed;
}
/**
* Retrieve whether the request can be cancelled or not.
*
* @returns `true` if there is a possibility and meaning to be able to cancel the request.
*/
get cancellable() {
return this.request.cancellable;
}
/**
* Retrieve whether the request has been canceled prior to completion or not.
*
* @returns `true` if the request didn't complete processing.
*/
get canceled() {
return this._canceled;
}
/**
* Update controller, which is used to cancel ongoing `service`-provided requests by signaling {@link fetch}.
*
* **Context:** `service`-provided requests only.
*
* @param value - Controller that has been used to signal {@link fetch} for request cancellation.
*/
set fetchAbortController(value: AbortController) {
// There is no point in completed request `fetch` abort controller set.
if (this.completed || this.canceled) return;
// Fetch abort controller can't be set for `client`-provided requests.
if (!this.isServiceRequest) {
console.error('Unexpected attempt to set fetch abort controller on client-provided request.');
return;
}
if (this._fetchAbortController) {
console.error('Only one abort controller can be set for service-provided requests.');
return;
}
this._fetchAbortController = value;
}
/**
* Retrieve `service`-provided fetch request abort controller.
*
* **Context:** `service`-provided requests only.
*
* @returns `service`-provided fetch request abort controller.
*/
get fetchAbortController() {
return this._fetchAbortController!;
}
/**
* Represent transport request as {@link fetch} {@link Request}.
*
* @returns Ready-to-use {@link Request} instance.
*/
get asFetchRequest(): Request {
const queryParameters = this.request.queryParameters;
const headers: Record<string, string> = {};
let query = '';
if (this.request.headers) for (const [key, value] of Object.entries(this.request.headers)) headers[key] = value;
if (queryParameters && Object.keys(queryParameters).length !== 0)
query = `?${this.queryStringFromObject(queryParameters)}`;
return new Request(`${this.origin}${this.request.path}${query}`, {
method: this.request.method,
headers: Object.keys(headers).length ? headers : undefined,
redirect: 'follow',
});
}
/**
* Retrieve the service (aggregated/modified) request, which will actually be used to call the REST API endpoint.
*
* **Context:** `client`-provided requests only.
*
* @returns Service (aggregated/modified) request, which will actually be used to call the REST API endpoint.
*/
get serviceRequest() {
return this._serviceRequest;
}
/**
* Link request processing results to the service (aggregated/modified) request.
*
* **Context:** `client`-provided requests only.
*
* @param value - Service (aggregated/modified) request for which process progress should be observed.
*/
set serviceRequest(value: BasePubNubRequest | undefined) {
// This function shouldn't be called even unintentionally, on the `service`-provided requests.
if (this.isServiceRequest) {
console.error('Unexpected attempt to set service-provided request on service-provided request.');
return;
}
const previousServiceRequest = this.serviceRequest;
this._serviceRequest = value;
// Detach from the previous service request if it has been changed (to a new one or unset).
if (previousServiceRequest && (!value || previousServiceRequest.identifier !== value.identifier))
previousServiceRequest.detachRequest(this);
// There is no need to set attach to service request if either of them is already completed, or canceled.
if (this.completed || this.canceled || (value && (value.completed || value.canceled))) {
this._serviceRequest = undefined;
return;
}
if (previousServiceRequest && value && previousServiceRequest.identifier === value.identifier) return;
// Attach the request to the service request processing results.
if (value) value.attachRequest(this);
}
/**
* Retrieve whether the receiver is a `service`-provided request or not.
*
* @returns `true` if the request has been created by the `SharedWorker`.
*/
get isServiceRequest() {
return !this.client;
}
// endregion
// --------------------------------------------------------
// ---------------------- Dependency ----------------------
// --------------------------------------------------------
// region Dependency
/**
* Retrieve a list of `client`-provided requests that have been attached to the `service`-provided request.
*
* **Context:** `service`-provided requests only.
*
* @returns List of attached `client`-provided requests.
*/
dependentRequests<T extends BasePubNubRequest>(): T[] {
// Return an empty list for `client`-provided requests.
if (!this.isServiceRequest) return [];
return Object.values(this.dependents) as T[];
}
/**
* Attach the `client`-provided request to the receiver (`service`-provided request) to receive a response from the
* PubNub REST API.
*
* **Context:** `service`-provided requests only.
*
* @param request - `client`-provided request that should be attached to the receiver (`service`-provided request).
*/
private attachRequest(request: BasePubNubRequest) {
// Request attachments works only on service requests.
if (!this.isServiceRequest || this.dependents[request.identifier]) {
if (!this.isServiceRequest) console.error('Unexpected attempt to attach requests using client-provided request.');
return;
}
this.dependents[request.identifier] = request;
this.addEventListenersForRequest(request);
}
/**
* Detach the `client`-provided request from the receiver (`service`-provided request) to ignore any response from the
* PubNub REST API.
*
* **Context:** `service`-provided requests only.
*
* @param request - `client`-provided request that should be attached to the receiver (`service`-provided request).
*/
private detachRequest(request: BasePubNubRequest) {
// Request detachments works only on service requests.
if (!this.isServiceRequest || !this.dependents[request.identifier]) {
if (!this.isServiceRequest) console.error('Unexpected attempt to detach requests using client-provided request.');
return;
}
delete this.dependents[request.identifier];
request.removeEventListenersFromRequest();
// Because `service`-provided requests are created in response to the `client`-provided one we need to cancel the
// receiver if there are no more attached `client`-provided requests.
// This ensures that there will be no abandoned/dangling `service`-provided request in `SharedWorker` structures.
if (Object.keys(this.dependents).length === 0) this.cancel('Cancel request');
}
// endregion
// --------------------------------------------------------
// ------------------ Request processing ------------------
// --------------------------------------------------------
// region Request processing
/**
* Notify listeners that ongoing request processing has been cancelled.
*
* **Note:** The current implementation doesn't let {@link PubNubClient|PubNub} directly call
* {@link cancel}, and it can be called from `SharedWorker` code logic.
*
* **Important:** Previously attached `client`-provided requests should be re-attached to another `service`-provided
* request or properly cancelled with {@link PubNubClient|PubNub} notification of the core PubNub client module.
*
* @param [reason] - Reason because of which the request has been cancelled. The request manager uses this to specify
* whether the `service`-provided request has been cancelled on-demand or because of timeout.
* @param [notifyDependent] - Whether dependent requests should receive cancellation error or not.
* @returns List of detached `client`-provided requests.
*/
cancel(reason?: string, notifyDependent: boolean = false) {
// There is no point in completed request cancellation.
if (this.completed || this.canceled) {
return [];
}
const dependentRequests = this.dependentRequests();
if (this.isServiceRequest) {
// Detach request if not interested in receiving request cancellation error (because of timeout).
// When switching between aggregated `service`-provided requests there is no need in handling cancellation of
// outdated request.
if (!notifyDependent) dependentRequests.forEach((request) => (request.serviceRequest = undefined));
if (this._fetchAbortController) {
this._fetchAbortController.abort(reason);
this._fetchAbortController = undefined;
}
} else this.serviceRequest = undefined;
this._canceled = true;
this.stopRequestTimeoutTimer();
this.dispatchEvent(new RequestCancelEvent(this));
return dependentRequests;
}
/**
* Create and return running request processing timeout timer.
*
* @returns Promise with timout timer resolution.
*/
requestTimeoutTimer() {
return new Promise<Response>((_, reject) => {
this._fetchTimeoutTimer = setTimeout(() => {
reject(new Error('Request timeout'));
this.cancel('Cancel because of timeout', true);
}, this.request.timeout * 1000);
});
}
/**
* Stop request processing timeout timer without error.
*/
stopRequestTimeoutTimer() {
if (!this._fetchTimeoutTimer) return;
clearTimeout(this._fetchTimeoutTimer);
this._fetchTimeoutTimer = undefined;
}
/**
* Handle request processing started by the request manager (actual sending).
*/
handleProcessingStarted() {
// Log out request processing start (will be made only for client-provided request).
this.logRequestStart(this);
this.dispatchEvent(new RequestStartEvent(this));
}
/**
* Handle request processing successfully completed by request manager (actual sending).
*
* @param fetchRequest - Reference to the actual request that has been used with {@link fetch}.
* @param response - PubNub service response which is ready to be sent to the core PubNub client module.
*/
handleProcessingSuccess(fetchRequest: Request, response: RequestSendingSuccess) {
this.addRequestInformationForResult(this, fetchRequest, response);
this.logRequestSuccess(this, response);
this._completed = true;
this.stopRequestTimeoutTimer();
this.dispatchEvent(new RequestSuccessEvent(this, fetchRequest, response));
}
/**
* Handle request processing failed by request manager (actual sending).
*
* @param fetchRequest - Reference to the actual request that has been used with {@link fetch}.
* @param error - Request processing error description.
*/
handleProcessingError(fetchRequest: Request, error: RequestSendingError) {
this.addRequestInformationForResult(this, fetchRequest, error);
this.logRequestError(this, error);
this._completed = true;
this.stopRequestTimeoutTimer();
this.dispatchEvent(new RequestErrorEvent(this, fetchRequest, error));
}
// endregion
// --------------------------------------------------------
// ------------------- Event Handlers ---------------------
// --------------------------------------------------------
// region Event handlers
/**
* Add `service`-provided request processing progress listeners for `client`-provided requests.
*
* **Context:** `service`-provided requests only.
*
* @param request - `client`-provided request that would like to observe `service`-provided request progress.
*/
addEventListenersForRequest(request: BasePubNubRequest) {
if (!this.isServiceRequest) {
console.error('Unexpected attempt to add listeners using a client-provided request.');
return;
}
request.abortController = new AbortController();
this.addEventListener(
PubNubSharedWorkerRequestEvents.Started,
(event) => {
if (!(event instanceof RequestStartEvent)) return;
request.logRequestStart(event.request);
request.dispatchEvent(event.clone(request));
},
{ signal: request.abortController.signal, once: true },
);
this.addEventListener(
PubNubSharedWorkerRequestEvents.Success,
(event) => {
if (!(event instanceof RequestSuccessEvent)) return;
request.removeEventListenersFromRequest();
request.addRequestInformationForResult(event.request, event.fetchRequest, event.response);
request.logRequestSuccess(event.request, event.response);
request._completed = true;
request.dispatchEvent(event.clone(request));
},
{ signal: request.abortController.signal, once: true },
);
this.addEventListener(
PubNubSharedWorkerRequestEvents.Error,
(event) => {
if (!(event instanceof RequestErrorEvent)) return;
request.removeEventListenersFromRequest();
request.addRequestInformationForResult(event.request, event.fetchRequest, event.error);
request.logRequestError(event.request, event.error);
request._completed = true;
request.dispatchEvent(event.clone(request));
},
{ signal: request.abortController.signal, once: true },
);
}
/**
* Remove listeners added to the `service` request.
*
* **Context:** `client`-provided requests only.
*/
removeEventListenersFromRequest() {
// Only client-provided requests add listeners.
if (this.isServiceRequest || !this.abortController) {
if (this.isServiceRequest)
console.error('Unexpected attempt to remove listeners using a client-provided request.');
return;
}
this.abortController.abort();
this.abortController = undefined;
}
// endregion
// --------------------------------------------------------
// ----------------------- Helpers ------------------------
// --------------------------------------------------------
// region Helpers
/**
* Check whether the request contains specified channels in the URI path and channel groups in the request query or
* not.
*
* @param channels - List of channels for which any entry should be checked in the request.
* @param channelGroups - List of channel groups for which any entry should be checked in the request.
* @returns `true` if receiver has at least one entry from provided `channels` or `channelGroups` in own URI.
*/
hasAnyChannelsOrGroups(channels: string[], channelGroups: string[]) {
return (
this.channels.some((channel) => channels.includes(channel)) ||
this.channelGroups.some((channelGroup) => channelGroups.includes(channelGroup))
);
}
/**
* Append request-specific information to the processing result.
*
* @param fetchRequest - Reference to the actual request that has been used with {@link fetch}.
* @param request - Reference to the client- or service-provided request with information for response.
* @param result - Request processing result that should be modified.
*/
private addRequestInformationForResult(
request: BasePubNubRequest,
fetchRequest: Request,
result: RequestSendingResult,
) {
if (this.isServiceRequest) return;
result.clientIdentifier = this.client.identifier;
result.identifier = this.identifier;
result.url = fetchRequest.url;
}
/**
* Log to the core PubNub client module information about request processing start.
*
* @param request - Reference to the client- or service-provided request information about which should be logged.
*/
private logRequestStart(request: BasePubNubRequest) {
if (this.isServiceRequest) return;
this.client.logger.debug(() => ({ messageType: 'network-request', message: request.request }));
}
/**
* Log to the core PubNub client module information about request processing successful completion.
*
* @param request - Reference to the client- or service-provided request information about which should be logged.
* @param response - Reference to the PubNub service response.
*/
private logRequestSuccess(request: BasePubNubRequest, response: RequestSendingSuccess) {
if (this.isServiceRequest) return;
this.client.logger.debug(() => {
const { status, headers, body } = response.response;
const fetchRequest = request.asFetchRequest;
const _headers: Record<string, string> = {};
// Copy Headers object content into plain Record.
Object.entries(headers).forEach(([key, value]) => (_headers[key] = value));
return { messageType: 'network-response', message: { status, url: fetchRequest.url, headers, body } };
});
}
/**
* Log to the core PubNub client module information about request processing error.
*
* @param request - Reference to the client- or service-provided request information about which should be logged.
* @param error - Request processing error information.
*/
private logRequestError(request: BasePubNubRequest, error: RequestSendingError) {
if (this.isServiceRequest) return;
if ((error.error ? error.error.message : 'Unknown').toLowerCase().includes('timeout')) {
this.client.logger.debug(() => ({
messageType: 'network-request',
message: request.request,
details: 'Timeout',
canceled: true,
}));
} else {
this.client.logger.warn(() => {
const { details, canceled } = this.errorDetailsFromSendingError(error);
let logDetails = details;
if (canceled) logDetails = 'Aborted';
else if (details.toLowerCase().includes('network')) logDetails = 'Network error';
return {
messageType: 'network-request',
message: request.request,
details: logDetails,
canceled: canceled,
failed: !canceled,
};
});
}
}
/**
* Retrieve error details from the error response object.
*
* @param error - Request fetch error object.
* @reruns Object with error details and whether it has been canceled or not.
*/
private errorDetailsFromSendingError(error: RequestSendingError): { details: string; canceled: boolean } {
const canceled = error.error ? error.error.type === 'TIMEOUT' || error.error.type === 'ABORTED' : false;
let details = error.error ? error.error.message : 'Unknown';
if (error.response) {
const contentType = error.response.headers['content-type'];
if (
error.response.body &&
contentType &&
(contentType.indexOf('javascript') !== -1 || contentType.indexOf('json') !== -1)
) {
try {
const serviceResponse = JSON.parse(new TextDecoder().decode(error.response.body));
if ('message' in serviceResponse) details = serviceResponse.message;
else if ('error' in serviceResponse) {
if (typeof serviceResponse.error === 'string') details = serviceResponse.error;
else if (typeof serviceResponse.error === 'object' && 'message' in serviceResponse.error)
details = serviceResponse.error.message;
}
} catch (_) {}
}
if (details === 'Unknown') {
if (error.response.status >= 500) details = 'Internal Server Error';
else if (error.response.status == 400) details = 'Bad request';
else if (error.response.status == 403) details = 'Access denied';
else details = `${error.response.status}`;
}
}
return { details, canceled };
}
/**
* Stringify request query key/value pairs.
*
* @param query - Request query object.
* @returns Stringified query object.
*/
private queryStringFromObject = (query: Query) => {
return Object.keys(query)
.map((key) => {
const queryValue = query[key];
if (!Array.isArray(queryValue)) return `${key}=${this.encodeString(queryValue)}`;
return queryValue.map((value) => `${key}=${this.encodeString(value)}`).join('&');
})
.join('&');
};
/**
* Percent-encode input string.
*
* **Note:** Encode content in accordance of the `PubNub` service requirements.
*
* @param input - Source string or number for encoding.
* @returns Percent-encoded string.
*/
protected encodeString(input: string | number) {
return encodeURIComponent(input).replace(/[!~*'()]/g, (x) => `%${x.charCodeAt(0).toString(16).toUpperCase()}`);
}
// endregion
}