@uppy/aws-s3
Version:
Upload to Amazon S3 with Uppy
1,010 lines (887 loc) • 29.4 kB
text/typescript
import { RequestClient } from '@uppy/companion-client'
import {
BasePlugin,
type DefinePluginOpts,
type PluginOpts,
type Uppy,
} from '@uppy/core'
import EventManager from '@uppy/core/lib/EventManager.js'
import { createAbortError } from '@uppy/utils/lib/AbortController'
import type { RequestOptions } from '@uppy/utils/lib/CompanionClientProvider'
import {
filterFilesToEmitUploadStarted,
filterNonFailedFiles,
} from '@uppy/utils/lib/fileFilters'
import getAllowedMetaFields from '@uppy/utils/lib/getAllowedMetaFields'
import { RateLimitedQueue } from '@uppy/utils/lib/RateLimitedQueue'
import type { Body, Meta, UppyFile } from '@uppy/utils/lib/UppyFile'
import packageJson from '../package.json' with { type: 'json' }
import createSignedURL from './createSignedURL.js'
import { HTTPCommunicationQueue } from './HTTPCommunicationQueue.js'
import MultipartUploader from './MultipartUploader.js'
import type {
MultipartUploadResultWithSignal,
UploadPartBytesResult,
UploadResult,
UploadResultWithSignal,
} from './utils.js'
import { throwIfAborted } from './utils.js'
interface MultipartFile<M extends Meta, B extends Body> extends UppyFile<M, B> {
s3Multipart: UploadResult
}
type PartUploadedCallback<M extends Meta, B extends Body> = (
file: UppyFile<M, B>,
part: { PartNumber: number; ETag: string },
) => void
declare module '@uppy/core' {
export interface UppyEventMap<M extends Meta, B extends Body> {
's3-multipart:part-uploaded': PartUploadedCallback<M, B>
}
}
function assertServerError<T>(res: T): T {
if ((res as any)?.error) {
const error = new Error((res as any).message)
Object.assign(error, (res as any).error)
throw error
}
return res
}
export interface AwsS3STSResponse {
credentials: {
AccessKeyId: string
SecretAccessKey: string
SessionToken: string
Expiration?: string
}
bucket: string
region: string
}
/**
* Computes the expiry time for a request signed with temporary credentials. If
* no expiration was provided, or an invalid value (e.g. in the past) is
* provided, undefined is returned. This function assumes the client clock is in
* sync with the remote server, which is a requirement for the signature to be
* validated for AWS anyway.
*/
function getExpiry(
credentials: AwsS3STSResponse['credentials'],
): number | undefined {
const expirationDate = credentials.Expiration
if (expirationDate) {
const timeUntilExpiry = Math.floor(
((new Date(expirationDate) as any as number) - Date.now()) / 1000,
)
if (timeUntilExpiry > 9) {
return timeUntilExpiry
}
}
return undefined
}
function getAllowedMetadata<M extends Record<string, any>>({
meta,
allowedMetaFields,
querify = false,
}: {
meta: M
allowedMetaFields?: string[] | null
querify?: boolean
}) {
const metaFields = allowedMetaFields ?? Object.keys(meta)
if (!meta) return {}
return Object.fromEntries(
metaFields
.filter((key) => meta[key] != null)
.map((key) => {
const realKey = querify ? `metadata[${key}]` : key
const value = String(meta[key])
return [realKey, value]
}),
)
}
type MaybePromise<T> = T | Promise<T>
type SignPartOptions = {
uploadId: string
key: string
partNumber: number
body: Blob
signal?: AbortSignal
}
export type AwsS3UploadParameters =
| {
method: 'POST'
url: string
fields: Record<string, string>
expires?: number
headers?: Record<string, string>
}
| {
method?: 'PUT'
url: string
fields?: Record<string, never>
expires?: number
headers?: Record<string, string>
}
export interface AwsS3Part {
PartNumber?: number
Size?: number
ETag?: string
}
type AWSS3WithCompanion = {
endpoint: ConstructorParameters<
typeof RequestClient<any, any>
>[1]['companionUrl']
headers?: ConstructorParameters<
typeof RequestClient<any, any>
>[1]['companionHeaders']
cookiesRule?: ConstructorParameters<
typeof RequestClient<any, any>
>[1]['companionCookiesRule']
getTemporarySecurityCredentials?: true
}
type AWSS3WithoutCompanion = {
getTemporarySecurityCredentials?: (options?: {
signal?: AbortSignal
}) => MaybePromise<AwsS3STSResponse>
uploadPartBytes?: (options: {
signature: AwsS3UploadParameters
body: FormData | Blob
size?: number
onProgress: any
onComplete: any
signal?: AbortSignal
}) => Promise<UploadPartBytesResult>
}
// biome-ignore lint/complexity/noBannedTypes: ...
type AWSS3NonMultipartWithCompanionMandatory = {
// No related options
}
type AWSS3NonMultipartWithoutCompanionMandatory<
M extends Meta,
B extends Body,
> = {
getUploadParameters: (
file: UppyFile<M, B>,
options: RequestOptions,
) => MaybePromise<AwsS3UploadParameters>
}
type AWSS3NonMultipartWithCompanion = AWSS3WithCompanion &
AWSS3NonMultipartWithCompanionMandatory & {
shouldUseMultipart: false
}
type AWSS3NonMultipartWithoutCompanion<
M extends Meta,
B extends Body,
> = AWSS3WithoutCompanion &
AWSS3NonMultipartWithoutCompanionMandatory<M, B> & {
shouldUseMultipart: false
}
type AWSS3MultipartWithoutCompanionMandatorySignPart<
M extends Meta,
B extends Body,
> = {
signPart: (
file: UppyFile<M, B>,
opts: SignPartOptions,
) => MaybePromise<AwsS3UploadParameters>
}
type AWSS3MultipartWithoutCompanionMandatory<M extends Meta, B extends Body> = {
getChunkSize?: (file: { size: number }) => number
createMultipartUpload: (file: UppyFile<M, B>) => MaybePromise<UploadResult>
listParts: (
file: UppyFile<M, B>,
opts: UploadResultWithSignal,
) => MaybePromise<AwsS3Part[]>
abortMultipartUpload: (
file: UppyFile<M, B>,
opts: UploadResultWithSignal,
) => MaybePromise<void>
completeMultipartUpload: (
file: UppyFile<M, B>,
opts: {
uploadId: string
key: string
parts: AwsS3Part[]
signal: AbortSignal
},
) => MaybePromise<{ location?: string }>
} & AWSS3MultipartWithoutCompanionMandatorySignPart<M, B>
type AWSS3MultipartWithoutCompanion<
M extends Meta,
B extends Body,
> = AWSS3WithoutCompanion &
AWSS3MultipartWithoutCompanionMandatory<M, B> & {
shouldUseMultipart?: true
}
type AWSS3MultipartWithCompanion<
M extends Meta,
B extends Body,
> = AWSS3WithCompanion &
Partial<AWSS3MultipartWithoutCompanionMandatory<M, B>> & {
shouldUseMultipart?: true
}
type AWSS3MaybeMultipartWithCompanion<
M extends Meta,
B extends Body,
> = AWSS3WithCompanion &
Partial<AWSS3MultipartWithoutCompanionMandatory<M, B>> &
AWSS3NonMultipartWithCompanionMandatory & {
shouldUseMultipart: (file: UppyFile<M, B>) => boolean
}
type AWSS3MaybeMultipartWithoutCompanion<
M extends Meta,
B extends Body,
> = AWSS3WithoutCompanion &
AWSS3MultipartWithoutCompanionMandatory<M, B> &
AWSS3NonMultipartWithoutCompanionMandatory<M, B> & {
shouldUseMultipart: (file: UppyFile<M, B>) => boolean
}
interface _AwsS3MultipartOptions extends PluginOpts {
allowedMetaFields?: string[] | boolean
limit?: number
retryDelays?: number[] | null
}
export type AwsS3MultipartOptions<
M extends Meta,
B extends Body,
> = _AwsS3MultipartOptions &
(
| AWSS3NonMultipartWithCompanion
| AWSS3NonMultipartWithoutCompanion<M, B>
| AWSS3MultipartWithCompanion<M, B>
| AWSS3MultipartWithoutCompanion<M, B>
| AWSS3MaybeMultipartWithCompanion<M, B>
| AWSS3MaybeMultipartWithoutCompanion<M, B>
)
export type { AwsS3MultipartOptions as AwsS3Options }
const defaultOptions = {
allowedMetaFields: true,
limit: 6,
getTemporarySecurityCredentials: false as any,
shouldUseMultipart: ((file: UppyFile<any, any>) =>
(file.size || 0) > 100 * 1024 * 1024) as any as true,
retryDelays: [0, 1000, 3000, 5000],
} satisfies Partial<AwsS3MultipartOptions<any, any>>
export type { AwsBody } from './utils.js'
export default class AwsS3Multipart<
M extends Meta,
B extends Body,
> extends BasePlugin<
DefinePluginOpts<AwsS3MultipartOptions<M, B>, keyof typeof defaultOptions> &
// We also have a few dynamic options defined below:
Pick<
AWSS3MultipartWithoutCompanionMandatory<M, B>,
| 'getChunkSize'
| 'createMultipartUpload'
| 'listParts'
| 'abortMultipartUpload'
| 'completeMultipartUpload'
> &
Required<Pick<AWSS3WithoutCompanion, 'uploadPartBytes'>> &
Partial<AWSS3WithCompanion> &
AWSS3MultipartWithoutCompanionMandatorySignPart<M, B> &
AWSS3NonMultipartWithoutCompanionMandatory<M, B>,
M,
B
> {
static VERSION = packageJson.version
#companionCommunicationQueue
#client!: RequestClient<M, B>
protected requests: any
protected uploaderEvents: Record<string, EventManager<M, B> | null>
protected uploaders: Record<string, MultipartUploader<M, B> | null>
constructor(uppy: Uppy<M, B>, opts?: AwsS3MultipartOptions<M, B>) {
super(uppy, {
...defaultOptions,
uploadPartBytes: AwsS3Multipart.uploadPartBytes,
createMultipartUpload: null as any,
listParts: null as any,
abortMultipartUpload: null as any,
completeMultipartUpload: null as any,
signPart: null as any,
getUploadParameters: null as any,
...opts,
})
// We need the `as any` here because of the dynamic default options.
this.type = 'uploader'
this.id = this.opts.id || 'AwsS3Multipart'
this.#setClient(opts)
const dynamicDefaultOptions = {
createMultipartUpload: this.createMultipartUpload,
listParts: this.listParts,
abortMultipartUpload: this.abortMultipartUpload,
completeMultipartUpload: this.completeMultipartUpload,
signPart: opts?.getTemporarySecurityCredentials
? this.createSignedURL
: this.signPart,
getUploadParameters: opts?.getTemporarySecurityCredentials
? (this.createSignedURL as any)
: this.getUploadParameters,
} satisfies Partial<AwsS3MultipartOptions<M, B>>
for (const key of Object.keys(dynamicDefaultOptions)) {
if (this.opts[key as keyof typeof dynamicDefaultOptions] == null) {
this.opts[key as keyof typeof dynamicDefaultOptions] =
dynamicDefaultOptions[key as keyof typeof dynamicDefaultOptions].bind(
this,
)
}
}
/**
* Simultaneous upload limiting is shared across all uploads with this plugin.
*
* @type {RateLimitedQueue}
*/
this.requests =
(this.opts as any).rateLimitedQueue ??
new RateLimitedQueue(this.opts.limit)
this.#companionCommunicationQueue = new HTTPCommunicationQueue(
this.requests,
this.opts,
this.#setS3MultipartState,
this.#getFile,
)
this.uploaders = Object.create(null)
this.uploaderEvents = Object.create(null)
}
private [Symbol.for('uppy test: getClient')]() {
return this.#client
}
#setClient(opts?: Partial<AwsS3MultipartOptions<M, B>>) {
if (
opts == null ||
!(
'endpoint' in opts ||
'companionUrl' in opts ||
'headers' in opts ||
'companionHeaders' in opts ||
'cookiesRule' in opts ||
'companionCookiesRule' in opts
)
)
return
if ('companionUrl' in opts && !('endpoint' in opts)) {
this.uppy.log(
'`companionUrl` option has been removed in @uppy/aws-s3, use `endpoint` instead.',
'warning',
)
}
if ('companionHeaders' in opts && !('headers' in opts)) {
this.uppy.log(
'`companionHeaders` option has been removed in @uppy/aws-s3, use `headers` instead.',
'warning',
)
}
if ('companionCookiesRule' in opts && !('cookiesRule' in opts)) {
this.uppy.log(
'`companionCookiesRule` option has been removed in @uppy/aws-s3, use `cookiesRule` instead.',
'warning',
)
}
if ('endpoint' in opts) {
this.#client = new RequestClient(this.uppy, {
pluginId: this.id,
provider: 'AWS',
companionUrl: this.opts.endpoint!,
companionHeaders: this.opts.headers,
companionCookiesRule: this.opts.cookiesRule,
})
} else {
if ('headers' in opts) {
this.#setCompanionHeaders()
}
if ('cookiesRule' in opts) {
this.#client.opts.companionCookiesRule = opts.cookiesRule
}
}
}
setOptions(newOptions: Partial<AwsS3MultipartOptions<M, B>>): void {
this.#companionCommunicationQueue.setOptions(newOptions)
super.setOptions(newOptions as any)
this.#setClient(newOptions)
}
/**
* Clean up all references for a file's upload: the MultipartUploader instance,
* any events related to the file, and the Companion WebSocket connection.
*
* Set `opts.abort` to tell S3 that the multipart upload is cancelled and must be removed.
* This should be done when the user cancels the upload, not when the upload is completed or errored.
*/
resetUploaderReferences(fileID: string, opts?: { abort: boolean }): void {
if (this.uploaders[fileID]) {
this.uploaders[fileID]!.abort({ really: opts?.abort || false })
this.uploaders[fileID] = null
}
if (this.uploaderEvents[fileID]) {
this.uploaderEvents[fileID]!.remove()
this.uploaderEvents[fileID] = null
}
}
#assertHost(method: string): void {
if (!this.#client) {
throw new Error(
`Expected a \`endpoint\` option containing a URL, or if you are not using Companion, a custom \`${method}\` implementation.`,
)
}
}
createMultipartUpload(
file: UppyFile<M, B>,
signal?: AbortSignal,
): Promise<UploadResult> {
this.#assertHost('createMultipartUpload')
throwIfAborted(signal)
const allowedMetaFields = getAllowedMetaFields(
this.opts.allowedMetaFields,
file.meta,
)
const metadata = getAllowedMetadata({ meta: file.meta, allowedMetaFields })
return this.#client
.post<UploadResult>(
's3/multipart',
{
filename: file.name,
type: file.type,
metadata,
},
{ signal },
)
.then(assertServerError)
}
listParts(
file: UppyFile<M, B>,
{ key, uploadId, signal }: UploadResultWithSignal,
oldSignal?: AbortSignal,
): Promise<AwsS3Part[]> {
signal ??= oldSignal
this.#assertHost('listParts')
throwIfAborted(signal)
const filename = encodeURIComponent(key)
return this.#client
.get<AwsS3Part[]>(
`s3/multipart/${encodeURIComponent(uploadId!)}?key=${filename}`,
{ signal },
)
.then(assertServerError)
}
completeMultipartUpload(
file: UppyFile<M, B>,
{ key, uploadId, parts, signal }: MultipartUploadResultWithSignal,
oldSignal?: AbortSignal,
): Promise<B> {
signal ??= oldSignal
this.#assertHost('completeMultipartUpload')
throwIfAborted(signal)
const filename = encodeURIComponent(key)
const uploadIdEnc = encodeURIComponent(uploadId!)
return this.#client
.post<B>(
`s3/multipart/${uploadIdEnc}/complete?key=${filename}`,
{ parts: parts.map(({ ETag, PartNumber }) => ({ ETag, PartNumber })) },
{ signal },
)
.then(assertServerError)
}
#cachedTemporaryCredentials?: MaybePromise<AwsS3STSResponse>
async #getTemporarySecurityCredentials(options?: RequestOptions) {
throwIfAborted(options?.signal)
if (this.#cachedTemporaryCredentials == null) {
const { getTemporarySecurityCredentials } = this.opts
// We do not await it just yet, so concurrent calls do not try to override it:
if (getTemporarySecurityCredentials === true) {
this.#assertHost('getTemporarySecurityCredentials')
this.#cachedTemporaryCredentials = this.#client
.get<AwsS3STSResponse>('s3/sts', options)
.then(assertServerError)
} else {
this.#cachedTemporaryCredentials =
(getTemporarySecurityCredentials as AWSS3WithoutCompanion['getTemporarySecurityCredentials'])!(
options,
)
}
this.#cachedTemporaryCredentials = await this.#cachedTemporaryCredentials
setTimeout(
() => {
// At half the time left before expiration, we clear the cache. That's
// an arbitrary tradeoff to limit the number of requests made to the
// remote while limiting the risk of using an expired token in case the
// clocks are not exactly synced.
// The HTTP cache should be configured to ensure a client doesn't request
// more tokens than it needs, but this timeout provides a second layer of
// security in case the HTTP cache is disabled or misconfigured.
this.#cachedTemporaryCredentials = null as any
},
(getExpiry(this.#cachedTemporaryCredentials.credentials) || 0) * 500,
)
}
return this.#cachedTemporaryCredentials
}
async createSignedURL(
file: UppyFile<M, B>,
options: SignPartOptions,
): Promise<AwsS3UploadParameters> {
const data = await this.#getTemporarySecurityCredentials(options)
const expires = getExpiry(data.credentials) || 604_800 // 604 800 is the max value accepted by AWS.
const { uploadId, key, partNumber } = options
// Return an object in the correct shape.
return {
method: 'PUT',
expires,
fields: {},
url: `${await createSignedURL({
accountKey: data.credentials.AccessKeyId,
accountSecret: data.credentials.SecretAccessKey,
sessionToken: data.credentials.SessionToken,
expires,
bucketName: data.bucket,
Region: data.region,
Key: key ?? `${crypto.randomUUID()}-${file.name}`,
uploadId,
partNumber,
})}`,
// Provide content type header required by S3
headers: {
'Content-Type': file.type as string,
},
}
}
signPart(
file: UppyFile<M, B>,
{ uploadId, key, partNumber, signal }: SignPartOptions,
): Promise<AwsS3UploadParameters> {
this.#assertHost('signPart')
throwIfAborted(signal)
if (uploadId == null || key == null || partNumber == null) {
throw new Error(
'Cannot sign without a key, an uploadId, and a partNumber',
)
}
const filename = encodeURIComponent(key)
return this.#client
.get<AwsS3UploadParameters>(
`s3/multipart/${encodeURIComponent(uploadId)}/${partNumber}?key=${filename}`,
{ signal },
)
.then(assertServerError)
}
abortMultipartUpload(
file: UppyFile<M, B>,
{ key, uploadId, signal }: UploadResultWithSignal,
): Promise<void> {
this.#assertHost('abortMultipartUpload')
const filename = encodeURIComponent(key)
const uploadIdEnc = encodeURIComponent(uploadId!)
return this.#client
.delete<void>(`s3/multipart/${uploadIdEnc}?key=${filename}`, undefined, {
signal,
})
.then(assertServerError)
}
getUploadParameters(
file: UppyFile<M, B>,
options: RequestOptions,
): Promise<AwsS3UploadParameters> {
this.#assertHost('getUploadParameters')
const { meta } = file
const { type, name: filename } = meta
const allowedMetaFields = getAllowedMetaFields(
this.opts.allowedMetaFields,
file.meta,
)
const metadata = getAllowedMetadata({
meta,
allowedMetaFields,
querify: true,
})
const query = new URLSearchParams({ filename, type, ...metadata } as Record<
string,
string
>)
return this.#client.get(`s3/params?${query}`, options)
}
static async uploadPartBytes({
signature: { url, expires, headers, method = 'PUT' },
body,
size = (body as Blob).size,
onProgress,
onComplete,
signal,
}: {
signature: AwsS3UploadParameters
body: FormData | Blob
size?: number
onProgress: any
onComplete: any
signal?: AbortSignal
}): Promise<UploadPartBytesResult> {
throwIfAborted(signal)
if (url == null) {
throw new Error('Cannot upload to an undefined URL')
}
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open(method, url, true)
if (headers) {
Object.keys(headers).forEach((key) => {
xhr.setRequestHeader(key, headers[key])
})
}
xhr.responseType = 'text'
if (typeof expires === 'number') {
xhr.timeout = expires * 1000
}
function onabort() {
xhr.abort()
}
function cleanup() {
signal?.removeEventListener('abort', onabort)
}
signal?.addEventListener('abort', onabort)
xhr.upload.addEventListener('progress', (ev) => {
onProgress(ev)
})
xhr.addEventListener('abort', () => {
cleanup()
reject(createAbortError())
})
xhr.addEventListener('timeout', () => {
cleanup()
const error = new Error('Request has expired')
;(error as any).source = { status: 403 }
reject(error)
})
xhr.addEventListener('load', () => {
cleanup()
if (
xhr.status === 403 &&
xhr.responseText.includes('<Message>Request has expired</Message>')
) {
const error = new Error('Request has expired')
;(error as any).source = xhr
reject(error)
return
}
if (xhr.status < 200 || xhr.status >= 300) {
const error = new Error('Non 2xx')
;(error as any).source = xhr
reject(error)
return
}
onProgress?.({ loaded: size, lengthComputable: true })
// https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/getAllResponseHeaders#examples
const arr = xhr
.getAllResponseHeaders()
.trim()
.split(/[\r\n]+/)
// @ts-expect-error null is allowed to avoid inherited properties
const headersMap: Record<string, string> = { __proto__: null }
for (const line of arr) {
const parts = line.split(': ')
const header = parts.shift()!
const value = parts.join(': ')
headersMap[header] = value
}
const { etag, location } = headersMap
// More info bucket settings when this is not present:
// https://github.com/transloadit/uppy/issues/5388#issuecomment-2464885562
if (method.toUpperCase() === 'POST' && location == null) {
// Not being able to read the Location header is not a fatal error.
console.error(
'@uppy/aws-s3: Could not read the Location header. This likely means CORS is not configured correctly on the S3 Bucket. See https://uppy.io/docs/aws-s3/#setting-up-your-s3-bucket',
)
}
if (etag == null) {
console.error(
'@uppy/aws-s3: Could not read the ETag header. This likely means CORS is not configured correctly on the S3 Bucket. See https://uppy.io/docs/aws-s3/#setting-up-your-s3-bucket',
)
return
}
onComplete?.(etag)
resolve({
...headersMap,
ETag: etag, // keep capitalised ETag for backwards compatiblity
})
})
xhr.addEventListener('error', (ev) => {
cleanup()
const error = new Error('Unknown error')
;(error as any).source = ev.target
reject(error)
})
xhr.send(body)
})
}
#setS3MultipartState = (
file: UppyFile<M, B>,
{ key, uploadId }: UploadResult,
) => {
const cFile = this.uppy.getFile(file.id)
if (cFile == null) {
// file was removed from store
return
}
this.uppy.setFileState(file.id, {
s3Multipart: {
...(cFile as MultipartFile<M, B>).s3Multipart,
key,
uploadId,
},
} as Partial<MultipartFile<M, B>>)
}
#getFile = (file: UppyFile<M, B>) => {
return this.uppy.getFile(file.id) || file
}
#uploadLocalFile(file: UppyFile<M, B>) {
return new Promise<undefined | string>((resolve, reject) => {
const onProgress = (bytesUploaded: number, bytesTotal: number) => {
const latestFile = this.uppy.getFile(file.id)
this.uppy.emit('upload-progress', latestFile, {
uploadStarted: latestFile.progress.uploadStarted ?? 0,
bytesUploaded,
bytesTotal,
})
}
const onError = (err: unknown) => {
this.uppy.log(err as Error)
this.uppy.emit('upload-error', file, err as Error)
this.resetUploaderReferences(file.id)
reject(err)
}
const onSuccess = (result: B) => {
const uploadResp = {
body: {
...result,
},
status: 200,
uploadURL: result.location as string,
}
this.resetUploaderReferences(file.id)
this.uppy.emit('upload-success', this.#getFile(file), uploadResp)
if (result.location) {
this.uppy.log(`Download ${file.name} from ${result.location}`)
}
resolve(undefined)
}
const upload = new MultipartUploader<M, B>(file.data, {
// .bind to pass the file object to each handler.
companionComm: this.#companionCommunicationQueue,
log: (...args: Parameters<Uppy<M, B>['log']>) => this.uppy.log(...args),
getChunkSize: this.opts.getChunkSize
? this.opts.getChunkSize.bind(this)
: undefined,
onProgress,
onError,
onSuccess,
onPartComplete: (part) => {
this.uppy.emit(
's3-multipart:part-uploaded',
this.#getFile(file),
part,
)
},
file,
shouldUseMultipart: this.opts.shouldUseMultipart,
...(file as MultipartFile<M, B>).s3Multipart,
})
this.uploaders[file.id] = upload
const eventManager = new EventManager(this.uppy)
this.uploaderEvents[file.id] = eventManager
eventManager.onFileRemove(file.id, (removed) => {
upload.abort()
this.resetUploaderReferences(file.id, { abort: true })
resolve(`upload ${removed} was removed`)
})
eventManager.onCancelAll(file.id, () => {
upload.abort()
this.resetUploaderReferences(file.id, { abort: true })
resolve(`upload ${file.id} was canceled`)
})
eventManager.onFilePause(file.id, (isPaused) => {
if (isPaused) {
upload.pause()
} else {
upload.start()
}
})
eventManager.onPauseAll(file.id, () => {
upload.pause()
})
eventManager.onResumeAll(file.id, () => {
upload.start()
})
upload.start()
})
}
#getCompanionClientArgs(file: UppyFile<M, B>) {
return {
...file.remote?.body,
protocol: 's3-multipart',
size: file.data.size,
metadata: file.meta,
}
}
#upload = async (fileIDs: string[]) => {
if (fileIDs.length === 0) return undefined
const files = this.uppy.getFilesByIds(fileIDs)
const filesFiltered = filterNonFailedFiles(files)
const filesToEmit = filterFilesToEmitUploadStarted(filesFiltered)
this.uppy.emit('upload-start', filesToEmit)
const promises = filesFiltered.map((file) => {
if (file.isRemote) {
const getQueue = () => this.requests
this.#setResumableUploadsCapability(false)
const controller = new AbortController()
const removedHandler = (removedFile: UppyFile<M, B>) => {
if (removedFile.id === file.id) controller.abort()
}
this.uppy.on('file-removed', removedHandler)
const uploadPromise = this.uppy
.getRequestClientForFile<RequestClient<M, B>>(file)
.uploadRemoteFile(file, this.#getCompanionClientArgs(file), {
signal: controller.signal,
getQueue,
})
this.requests.wrapSyncFunction(
() => {
this.uppy.off('file-removed', removedHandler)
},
{ priority: -1 },
)()
return uploadPromise
}
return this.#uploadLocalFile(file)
})
const upload = await Promise.allSettled(promises)
// After the upload is done, another upload may happen with only local files.
// We reset the capability so that the next upload can use resumable uploads.
this.#setResumableUploadsCapability(true)
return upload
}
#setCompanionHeaders = () => {
this.#client?.setCompanionHeaders(this.opts.headers!)
}
#setResumableUploadsCapability = (boolean: boolean) => {
const { capabilities } = this.uppy.getState()
this.uppy.setState({
capabilities: {
...capabilities,
resumableUploads: boolean,
},
})
}
#resetResumableCapability = () => {
this.#setResumableUploadsCapability(true)
}
install(): void {
this.#setResumableUploadsCapability(true)
this.uppy.addPreProcessor(this.#setCompanionHeaders)
this.uppy.addUploader(this.#upload)
this.uppy.on('cancel-all', this.#resetResumableCapability)
}
uninstall(): void {
this.uppy.removePreProcessor(this.#setCompanionHeaders)
this.uppy.removeUploader(this.#upload)
this.uppy.off('cancel-all', this.#resetResumableCapability)
}
}
export type uploadPartBytes = (typeof AwsS3Multipart<
any,
any
>)['uploadPartBytes']