UNPKG

@aws-amplify/storage

Version:

Storage category of aws-amplify

246 lines (217 loc) • 7.89 kB
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 import { HttpRequest, HttpResponse, ResponseBodyMixin, TransferHandler, withMemoization, } from '@aws-amplify/core/internals/aws-client-utils'; import { ConsoleLogger } from '@aws-amplify/core'; import { TransferProgressEvent } from '../../../../../types/common'; import { CanceledError } from '../../../../../errors/CanceledError'; import { StorageError } from '../../../../../errors/StorageError'; import { ABORT_ERROR_CODE, ABORT_ERROR_MESSAGE, CANCELED_ERROR_CODE, CANCELED_ERROR_MESSAGE, NETWORK_ERROR_CODE, NETWORK_ERROR_MESSAGE, } from './constants'; const logger = new ConsoleLogger('xhr-http-handler'); /** * @internal */ export interface XhrTransferHandlerOptions { // Expected response body type. If `blob`, the response will be returned as a Blob object. It's mainly used to // download binary data. Otherwise, use `text` to return the response as a string. responseType: 'text' | 'blob'; abortSignal?: AbortSignal; onDownloadProgress?(event: TransferProgressEvent): void; onUploadProgress?(event: TransferProgressEvent): void; } /** * Base transfer handler implementation using XMLHttpRequest to support upload and download progress events. * * @param request - The request object. * @param options - The request options. * @returns A promise that will be resolved with the response object. * * @internal */ export const xhrTransferHandler: TransferHandler< HttpRequest, HttpResponse, XhrTransferHandlerOptions > = (request, options): Promise<HttpResponse> => { const { url, method, headers, body } = request; const { onDownloadProgress, onUploadProgress, responseType, abortSignal } = options; return new Promise((resolve, reject) => { let xhr: XMLHttpRequest | null = new XMLHttpRequest(); xhr.open(method.toUpperCase(), url.toString()); Object.entries(headers) .filter(([header]) => !FORBIDDEN_HEADERS.includes(header)) .forEach(([header, value]) => { xhr!.setRequestHeader(header, value); }); xhr.responseType = responseType; if (onDownloadProgress) { xhr.addEventListener('progress', event => { onDownloadProgress(convertToTransferProgressEvent(event)); logger.debug(event); }); } if (onUploadProgress) { xhr.upload.addEventListener('progress', event => { onUploadProgress(convertToTransferProgressEvent(event)); logger.debug(event); }); } xhr.addEventListener('error', () => { const networkError = new StorageError({ message: NETWORK_ERROR_MESSAGE, name: NETWORK_ERROR_CODE, }); logger.error(NETWORK_ERROR_MESSAGE); reject(networkError); xhr = null; // clean up request }); // Handle browser request cancellation (as opposed to a manual cancellation) xhr.addEventListener('abort', () => { // The abort event can be triggered after the error or load event. So we need to check if the xhr is null. // When request is aborted by AbortSignal, the promise is rejected in the abortSignal's 'abort' event listener. if (!xhr || abortSignal?.aborted) return; // Handle abort request caused by browser instead of AbortController // see: https://github.com/axios/axios/issues/537 const error = buildHandlerError(ABORT_ERROR_MESSAGE, ABORT_ERROR_CODE); logger.error(ABORT_ERROR_MESSAGE); reject(error); xhr = null; // clean up request }); // Skip handling timeout error since we don't have a timeout xhr.addEventListener('readystatechange', () => { if (!xhr || xhr.readyState !== xhr.DONE) { return; } const onloadend = () => { // The load event is triggered after the error/abort/load event. So we need to check if the xhr is null. if (!xhr) return; const responseHeaders = convertResponseHeaders( xhr.getAllResponseHeaders(), ); const { responseType: loadEndResponseType } = xhr; const responseBlob = xhr.response as Blob; const responseText = loadEndResponseType === 'text' ? xhr.responseText : ''; const bodyMixIn: ResponseBodyMixin = { blob: () => Promise.resolve(responseBlob), text: withMemoization(() => loadEndResponseType === 'blob' ? readBlobAsText(responseBlob) : Promise.resolve(responseText), ), json: () => Promise.reject( // S3 does not support JSON response. So fail-fast here with nicer error message. new Error( 'Parsing response to JSON is not implemented. Please use response.text() instead.', ), ), }; const response: HttpResponse = { statusCode: xhr.status, headers: responseHeaders, // The xhr.responseType is only set to 'blob' for streaming binary S3 object data. The streaming data is // exposed via public interface of Storage.get(). So we need to return the response as a Blob object for // backward compatibility. In other cases, the response payload is only used internally, we return it is // {@link ResponseBodyMixin} body: (xhr.responseType === 'blob' ? Object.assign(responseBlob, bodyMixIn) : bodyMixIn) as HttpResponse['body'], }; resolve(response); xhr = null; // clean up request }; // readystate handler is calling before onerror or ontimeout handlers, // so we should call onloadend on the next 'tick' // @see https://github.com/axios/axios/blob/9588fcdec8aca45c3ba2f7968988a5d03f23168c/lib/adapters/xhr.js#L98-L99 setTimeout(onloadend); }); if (abortSignal) { const onCanceled = () => { // The abort event is triggered after the error or load event. So we need to check if the xhr is null. if (!xhr) { return; } const canceledError = new CanceledError({ name: CANCELED_ERROR_CODE, message: CANCELED_ERROR_MESSAGE, }); reject(canceledError); xhr.abort(); xhr = null; }; abortSignal.aborted ? onCanceled() : abortSignal.addEventListener('abort', onCanceled); } if ( typeof ReadableStream === 'function' && body instanceof ReadableStream ) { // This does not matter as previous implementation uses Axios which does not support ReadableStream anyway. throw new Error('ReadableStream request payload is not supported.'); } xhr.send((body as Exclude<BodyInit, ReadableStream>) ?? null); }); }; const convertToTransferProgressEvent = ( event: ProgressEvent, ): TransferProgressEvent => ({ transferredBytes: event.loaded, totalBytes: event.lengthComputable ? event.total : undefined, }); const buildHandlerError = (message: string, name: string): Error => { const error = new Error(message); error.name = name; return error; }; /** * Convert xhr.getAllResponseHeaders() string to a Record<string, string>. Note that modern browser already returns * header names in lowercase. * @param xhrHeaders - string of headers returned from xhr.getAllResponseHeaders() */ const convertResponseHeaders = (xhrHeaders: string): Record<string, string> => { if (!xhrHeaders) { return {}; } return xhrHeaders .split('\r\n') .reduce((headerMap: Record<string, string>, line: string) => { const parts = line.split(': '); const header = parts.shift()!; const value = parts.join(': '); headerMap[header.toLowerCase()] = value; return headerMap; }, {}); }; const readBlobAsText = (blob: Blob) => { const reader = new FileReader(); return new Promise<string>((resolve, reject) => { reader.onloadend = () => { if (reader.readyState !== FileReader.DONE) { return; } resolve(reader.result as string); }; reader.onerror = () => { reject(reader.error); }; reader.readAsText(blob); }); }; // To add more forbidden headers as found set by S3. Intentionally NOT list all of them here to save bundle size. // https://developer.mozilla.org/en-US/docs/Glossary/Forbidden_header_name const FORBIDDEN_HEADERS = ['host'];