UNPKG

pubnub

Version:

Publish & Subscribe Real-time Messaging with PubNub

460 lines (398 loc) 15.5 kB
/** * Common browser and React Native Transport provider module. * * @internal */ import { CancellationController, TransportRequest } from '../core/types/transport-request'; import { TransportResponse } from '../core/types/transport-response'; import { LoggerManager } from '../core/components/logger-manager'; import { PubNubAPIError } from '../errors/pubnub-api-error'; import { Transport } from '../core/interfaces/transport'; import { PubNubFileInterface } from '../core/types/file'; import { queryStringFromObject } from '../core/utils'; /** * Object represent a request to be sent with API available in browser. * * @internal */ type WebTransportRequest = { /** * Full URL to the remote resource. */ url: string; /** * For how long (in seconds) request should wait response from the server. */ timeout: number; /** * Transport request HTTP method. */ method: TransportRequest['method']; /** * Headers to be sent with the request. */ headers?: Record<string, string> | undefined; /** * Body to be sent with the request. */ body: string | ArrayBuffer | FormData | undefined; }; /** * Web request cancellation controller. */ type WebCancellationController = CancellationController & { /** * Abort controller object which provides abort signal. */ abortController: AbortController; }; /** * Class representing a `fetch`-based browser and React Native transport provider. * * @internal */ export class WebTransport implements Transport { /** * Pointer to the "clean" `fetch` function. * * This protects against APM which overload implementation and may break crucial functionality. * * @internal */ private static originalFetch: typeof fetch; /** * Request body decoder. * * @internal */ protected static encoder = new TextEncoder(); /** * Service {@link ArrayBuffer} response decoder. * * @internal */ protected static decoder = new TextDecoder(); /** * Create and configure transport provider for Web and Rect environments. * * @param logger - Registered loggers' manager. * @param [transport] - API, which should be used to make network requests. * * @internal */ constructor( private readonly logger: LoggerManager, private readonly transport: 'fetch' | 'xhr' = 'fetch', ) { logger.debug('WebTransport', `Create with configuration:\n - transport: ${transport}`); if (transport === 'fetch' && (!window || !window.fetch)) { logger.warn('WebTransport', `'${transport}' not supported in this browser. Fallback to the 'xhr' transport.`); this.transport = 'xhr'; } if (this.transport !== 'fetch') return; // Keeping reference on current `window.fetch` function. WebTransport.originalFetch = fetch.bind(window); // Check whether `fetch` has been monkey patched or not. if (this.isFetchMonkeyPatched()) { WebTransport.originalFetch = WebTransport.getOriginalFetch(); logger.warn('WebTransport', "Native Web Fetch API 'fetch' function monkey patched."); if (!this.isFetchMonkeyPatched(WebTransport.originalFetch)) { logger.info('WebTransport', "Use native Web Fetch API 'fetch' implementation from iframe as APM workaround."); } else { logger.warn( 'WebTransport', 'Unable receive native Web Fetch API. There can be issues with subscribe long-poll cancellation', ); } } } makeSendable(req: TransportRequest): [Promise<TransportResponse>, CancellationController | undefined] { const abortController = new AbortController(); const cancellation: WebCancellationController = { abortController, abort: (reason) => { if (!abortController.signal.aborted) { this.logger.trace('WebTransport', `On-demand request aborting: ${reason}`); abortController.abort(reason); } }, }; return [ this.webTransportRequestFromTransportRequest(req).then((request) => { this.logger.debug('WebTransport', () => ({ messageType: 'network-request', message: req })); return this.sendRequest(request, cancellation) .then((response): Promise<[Response, ArrayBuffer]> | [Response, ArrayBuffer] => response.arrayBuffer().then((arrayBuffer) => [response, arrayBuffer]), ) .then((response) => { const body = response[1].byteLength > 0 ? response[1] : undefined; const { status, headers: requestHeaders } = response[0]; const headers: Record<string, string> = {}; // Copy Headers object content into plain Record. requestHeaders.forEach((value, key) => (headers[key] = value.toLowerCase())); const transportResponse: TransportResponse = { status, url: request.url, headers, body }; this.logger.debug('WebTransport', () => ({ messageType: 'network-response', message: transportResponse, })); if (status >= 400) throw PubNubAPIError.create(transportResponse); return transportResponse; }) .catch((error) => { const errorMessage = (typeof error === 'string' ? error : (error as Error).message).toLowerCase(); let fetchError = typeof error === 'string' ? new Error(error) : (error as Error); if (errorMessage.includes('timeout')) { this.logger.warn('WebTransport', () => ({ messageType: 'network-request', message: req, details: 'Timeout', canceled: true, })); } else if (errorMessage.includes('cancel') || errorMessage.includes('abort')) { this.logger.debug('WebTransport', () => ({ messageType: 'network-request', message: req, details: 'Aborted', canceled: true, })); fetchError = new Error('Aborted'); fetchError.name = 'AbortError'; } else if (errorMessage.includes('network')) { this.logger.warn('WebTransport', () => ({ messageType: 'network-request', message: req, details: 'Network error', failed: true, })); } else { this.logger.warn('WebTransport', () => ({ messageType: 'network-request', message: req, details: PubNubAPIError.create(fetchError).message, failed: true, })); } throw PubNubAPIError.create(fetchError); }); }), cancellation, ]; } request(req: TransportRequest): TransportRequest { return req; } /** * Send network request using preferred API. * * @param req - Transport API agnostic request object with prepared body and URL. * @param controller - Request cancellation controller (for long-poll requests). * * @returns Promise which will be resolved or rejected at the end of network request processing. * * @internal */ private async sendRequest(req: WebTransportRequest, controller: WebCancellationController): Promise<Response> { if (this.transport === 'fetch') return this.sendFetchRequest(req, controller); return this.sendXHRRequest(req, controller); } /** * Send network request using legacy XMLHttpRequest API. * * @param req - Transport API agnostic request object with prepared body and URL. * @param controller - Request cancellation controller (for long-poll requests). * * @returns Promise which will be resolved or rejected at the end of network request processing. * * @internal */ private async sendFetchRequest(req: WebTransportRequest, controller: WebCancellationController): Promise<Response> { let timeoutId: ReturnType<typeof setTimeout> | undefined; const requestTimeout = new Promise<Response>((_, reject) => { timeoutId = setTimeout(() => { clearTimeout(timeoutId); reject(new Error('Request timeout')); controller.abort('Cancel because of timeout'); }, req.timeout * 1000); }); const request = new Request(req.url, { method: req.method, headers: req.headers, redirect: 'follow', body: req.body, }); return Promise.race([ WebTransport.originalFetch(request, { signal: controller.abortController.signal, credentials: 'omit', cache: 'no-cache', }).then((response) => { if (timeoutId) clearTimeout(timeoutId); return response; }), requestTimeout, ]); } /** * Send network request using legacy XMLHttpRequest API. * * @param req - Transport API agnostic request object with prepared body and URL. * @param controller - Request cancellation controller (for long-poll requests). * * @returns Promise which will be resolved or rejected at the end of network request processing. * * @internal */ private async sendXHRRequest(req: WebTransportRequest, controller: WebCancellationController): Promise<Response> { return new Promise<Response>((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open(req.method, req.url, true); let aborted = false; // Setup request xhr.responseType = 'arraybuffer'; xhr.timeout = req.timeout * 1000; controller.abortController.signal.onabort = () => { if (xhr.readyState == XMLHttpRequest.DONE || xhr.readyState == XMLHttpRequest.UNSENT) return; aborted = true; xhr.abort(); }; Object.entries(req.headers ?? {}).forEach(([key, value]) => xhr.setRequestHeader(key, value)); // Setup handlers to match `fetch` results handling. xhr.onabort = () => { reject(new Error('Aborted')); }; xhr.ontimeout = () => { reject(new Error('Request timeout')); }; xhr.onerror = () => { if (!aborted) { const response = this.transportResponseFromXHR(req.url, xhr); reject(new Error(PubNubAPIError.create(response).message)); } }; xhr.onload = () => { const headers = new Headers(); xhr .getAllResponseHeaders() .split('\r\n') .forEach((header) => { const [key, value] = header.split(': '); if (key.length > 1 && value.length > 1) headers.append(key, value); }); resolve(new Response(xhr.response, { status: xhr.status, headers, statusText: xhr.statusText })); }; xhr.send(req.body); }); } /** * Creates a Web 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. * * @internal */ private async webTransportRequestFromTransportRequest(req: TransportRequest): Promise<WebTransportRequest> { let body: string | ArrayBuffer | FormData | undefined; let path = req.path; // Create a multipart request body. if (req.formData && req.formData.length > 0) { // Reset query parameters to conform to signed URL req.queryParameters = {}; const file = req.body as PubNubFileInterface; const formData = new FormData(); for (const { key, value } of req.formData) formData.append(key, value); try { const fileData = await file.toArrayBuffer(); formData.append('file', new Blob([fileData], { type: 'application/octet-stream' }), file.name); } catch (toBufferError) { this.logger.warn('WebTransport', () => ({ messageType: 'error', message: toBufferError })); try { const fileData = await file.toFileUri(); // @ts-expect-error React Native File Uri support. formData.append('file', fileData, file.name); } catch (toFileURLError) { this.logger.error('WebTransport', () => ({ messageType: 'error', message: toFileURLError })); } } body = formData; } // Handle regular body payload (if passed). else if (req.body && (typeof req.body === 'string' || req.body instanceof ArrayBuffer)) { // Compressing body if the browser has native support. if (req.compressible && typeof CompressionStream !== 'undefined') { const bodyArrayBuffer = typeof req.body === 'string' ? WebTransport.encoder.encode(req.body) : req.body; const initialBodySize = bodyArrayBuffer.byteLength; const bodyStream = new ReadableStream({ start(controller) { controller.enqueue(bodyArrayBuffer); controller.close(); }, }); body = await new Response(bodyStream.pipeThrough(new CompressionStream('deflate'))).arrayBuffer(); this.logger.trace('WebTransport', () => { const compressedSize = (body! as ArrayBuffer).byteLength; const ratio = (compressedSize / initialBodySize).toFixed(2); return { messageType: 'text', message: `Body of ${initialBodySize} bytes, compressed by ${ratio}x to ${compressedSize} bytes.`, }; }); } else body = req.body; } if (req.queryParameters && Object.keys(req.queryParameters).length !== 0) path = `${path}?${queryStringFromObject(req.queryParameters)}`; return { url: `${req.origin!}${path}`, method: req.method, headers: req.headers, timeout: req.timeout, body, }; } /** * Check whether the original ` fetch ` has been monkey patched or not. * * @returns `true` if original `fetch` has been patched. */ private isFetchMonkeyPatched(oFetch?: typeof fetch): boolean { const fetchString = (oFetch ?? fetch).toString(); return !fetchString.includes('[native code]') && fetch.name !== 'fetch'; } /** * Create service response from {@link XMLHttpRequest} processing result. * * @param url - Used endpoint url. * @param xhr - `HTTPClient`, which has been used to make a request. * * @returns Pre-processed transport response. */ private transportResponseFromXHR(url: string, xhr: XMLHttpRequest): TransportResponse { const allHeaders = xhr.getAllResponseHeaders().split('\n'); const headers: Record<string, string> = {}; for (const header of allHeaders) { const [key, value] = header.trim().split(':'); if (key && value) headers[key.toLowerCase()] = value.trim(); } return { status: xhr.status, url, headers, body: xhr.response as ArrayBuffer }; } /** * Retrieve the original ` fetch ` implementation. * * Retrieve implementation which hasn't been patched by APM tools. * * @returns Reference to the `fetch` function. */ private static getOriginalFetch(): typeof fetch { let iframe = document.querySelector<HTMLIFrameElement>('iframe[name="pubnub-context-unpatched-fetch"]'); if (!iframe) { iframe = document.createElement('iframe'); iframe.style.display = 'none'; iframe.name = 'pubnub-context-unpatched-fetch'; iframe.src = 'about:blank'; document.body.appendChild(iframe); } if (iframe.contentWindow) return iframe.contentWindow.fetch.bind(iframe.contentWindow); return fetch; } }