@uppy/aws-s3
Version:
Upload to Amazon S3 with Uppy
265 lines (219 loc) • 8.27 kB
text/typescript
import type { Uppy } from '@uppy/core'
import { AbortController } from '@uppy/utils/lib/AbortController'
import type { Body, Meta, UppyFile } from '@uppy/utils/lib/UppyFile'
import type { HTTPCommunicationQueue } from './HTTPCommunicationQueue.js'
const MB = 1024 * 1024
interface MultipartUploaderOptions<M extends Meta, B extends Body> {
getChunkSize?: (file: { size: number }) => number
onProgress?: (bytesUploaded: number, bytesTotal: number) => void
onPartComplete?: (part: { PartNumber: number; ETag: string }) => void
shouldUseMultipart?: boolean | ((file: UppyFile<M, B>) => boolean)
onSuccess?: (result: B) => void
onError?: (err: unknown) => void
companionComm: HTTPCommunicationQueue<M, B>
file: UppyFile<M, B>
log: Uppy<M, B>['log']
uploadId?: string
key: string
}
const defaultOptions = {
getChunkSize(file: { size: number }) {
return Math.ceil(file.size / 10000)
},
onProgress() {},
onPartComplete() {},
onSuccess() {},
onError(err: unknown) {
throw err
},
} satisfies Partial<MultipartUploaderOptions<any, any>>
export interface Chunk {
getData: () => Blob
onProgress: (ev: ProgressEvent) => void
onComplete: (etag: string) => void
shouldUseMultipart: boolean
setAsUploaded?: () => void
}
function ensureInt<T>(value: T): T extends number | string ? number : never {
if (typeof value === 'string') {
// @ts-expect-error TS is not able to recognize it's fine.
return parseInt(value, 10)
}
if (typeof value === 'number') {
// @ts-expect-error TS is not able to recognize it's fine.
return value
}
throw new TypeError('Expected a number')
}
export const pausingUploadReason = Symbol('pausing upload, not an actual error')
/**
* A MultipartUploader instance is used per file upload to determine whether a
* upload should be done as multipart or as a regular S3 upload
* (based on the user-provided `shouldUseMultipart` option value) and to manage
* the chunk splitting.
*/
class MultipartUploader<M extends Meta, B extends Body> {
options: MultipartUploaderOptions<M, B> &
Required<Pick<MultipartUploaderOptions<M, B>, keyof typeof defaultOptions>>
#abortController = new AbortController()
#chunks: Array<Chunk | null> = []
#chunkState: { uploaded: number; etag?: string; done?: boolean }[] = []
/**
* The (un-chunked) data to upload.
*/
#data: Blob
#file: UppyFile<M, B>
#uploadHasStarted = false
#onError: (err: unknown) => void
#onSuccess: (result: B) => void
#shouldUseMultipart: MultipartUploaderOptions<M, B>['shouldUseMultipart']
#isRestoring: boolean
#onReject = (err: unknown) =>
(err as any)?.cause === pausingUploadReason ? null : this.#onError(err)
#maxMultipartParts = 10_000
#minPartSize = 5 * MB
constructor(data: Blob, options: MultipartUploaderOptions<M, B>) {
this.options = {
...defaultOptions,
...options,
}
// Use default `getChunkSize` if it was null or something
this.options.getChunkSize ??= defaultOptions.getChunkSize
this.#data = data
this.#file = options.file
this.#onSuccess = this.options.onSuccess
this.#onError = this.options.onError
this.#shouldUseMultipart = this.options.shouldUseMultipart
// When we are restoring an upload, we already have an UploadId and a Key. Otherwise
// we need to call `createMultipartUpload` to get an `uploadId` and a `key`.
// Non-multipart uploads are not restorable.
this.#isRestoring = (options.uploadId && options.key) as any as boolean
this.#initChunks()
}
// initChunks checks the user preference for using multipart uploads (opts.shouldUseMultipart)
// and calculates the optimal part size. When using multipart part uploads every part except for the last has
// to be at least 5 MB and there can be no more than 10K parts.
// This means we sometimes need to change the preferred part size from the user in order to meet these requirements.
#initChunks() {
const fileSize = this.#data.size
const shouldUseMultipart =
typeof this.#shouldUseMultipart === 'function'
? this.#shouldUseMultipart(this.#file)
: Boolean(this.#shouldUseMultipart)
if (shouldUseMultipart && fileSize > this.#minPartSize) {
// At least 5MB per request:
let chunkSize = Math.max(
this.options.getChunkSize(this.#data) as number, // Math.max can take undefined but TS does not think so
this.#minPartSize,
)
let arraySize = Math.floor(fileSize / chunkSize)
// At most 10k requests per file:
if (arraySize > this.#maxMultipartParts) {
arraySize = this.#maxMultipartParts
chunkSize = fileSize / this.#maxMultipartParts
}
this.#chunks = Array(arraySize)
for (let offset = 0, j = 0; offset < fileSize; offset += chunkSize, j++) {
const end = Math.min(fileSize, offset + chunkSize)
// Defer data fetching/slicing until we actually need the data, because it's slow if we have a lot of files
const getData = () => {
const i2 = offset
return this.#data.slice(i2, end)
}
this.#chunks[j] = {
getData,
onProgress: this.#onPartProgress(j),
onComplete: this.#onPartComplete(j),
shouldUseMultipart,
}
if (this.#isRestoring) {
const size =
offset + chunkSize > fileSize ? fileSize - offset : chunkSize
// setAsUploaded is called by listPart, to keep up-to-date the
// quantity of data that is left to actually upload.
this.#chunks[j]!.setAsUploaded = () => {
this.#chunks[j] = null
this.#chunkState[j].uploaded = size
}
}
}
} else {
this.#chunks = [
{
getData: () => this.#data,
onProgress: this.#onPartProgress(0),
onComplete: this.#onPartComplete(0),
shouldUseMultipart,
},
]
}
this.#chunkState = this.#chunks.map(() => ({ uploaded: 0 }))
}
#createUpload() {
this.options.companionComm
.uploadFile(
this.#file,
this.#chunks as Chunk[],
this.#abortController.signal,
)
.then(this.#onSuccess, this.#onReject)
this.#uploadHasStarted = true
}
#resumeUpload() {
this.options.companionComm
.resumeUploadFile(this.#file, this.#chunks, this.#abortController.signal)
.then(this.#onSuccess, this.#onReject)
}
#onPartProgress = (index: number) => (ev: ProgressEvent) => {
if (!ev.lengthComputable) return
this.#chunkState[index].uploaded = ensureInt(ev.loaded)
const totalUploaded = this.#chunkState.reduce((n, c) => n + c.uploaded, 0)
this.options.onProgress(totalUploaded, this.#data.size)
}
#onPartComplete = (index: number) => (etag: string) => {
// This avoids the net::ERR_OUT_OF_MEMORY in Chromium Browsers.
this.#chunks[index] = null
this.#chunkState[index].etag = etag
this.#chunkState[index].done = true
const part = {
PartNumber: index + 1,
ETag: etag,
}
this.options.onPartComplete(part)
}
#abortUpload() {
this.#abortController.abort()
this.options.companionComm
.abortFileUpload(this.#file)
.catch((err: unknown) => this.options.log(err as Error))
}
start(): void {
if (this.#uploadHasStarted) {
if (!this.#abortController.signal.aborted)
this.#abortController.abort(pausingUploadReason)
this.#abortController = new AbortController()
this.#resumeUpload()
} else if (this.#isRestoring) {
this.options.companionComm.restoreUploadFile(this.#file, {
uploadId: this.options.uploadId,
key: this.options.key,
})
this.#resumeUpload()
} else {
this.#createUpload()
}
}
pause(): void {
this.#abortController.abort(pausingUploadReason)
// Swap it out for a new controller, because this instance may be resumed later.
this.#abortController = new AbortController()
}
abort(opts?: { really?: boolean }): void {
if (opts?.really) this.#abortUpload()
else this.pause()
}
private [Symbol.for('uppy test: getChunkState')]() {
return this.#chunkState
}
}
export default MultipartUploader