amplify-s3-chunk-upload
Version:
A custom storage upload plugin for AWS Amplify. Instead of reading file completely in memory, it helps to read file chunk by chunk.
370 lines (332 loc) • 11.8 kB
text/typescript
/*
* Copyright 2017-2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with
* the License. A copy of the License is located at
*
* http://aws.amazon.com/apache2.0/
*
* or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
* CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions
* and limitations under the License.
*/
import { ConsoleLogger as Logger, getAmplifyUserAgent, Platform, CredentialsClass } from '@aws-amplify/core';
import {
S3Client,
PutObjectCommand,
CreateMultipartUploadCommand,
UploadPartCommand,
CompleteMultipartUploadCommand,
CompleteMultipartUploadCommandInput,
UploadPartCommandOutput,
UploadPartCommandInput,
ListPartsCommand,
AbortMultipartUploadCommand,
} from '@aws-sdk/client-s3';
import { AxiosHttpHandler, SEND_PROGRESS_EVENT } from './axios-http-handler';
import * as events from 'events';
import { streamCollector } from '@aws-sdk/fetch-http-handler';
const logger = new Logger('AWSS3ProviderManagedUpload');
const localTestingStorageEndpoint = 'http://localhost:20005';
const SET_CONTENT_LENGTH_HEADER = 'contentLengthMiddleware';
export declare interface Part {
bodyPart: any;
partNumber: number;
emitter: any;
etag?: string;
_lastUploadedBytes: number;
}
export class StorageChunkManagedUpload {
// Defaults
protected minPartSize = 5 * 1024 * 1024; // in MB
private queueSize = 4;
// Data for current upload
private body = null;
private params = null;
private opts = null;
private multiPartMap = [];
private cancel: boolean = false;
// Progress reporting
private bytesUploaded = 0;
private totalBytesToUpload = 0;
private emitter = null;
constructor(params, private credentials: CredentialsClass, opts, emitter) {
this.params = params;
this.opts = opts;
this.emitter = emitter;
}
public async upload() {
this.body = await this.validateAndSanitizeBody(this.params.Body);
this.totalBytesToUpload = this.byteLength(this.body);
if (this.totalBytesToUpload <= this.minPartSize) {
// Multipart upload is not required. Upload the sanitized body as is
// We could get body as promise, let's resolve it.
this.params.Body = await Promise.resolve(this.body.slice(0, this.totalBytesToUpload));
const putObjectCommand = new PutObjectCommand(this.params);
const s3 = await this._createNewS3Client(this.opts, this.emitter);
return s3.send(putObjectCommand);
} else {
// Step 1: Initiate the multi part upload
const uploadId = await this.createMultiPartUpload();
// Step 2: Upload chunks in parallel as requested
const numberOfPartsToUpload = Math.ceil(this.totalBytesToUpload / this.minPartSize);
for (let start = 0; start < numberOfPartsToUpload; start += this.queueSize) {
/** This first block will try to cancel the upload if the cancel
* request came before any parts uploads have started.
*/
await this.checkIfUploadCancelled(uploadId);
// Upload as many as `queueSize` parts simultaneously
const parts: Part[] = await this.createParts(start);
await this.uploadParts(uploadId, parts);
/** Call cleanup a second time in case there were part upload requests
* in flight. This is to ensure that all parts are cleaned up.
*/
await this.checkIfUploadCancelled(uploadId);
}
// Step 3: Finalize the upload such that S3 can recreate the file
return await this.finishMultiPartUpload(uploadId);
}
}
private async createParts(startPartNumber: number): Promise<Part[]> {
const parts: Part[] = [];
let partNumber = startPartNumber;
for (
let bodyStart = startPartNumber * this.minPartSize;
bodyStart < this.totalBytesToUpload && parts.length < this.queueSize;
) {
const bodyEnd = Math.min(bodyStart + this.minPartSize, this.totalBytesToUpload);
parts.push({
bodyPart: await Promise.resolve(this.body.slice(bodyStart, bodyEnd)),
partNumber: ++partNumber,
emitter: new events.EventEmitter(),
_lastUploadedBytes: 0,
});
bodyStart += this.minPartSize;
}
return parts;
}
private async createMultiPartUpload() {
const createMultiPartUploadCommand = new CreateMultipartUploadCommand(this.params);
const s3 = await this._createNewS3Client(this.opts);
// @aws-sdk/client-s3 seems to be ignoring the `ContentType` parameter, so we
// are explicitly adding it via middleware.
// https://github.com/aws/aws-sdk-js-v3/issues/2000
s3.middlewareStack.add(
next => (args: any) => {
if (
this.params.ContentType &&
args &&
args.request &&
args.request.headers
) {
args.request.headers['Content-Type'] = this.params.ContentType;
}
return next(args);
},
{
step: 'build',
}
);
const response = await s3.send(createMultiPartUploadCommand);
logger.debug(response.UploadId);
return response.UploadId;
}
/**
* @private Not to be extended outside of tests
* @VisibleFotTesting
*/
protected async uploadParts(uploadId: string, parts: Part[]) {
const promises: Array<Promise<UploadPartCommandOutput>> = [];
for (const part of parts) {
this.setupEventListener(part);
const uploadPartCommandInput: UploadPartCommandInput = {
PartNumber: part.partNumber,
Body: part.bodyPart,
UploadId: uploadId,
Key: this.params.Key,
Bucket: this.params.Bucket,
};
const uploadPartCommand = new UploadPartCommand(uploadPartCommandInput);
const s3 = await this._createNewS3Client(this.opts, part.emitter);
promises.push(s3.send(uploadPartCommand));
}
try {
const allResults: Array<UploadPartCommandOutput> = await Promise.all(promises);
// The order of resolved promises is the same as input promise order.
for (let i = 0; i < allResults.length; i++) {
this.multiPartMap.push({
PartNumber: parts[i].partNumber,
ETag: allResults[i].ETag,
});
}
} catch (error) {
logger.error('error happened while uploading a part. Cancelling the multipart upload', error);
this.cancelUpload();
return;
}
}
private async finishMultiPartUpload(uploadId: string) {
const input: CompleteMultipartUploadCommandInput = {
Bucket: this.params.Bucket,
Key: this.params.Key,
UploadId: uploadId,
MultipartUpload: { Parts: this.multiPartMap },
};
const completeUploadCommand = new CompleteMultipartUploadCommand(input);
const s3 = await this._createNewS3Client(this.opts);
try {
const data = await s3.send(completeUploadCommand);
return data.Key;
} catch (error) {
logger.error('error happened while finishing the upload. Cancelling the multipart upload', error);
this.cancelUpload();
return;
}
}
private async checkIfUploadCancelled(uploadId: string) {
if (this.cancel) {
let errorMessage = 'Upload was cancelled.';
try {
await this.cleanup(uploadId);
} catch (error) {
errorMessage += error.errorMessage;
}
throw new Error(errorMessage);
}
}
public cancelUpload() {
this.cancel = true;
}
private async cleanup(uploadId: string) {
// Reset this's state
this.body = null;
this.multiPartMap = [];
this.bytesUploaded = 0;
this.totalBytesToUpload = 0;
const input = {
Bucket: this.params.Bucket,
Key: this.params.Key,
UploadId: uploadId,
};
const s3 = await this._createNewS3Client(this.opts);
await s3.send(new AbortMultipartUploadCommand(input));
// verify that all parts are removed.
const data = await s3.send(new ListPartsCommand(input));
if (data && data.Parts && data.Parts.length > 0) {
throw new Error('Multi Part upload clean up failed');
}
}
private setupEventListener(part: Part) {
part.emitter.on(SEND_PROGRESS_EVENT, (progress) => {
this.progressChanged(part.partNumber, progress.loaded - part._lastUploadedBytes);
part._lastUploadedBytes = progress.loaded;
});
}
private progressChanged(partNumber: number, incrementalUpdate: number) {
this.bytesUploaded += incrementalUpdate;
this.emitter.emit(SEND_PROGRESS_EVENT, {
loaded: this.bytesUploaded,
total: this.totalBytesToUpload,
part: partNumber,
key: this.params.Key,
});
}
private byteLength(input: any) {
if (input === null || input === undefined) return 0;
if (typeof input.byteLength === 'number') {
return input.byteLength;
} else if (typeof input.length === 'number') {
return input.length;
} else if (typeof input.size === 'number') {
return input.size;
} else if (typeof input.path === 'string') {
/* NodeJs Support
return require('fs').lstatSync(input.path).size;
*/
} else {
throw new Error('Cannot determine length of ' + input);
}
}
private async validateAndSanitizeBody(body: any): Promise<any> {
if (this.isGenericObject(body)) {
// Any javascript object
return JSON.stringify(body);
} else if (this.isBlob(body)) {
// If it's a blob, we need to convert it to an array buffer as axios has issues
// with correctly identifying blobs in *react native* environment. For more
// details see https://github.com/aws-amplify/amplify-js/issues/5311
if (Platform.isReactNative) {
return await streamCollector(body);
}
return body;
} else {
// Files, arrayBuffer etc
return body;
}
/* TODO: streams and files for nodejs
if (
typeof body.path === 'string' &&
require('fs').lstatSync(body.path).size > 0
) {
return body;
} */
}
private isBlob(body: any) {
return typeof Blob !== 'undefined' && body instanceof Blob;
}
private isGenericObject(body: any) {
if (body !== null && typeof body === 'object') {
try {
return !(this.byteLength(body) >= 0);
} catch (error) {
// If we cannot determine the length of the body, consider it
// as a generic object and upload a stringified version of it
return true;
}
}
return false;
}
/**
* @private
* creates an S3 client with new V3 aws sdk
*/
protected async _createNewS3Client(config, emitter?) {
const credentials = await this._getCredentials();
const { region, dangerouslyConnectToHttpEndpointForTesting } = config;
let localTestingConfig = {};
if (dangerouslyConnectToHttpEndpointForTesting) {
localTestingConfig = {
endpoint: localTestingStorageEndpoint,
tls: false,
bucketEndpoint: false,
forcePathStyle: true,
};
}
const client = new S3Client({
region,
credentials,
...localTestingConfig,
requestHandler: new AxiosHttpHandler({}, emitter),
customUserAgent: getAmplifyUserAgent(),
});
client.middlewareStack.remove(SET_CONTENT_LENGTH_HEADER);
return client;
}
/**
* @private
*/
_getCredentials() {
return this.credentials
.get()
.then((credentials) => {
if (!credentials) return false;
const cred = this.credentials.shear(credentials);
logger.debug('set credentials for storage', cred);
return cred;
})
.catch((error) => {
logger.warn('ensure credentials error', error);
return false;
});
}
}