@aws-amplify/storage
Version: 
Storage category of aws-amplify
183 lines (181 loc) • 8.37 kB
JavaScript
;
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
Object.defineProperty(exports, "__esModule", { value: true });
exports.xhrTransferHandler = void 0;
const aws_client_utils_1 = require("@aws-amplify/core/internals/aws-client-utils");
const core_1 = require("@aws-amplify/core");
const CanceledError_1 = require("../../../../../errors/CanceledError");
const StorageError_1 = require("../../../../../errors/StorageError");
const constants_1 = require("./constants");
const logger = new core_1.ConsoleLogger('xhr-http-handler');
/**
 * 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
 */
const xhrTransferHandler = (request, options) => {
    const { url, method, headers, body } = request;
    const { onDownloadProgress, onUploadProgress, responseType, abortSignal } = options;
    return new Promise((resolve, reject) => {
        let xhr = 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_1.StorageError({
                message: constants_1.NETWORK_ERROR_MESSAGE,
                name: constants_1.NETWORK_ERROR_CODE,
            });
            logger.error(constants_1.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(constants_1.ABORT_ERROR_MESSAGE, constants_1.ABORT_ERROR_CODE);
            logger.error(constants_1.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;
                const responseText = loadEndResponseType === 'text' ? xhr.responseText : '';
                const bodyMixIn = {
                    blob: () => Promise.resolve(responseBlob),
                    text: (0, aws_client_utils_1.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 = {
                    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),
                };
                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_1.CanceledError({
                    name: constants_1.CANCELED_ERROR_CODE,
                    message: constants_1.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 ?? null);
    });
};
exports.xhrTransferHandler = xhrTransferHandler;
const convertToTransferProgressEvent = (event) => ({
    transferredBytes: event.loaded,
    totalBytes: event.lengthComputable ? event.total : undefined,
});
const buildHandlerError = (message, name) => {
    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) => {
    if (!xhrHeaders) {
        return {};
    }
    return xhrHeaders
        .split('\r\n')
        .reduce((headerMap, line) => {
        const parts = line.split(': ');
        const header = parts.shift();
        const value = parts.join(': ');
        headerMap[header.toLowerCase()] = value;
        return headerMap;
    }, {});
};
const readBlobAsText = (blob) => {
    const reader = new FileReader();
    return new Promise((resolve, reject) => {
        reader.onloadend = () => {
            if (reader.readyState !== FileReader.DONE) {
                return;
            }
            resolve(reader.result);
        };
        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'];
//# sourceMappingURL=xhrTransferHandler.js.map