@uppy/aws-s3-multipart: refactor to TS (#4902)

This commit is contained in:
Antoine du Hamel 2024-03-19 23:30:32 +02:00 committed by GitHub
parent 96dc19766c
commit 7b0533b4bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 2019 additions and 1268 deletions

View file

@ -0,0 +1,427 @@
import type { Meta, UppyFile } from '@uppy/utils/lib/UppyFile'
import type {
RateLimitedQueue,
WrapPromiseFunctionType,
} from '@uppy/utils/lib/RateLimitedQueue'
import { pausingUploadReason, type Chunk } from './MultipartUploader.ts'
import type AwsS3Multipart from './index.ts'
import { throwIfAborted } from './utils.ts'
import type { Body, UploadPartBytesResult, UploadResult } from './utils.ts'
import type { AwsS3MultipartOptions, uploadPartBytes } from './index.ts'
function removeMetadataFromURL(urlString: string) {
const urlObject = new URL(urlString)
urlObject.search = ''
urlObject.hash = ''
return urlObject.href
}
export class HTTPCommunicationQueue<M extends Meta, B extends Body> {
#abortMultipartUpload: WrapPromiseFunctionType<
AwsS3Multipart<M, B>['abortMultipartUpload']
>
#cache = new WeakMap()
#createMultipartUpload: WrapPromiseFunctionType<
AwsS3Multipart<M, B>['createMultipartUpload']
>
#fetchSignature: WrapPromiseFunctionType<AwsS3Multipart<M, B>['signPart']>
#getUploadParameters: WrapPromiseFunctionType<
AwsS3Multipart<M, B>['getUploadParameters']
>
#listParts: WrapPromiseFunctionType<AwsS3Multipart<M, B>['listParts']>
#previousRetryDelay: number
#requests
#retryDelays: { values: () => Iterator<number> }
#sendCompletionRequest: WrapPromiseFunctionType<
AwsS3Multipart<M, B>['completeMultipartUpload']
>
#setS3MultipartState
#uploadPartBytes: WrapPromiseFunctionType<uploadPartBytes>
#getFile
constructor(
requests: RateLimitedQueue,
options: AwsS3MultipartOptions<M, B>,
setS3MultipartState: (file: UppyFile<M, B>, result: UploadResult) => void,
getFile: (file: UppyFile<M, B>) => UppyFile<M, B>,
) {
this.#requests = requests
this.#setS3MultipartState = setS3MultipartState
this.#getFile = getFile
this.setOptions(options)
}
setOptions(options: Partial<AwsS3MultipartOptions<M, B>>): void {
const requests = this.#requests
if ('abortMultipartUpload' in options) {
this.#abortMultipartUpload = requests.wrapPromiseFunction(
options.abortMultipartUpload as any,
{ priority: 1 },
)
}
if ('createMultipartUpload' in options) {
this.#createMultipartUpload = requests.wrapPromiseFunction(
options.createMultipartUpload as any,
{ priority: -1 },
)
}
if ('signPart' in options) {
this.#fetchSignature = requests.wrapPromiseFunction(
options.signPart as any,
)
}
if ('listParts' in options) {
this.#listParts = requests.wrapPromiseFunction(options.listParts as any)
}
if ('completeMultipartUpload' in options) {
this.#sendCompletionRequest = requests.wrapPromiseFunction(
options.completeMultipartUpload as any,
{ priority: 1 },
)
}
if ('retryDelays' in options) {
this.#retryDelays = options.retryDelays ?? []
}
if ('uploadPartBytes' in options) {
this.#uploadPartBytes = requests.wrapPromiseFunction(
options.uploadPartBytes as any,
{ priority: Infinity },
)
}
if ('getUploadParameters' in options) {
this.#getUploadParameters = requests.wrapPromiseFunction(
options.getUploadParameters as any,
)
}
}
async #shouldRetry(err: any, retryDelayIterator: Iterator<number>) {
const requests = this.#requests
const status = err?.source?.status
// TODO: this retry logic is taken out of Tus. We should have a centralized place for retrying,
// perhaps the rate limited queue, and dedupe all plugins with that.
if (status == null) {
return false
}
if (status === 403 && err.message === 'Request has expired') {
if (!requests.isPaused) {
// We don't want to exhaust the retryDelayIterator as long as there are
// more than one request in parallel, to give slower connection a chance
// to catch up with the expiry set in Companion.
if (requests.limit === 1 || this.#previousRetryDelay == null) {
const next = retryDelayIterator.next()
if (next == null || next.done) {
return false
}
// If there are more than 1 request done in parallel, the RLQ limit is
// decreased and the failed request is requeued after waiting for a bit.
// If there is only one request in parallel, the limit can't be
// decreased, so we iterate over `retryDelayIterator` as we do for
// other failures.
// `#previousRetryDelay` caches the value so we can re-use it next time.
this.#previousRetryDelay = next.value
}
// No need to stop the other requests, we just want to lower the limit.
requests.rateLimit(0)
await new Promise((resolve) =>
setTimeout(resolve, this.#previousRetryDelay),
)
}
} else if (status === 429) {
// HTTP 429 Too Many Requests => to avoid the whole download to fail, pause all requests.
if (!requests.isPaused) {
const next = retryDelayIterator.next()
if (next == null || next.done) {
return false
}
requests.rateLimit(next.value)
}
} else if (status > 400 && status < 500 && status !== 409) {
// HTTP 4xx, the server won't send anything, it's doesn't make sense to retry
return false
} else if (typeof navigator !== 'undefined' && navigator.onLine === false) {
// The navigator is offline, let's wait for it to come back online.
if (!requests.isPaused) {
requests.pause()
window.addEventListener(
'online',
() => {
requests.resume()
},
{ once: true },
)
}
} else {
// Other error code means the request can be retried later.
const next = retryDelayIterator.next()
if (next == null || next.done) {
return false
}
await new Promise((resolve) => setTimeout(resolve, next.value))
}
return true
}
async getUploadId(
file: UppyFile<M, B>,
signal: AbortSignal,
): Promise<UploadResult> {
let cachedResult
// As the cache is updated asynchronously, there could be a race condition
// where we just miss a new result so we loop here until we get nothing back,
// at which point it's out turn to create a new cache entry.
// eslint-disable-next-line no-cond-assign
while ((cachedResult = this.#cache.get(file.data)) != null) {
try {
return await cachedResult
} catch {
// In case of failure, we want to ignore the cached error.
// At this point, either there's a new cached value, or we'll exit the loop a create a new one.
}
}
const promise = this.#createMultipartUpload(this.#getFile(file), signal)
const abortPromise = () => {
promise.abort(signal.reason)
this.#cache.delete(file.data)
}
signal.addEventListener('abort', abortPromise, { once: true })
this.#cache.set(file.data, promise)
promise.then(
async (result) => {
signal.removeEventListener('abort', abortPromise)
this.#setS3MultipartState(file, result)
this.#cache.set(file.data, result)
},
() => {
signal.removeEventListener('abort', abortPromise)
this.#cache.delete(file.data)
},
)
return promise
}
async abortFileUpload(file: UppyFile<M, B>): Promise<void> {
const result = this.#cache.get(file.data)
if (result == null) {
// If the createMultipartUpload request never was made, we don't
// need to send the abortMultipartUpload request.
return
}
// Remove the cache entry right away for follow-up requests do not try to
// use the soon-to-be aborted cached values.
this.#cache.delete(file.data)
this.#setS3MultipartState(file, Object.create(null))
let awaitedResult
try {
awaitedResult = await result
} catch {
// If the cached result rejects, there's nothing to abort.
return
}
await this.#abortMultipartUpload(this.#getFile(file), awaitedResult)
}
async #nonMultipartUpload(
file: UppyFile<M, B>,
chunk: Chunk,
signal?: AbortSignal,
): Promise<UploadPartBytesResult & B> {
const {
method = 'POST',
url,
fields,
headers,
} = await this.#getUploadParameters(this.#getFile(file), {
signal,
}).abortOn(signal)
let body
const data = chunk.getData()
if (method.toUpperCase() === 'POST') {
const formData = new FormData()
Object.entries(fields!).forEach(([key, value]) =>
formData.set(key, value),
)
formData.set('file', data)
body = formData
} else {
body = data
}
const { onProgress, onComplete } = chunk
const result = await this.#uploadPartBytes({
signature: { url, headers, method } as any,
body,
size: data.size,
onProgress,
onComplete,
signal,
}).abortOn(signal)
return 'location' in result ?
(result as UploadPartBytesResult & B)
: ({
location: removeMetadataFromURL(url),
...result,
} as any)
}
async uploadFile(
file: UppyFile<M, B>,
chunks: Chunk[],
signal: AbortSignal,
): Promise<B & Partial<UploadPartBytesResult>> {
throwIfAborted(signal)
if (chunks.length === 1 && !chunks[0].shouldUseMultipart) {
return this.#nonMultipartUpload(file, chunks[0], signal)
}
const { uploadId, key } = await this.getUploadId(file, signal)
throwIfAborted(signal)
try {
const parts = await Promise.all(
chunks.map((chunk, i) => this.uploadChunk(file, i + 1, chunk, signal)),
)
throwIfAborted(signal)
return await this.#sendCompletionRequest(
this.#getFile(file),
{ key, uploadId, parts, signal },
signal,
).abortOn(signal)
} catch (err) {
if (err?.cause !== pausingUploadReason && err?.name !== 'AbortError') {
// We purposefully don't wait for the promise and ignore its status,
// because we want the error `err` to bubble up ASAP to report it to the
// user. A failure to abort is not that big of a deal anyway.
this.abortFileUpload(file)
}
throw err
}
}
restoreUploadFile(file: UppyFile<M, B>, uploadIdAndKey: UploadResult): void {
this.#cache.set(file.data, uploadIdAndKey)
}
async resumeUploadFile(
file: UppyFile<M, B>,
chunks: Array<Chunk | null>,
signal: AbortSignal,
): Promise<B> {
throwIfAborted(signal)
if (
chunks.length === 1 &&
chunks[0] != null &&
!chunks[0].shouldUseMultipart
) {
return this.#nonMultipartUpload(file, chunks[0], signal)
}
const { uploadId, key } = await this.getUploadId(file, signal)
throwIfAborted(signal)
const alreadyUploadedParts = await this.#listParts(
this.#getFile(file),
{ uploadId, key, signal },
signal,
).abortOn(signal)
throwIfAborted(signal)
const parts = await Promise.all(
chunks.map((chunk, i) => {
const partNumber = i + 1
const alreadyUploadedInfo = alreadyUploadedParts.find(
({ PartNumber }) => PartNumber === partNumber,
)
if (alreadyUploadedInfo == null) {
return this.uploadChunk(file, partNumber, chunk!, signal)
}
// Already uploaded chunks are set to null. If we are restoring the upload, we need to mark it as already uploaded.
chunk?.setAsUploaded?.()
return { PartNumber: partNumber, ETag: alreadyUploadedInfo.ETag }
}),
)
throwIfAborted(signal)
return this.#sendCompletionRequest(
this.#getFile(file),
{ key, uploadId, parts, signal },
signal,
).abortOn(signal)
}
async uploadChunk(
file: UppyFile<M, B>,
partNumber: number,
chunk: Chunk,
signal: AbortSignal,
): Promise<UploadPartBytesResult & { PartNumber: number }> {
throwIfAborted(signal)
const { uploadId, key } = await this.getUploadId(file, signal)
const signatureRetryIterator = this.#retryDelays.values()
const chunkRetryIterator = this.#retryDelays.values()
const shouldRetrySignature = () => {
const next = signatureRetryIterator.next()
if (next == null || next.done) {
return null
}
return next.value
}
for (;;) {
throwIfAborted(signal)
const chunkData = chunk.getData()
const { onProgress, onComplete } = chunk
let signature
try {
signature = await this.#fetchSignature(this.#getFile(file), {
uploadId,
key,
partNumber,
body: chunkData,
signal,
}).abortOn(signal)
} catch (err) {
const timeout = shouldRetrySignature()
if (timeout == null || signal.aborted) {
throw err
}
await new Promise((resolve) => setTimeout(resolve, timeout))
// eslint-disable-next-line no-continue
continue
}
throwIfAborted(signal)
try {
return {
PartNumber: partNumber,
...(await this.#uploadPartBytes({
signature,
body: chunkData,
size: chunkData.size,
onProgress,
onComplete,
signal,
}).abortOn(signal)),
}
} catch (err) {
if (!(await this.#shouldRetry(err, chunkRetryIterator))) throw err
}
}
}
}

View file

@ -1,24 +1,53 @@
import type { Uppy } from '@uppy/core'
import { AbortController } from '@uppy/utils/lib/AbortController'
import type { Meta, UppyFile } from '@uppy/utils/lib/UppyFile'
import type { HTTPCommunicationQueue } from './HTTPCommunicationQueue'
import type { Body } from './utils'
const MB = 1024 * 1024
const defaultOptions = {
getChunkSize (file) {
return Math.ceil(file.size / 10000)
},
onProgress () {},
onPartComplete () {},
onSuccess () {},
onError (err) {
throw err
},
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
}
function ensureInt (value) {
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')
@ -32,47 +61,41 @@ export const pausingUploadReason = Symbol('pausing upload, not an actual error')
* (based on the user-provided `shouldUseMultipart` option value) and to manage
* the chunk splitting.
*/
class MultipartUploader {
class MultipartUploader<M extends Meta, B extends Body> {
options: MultipartUploaderOptions<M, B> &
Required<Pick<MultipartUploaderOptions<M, B>, keyof typeof defaultOptions>>
#abortController = new AbortController()
/** @type {import("../types/chunk").Chunk[]} */
#chunks
#chunks: Array<Chunk | null>
/** @type {{ uploaded: number, etag?: string, done?: boolean }[]} */
#chunkState
#chunkState: { uploaded: number; etag?: string; done?: boolean }[]
/**
* The (un-chunked) data to upload.
*
* @type {Blob}
*/
#data
#data: Blob
/** @type {import("@uppy/core").UppyFile} */
#file
#file: UppyFile<M, B>
/** @type {boolean} */
#uploadHasStarted = false
/** @type {(err?: Error | any) => void} */
#onError
#onError: (err: unknown) => void
/** @type {() => void} */
#onSuccess
#onSuccess: (result: B) => void
/** @type {import('../types/index').AwsS3MultipartOptions["shouldUseMultipart"]} */
#shouldUseMultipart
#shouldUseMultipart: MultipartUploaderOptions<M, B>['shouldUseMultipart']
/** @type {boolean} */
#isRestoring
#isRestoring: boolean
#onReject = (err) => (err?.cause === pausingUploadReason ? null : this.#onError(err))
#onReject = (err: unknown) =>
(err as any)?.cause === pausingUploadReason ? null : this.#onError(err)
#maxMultipartParts = 10_000
#minPartSize = 5 * MB
constructor (data, options) {
constructor(data: Blob, options: MultipartUploaderOptions<M, B>) {
this.options = {
...defaultOptions,
...options,
@ -89,7 +112,7 @@ class MultipartUploader {
// 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
this.#isRestoring = (options.uploadId && options.key) as any as boolean
this.#initChunks()
}
@ -98,15 +121,19 @@ class MultipartUploader {
// 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 () {
#initChunks() {
const fileSize = this.#data.size
const shouldUseMultipart = typeof this.#shouldUseMultipart === 'function'
? this.#shouldUseMultipart(this.#file)
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), this.#minPartSize)
let chunkSize = Math.max(
this.options.getChunkSize(this.#data),
this.#minPartSize,
)
let arraySize = Math.floor(fileSize / chunkSize)
// At most 10k requests per file:
@ -132,41 +159,48 @@ class MultipartUploader {
shouldUseMultipart,
}
if (this.#isRestoring) {
const size = offset + chunkSize > fileSize ? fileSize - offset : chunkSize
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]!.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.#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, this.#abortController.signal)
#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)
#resumeUpload() {
this.options.companionComm
.resumeUploadFile(this.#file, this.#chunks, this.#abortController.signal)
.then(this.#onSuccess, this.#onReject)
}
#onPartProgress = (index) => (ev) => {
#onPartProgress = (index: number) => (ev: ProgressEvent) => {
if (!ev.lengthComputable) return
this.#chunkState[index].uploaded = ensureInt(ev.loaded)
@ -175,7 +209,7 @@ class MultipartUploader {
this.options.onProgress(totalUploaded, this.#data.size)
}
#onPartComplete = (index) => (etag) => {
#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
@ -188,37 +222,44 @@ class MultipartUploader {
this.options.onPartComplete(part)
}
#abortUpload () {
#abortUpload() {
this.#abortController.abort()
this.options.companionComm.abortFileUpload(this.#file).catch((err) => this.options.log(err))
this.options.companionComm
.abortFileUpload(this.#file)
.catch((err: unknown) => this.options.log(err as Error))
}
start () {
start(): void {
if (this.#uploadHasStarted) {
if (!this.#abortController.signal.aborted) this.#abortController.abort(pausingUploadReason)
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.options.companionComm.restoreUploadFile(this.#file, {
uploadId: this.options.uploadId,
key: this.options.key,
})
this.#resumeUpload()
} else {
this.#createUpload()
}
}
pause () {
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 = undefined) {
abort(opts?: { really?: boolean }): void {
if (opts?.really) this.#abortUpload()
else this.pause()
}
// TODO: remove this in the next major
get chunkState () {
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
get chunkState() {
return this.#chunkState
}
}

View file

@ -1,113 +0,0 @@
import { describe, it, beforeEach, afterEach } from 'vitest'
import assert from 'node:assert'
import { S3Client, UploadPartCommand, PutObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import createSignedURL from './createSignedURL.js'
const bucketName = 'some-bucket'
const s3ClientOptions = {
region: 'us-bar-1',
credentials: {
accessKeyId: 'foo',
secretAccessKey: 'bar',
sessionToken: 'foobar',
},
}
const { Date: OriginalDate } = globalThis
describe('createSignedURL', () => {
beforeEach(() => {
const now_ms = OriginalDate.now()
globalThis.Date = function Date () {
if (new.target) {
return Reflect.construct(OriginalDate, [now_ms])
}
return Reflect.apply(OriginalDate, this, [now_ms])
}
globalThis.Date.now = function now () {
return now_ms
}
})
afterEach(() => {
globalThis.Date = OriginalDate
})
it('should be able to sign non-multipart upload', async () => {
const client = new S3Client(s3ClientOptions)
assert.strictEqual(
(await createSignedURL({
accountKey: s3ClientOptions.credentials.accessKeyId,
accountSecret: s3ClientOptions.credentials.secretAccessKey,
sessionToken: s3ClientOptions.credentials.sessionToken,
bucketName,
Key: 'some/key',
Region: s3ClientOptions.region,
expires: 900,
})).searchParams.get('X-Amz-Signature'),
new URL(await getSignedUrl(client, new PutObjectCommand({
Bucket: bucketName,
Fields: {},
Key: 'some/key',
}), { expiresIn: 900 })).searchParams.get('X-Amz-Signature'),
)
})
it('should be able to sign multipart upload', async () => {
const client = new S3Client(s3ClientOptions)
const partNumber = 99
const uploadId = 'dummyUploadId'
assert.strictEqual(
(await createSignedURL({
accountKey: s3ClientOptions.credentials.accessKeyId,
accountSecret: s3ClientOptions.credentials.secretAccessKey,
sessionToken: s3ClientOptions.credentials.sessionToken,
uploadId,
partNumber,
bucketName,
Key: 'some/key',
Region: s3ClientOptions.region,
expires: 900,
})).searchParams.get('X-Amz-Signature'),
new URL(await getSignedUrl(client, new UploadPartCommand({
Bucket: bucketName,
UploadId: uploadId,
PartNumber: partNumber,
Key: 'some/key',
}), { expiresIn: 900 })).searchParams.get('X-Amz-Signature'),
)
})
it('should escape path and query as restricted to RFC 3986', async () => {
const client = new S3Client(s3ClientOptions)
const partNumber = 99
const specialChars = ';?:@&=+$,#!\'()'
const uploadId = `Upload${specialChars}Id`
// '.*' chars of path should be encoded
const Key = `${specialChars}.*/${specialChars}.*.ext`
const implResult =
await createSignedURL({
accountKey: s3ClientOptions.credentials.accessKeyId,
accountSecret: s3ClientOptions.credentials.secretAccessKey,
sessionToken: s3ClientOptions.credentials.sessionToken,
uploadId,
partNumber,
bucketName,
Key,
Region: s3ClientOptions.region,
expires: 900,
})
const sdkResult =
new URL(
await getSignedUrl(client, new UploadPartCommand({
Bucket: bucketName,
UploadId: uploadId,
PartNumber: partNumber,
Key,
}), { expiresIn: 900 }
)
)
assert.strictEqual(implResult.pathname, sdkResult.pathname)
const extractUploadId = /([?&])uploadId=([^&]+?)(&|$)/
const extractSignature = /([?&])X-Amz-Signature=([^&]+?)(&|$)/
assert.strictEqual(implResult.search.match(extractUploadId)[2], sdkResult.search.match(extractUploadId)[2])
assert.strictEqual(implResult.search.match(extractSignature)[2], sdkResult.search.match(extractSignature)[2])
})
})

View file

@ -0,0 +1,141 @@
import { describe, it, beforeEach, afterEach } from 'vitest'
import assert from 'node:assert'
import {
S3Client,
UploadPartCommand,
PutObjectCommand,
} from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import createSignedURL from './createSignedURL.ts'
const bucketName = 'some-bucket'
const s3ClientOptions = {
region: 'us-bar-1',
credentials: {
accessKeyId: 'foo',
secretAccessKey: 'bar',
sessionToken: 'foobar',
},
}
const { Date: OriginalDate } = globalThis
describe('createSignedURL', () => {
beforeEach(() => {
const now_ms = OriginalDate.now()
// @ts-expect-error we're touching globals for test purposes.
globalThis.Date = function Date() {
if (new.target) {
return Reflect.construct(OriginalDate, [now_ms])
}
return Reflect.apply(OriginalDate, this, [now_ms])
}
globalThis.Date.now = function now() {
return now_ms
}
})
afterEach(() => {
globalThis.Date = OriginalDate
})
it('should be able to sign non-multipart upload', async () => {
const client = new S3Client(s3ClientOptions)
assert.strictEqual(
(
await createSignedURL({
accountKey: s3ClientOptions.credentials.accessKeyId,
accountSecret: s3ClientOptions.credentials.secretAccessKey,
sessionToken: s3ClientOptions.credentials.sessionToken,
bucketName,
Key: 'some/key',
Region: s3ClientOptions.region,
expires: 900,
})
).searchParams.get('X-Amz-Signature'),
new URL(
await getSignedUrl(
client,
new PutObjectCommand({
Bucket: bucketName,
Key: 'some/key',
}),
{ expiresIn: 900 },
),
).searchParams.get('X-Amz-Signature'),
)
})
it('should be able to sign multipart upload', async () => {
const client = new S3Client(s3ClientOptions)
const partNumber = 99
const uploadId = 'dummyUploadId'
assert.strictEqual(
(
await createSignedURL({
accountKey: s3ClientOptions.credentials.accessKeyId,
accountSecret: s3ClientOptions.credentials.secretAccessKey,
sessionToken: s3ClientOptions.credentials.sessionToken,
uploadId,
partNumber,
bucketName,
Key: 'some/key',
Region: s3ClientOptions.region,
expires: 900,
})
).searchParams.get('X-Amz-Signature'),
new URL(
await getSignedUrl(
client,
new UploadPartCommand({
Bucket: bucketName,
UploadId: uploadId,
PartNumber: partNumber,
Key: 'some/key',
}),
{ expiresIn: 900 },
),
).searchParams.get('X-Amz-Signature'),
)
})
it('should escape path and query as restricted to RFC 3986', async () => {
const client = new S3Client(s3ClientOptions)
const partNumber = 99
const specialChars = ";?:@&=+$,#!'()"
const uploadId = `Upload${specialChars}Id`
// '.*' chars of path should be encoded
const Key = `${specialChars}.*/${specialChars}.*.ext`
const implResult = await createSignedURL({
accountKey: s3ClientOptions.credentials.accessKeyId,
accountSecret: s3ClientOptions.credentials.secretAccessKey,
sessionToken: s3ClientOptions.credentials.sessionToken,
uploadId,
partNumber,
bucketName,
Key,
Region: s3ClientOptions.region,
expires: 900,
})
const sdkResult = new URL(
await getSignedUrl(
client,
new UploadPartCommand({
Bucket: bucketName,
UploadId: uploadId,
PartNumber: partNumber,
Key,
}),
{ expiresIn: 900 },
),
)
assert.strictEqual(implResult.pathname, sdkResult.pathname)
const extractUploadId = /([?&])uploadId=([^&]+?)(&|$)/
const extractSignature = /([?&])X-Amz-Signature=([^&]+?)(&|$)/
assert.strictEqual(
implResult.search.match(extractUploadId)![2],
sdkResult.search.match(extractUploadId)![2],
)
assert.strictEqual(
implResult.search.match(extractSignature)![2],
sdkResult.search.match(extractSignature)![2],
)
})
})

View file

@ -5,44 +5,51 @@
*
* @see https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html#create-canonical-request
*
* @param {object} param0
* @param {string} param0.method The HTTP method.
* @param {string} param0.CanonicalUri The URI-encoded version of the absolute
* @param param0
* @param param0.method The HTTP method.
* @param param0.CanonicalUri The URI-encoded version of the absolute
* path component URL (everything between the host and the question mark
* character (?) that starts the query string parameters). If the absolute path
* is empty, use a forward slash character (/).
* @param {string} param0.CanonicalQueryString The URL-encoded query string
* @param param0.CanonicalQueryString The URL-encoded query string
* parameters, separated by ampersands (&). Percent-encode reserved characters,
* including the space character. Encode names and values separately. If there
* are empty parameters, append the equals sign to the parameter name before
* encoding. After encoding, sort the parameters alphabetically by key name. If
* there is no query string, use an empty string ("").
* @param {Record<string, string>} param0.SignedHeaders The request headers,
* @param param0.SignedHeaders The request headers,
* that will be signed, and their values, separated by newline characters.
* For the values, trim any leading or trailing spaces, convert sequential
* spaces to a single space, and separate the values for a multi-value header
* using commas. You must include the host header (HTTP/1.1), and any x-amz-*
* headers in the signature. You can optionally include other standard headers
* in the signature, such as content-type.
* @param {string} param0.HashedPayload A string created using the payload in
* @param param0.HashedPayload A string created using the payload in
* the body of the HTTP request as input to a hash function. This string uses
* lowercase hexadecimal characters. If the payload is empty, use an empty
* string as the input to the hash function.
* @returns {string}
*/
function createCanonicalRequest ({
function createCanonicalRequest({
method = 'PUT',
CanonicalUri = '/',
CanonicalQueryString = '',
SignedHeaders,
HashedPayload,
}) {
const headerKeys = Object.keys(SignedHeaders).map(k => k.toLowerCase()).sort()
}: {
method?: string
CanonicalUri: string
CanonicalQueryString: string
SignedHeaders: Record<string, string>
HashedPayload: string
}): string {
const headerKeys = Object.keys(SignedHeaders)
.map((k) => k.toLowerCase())
.sort()
return [
method,
CanonicalUri,
CanonicalQueryString,
...headerKeys.map(k => `${k}:${SignedHeaders[k]}`),
...headerKeys.map((k) => `${k}:${SignedHeaders[k]}`),
'',
headerKeys.join(';'),
HashedPayload,
@ -52,17 +59,23 @@ function createCanonicalRequest ({
const ec = new TextEncoder()
const algorithm = { name: 'HMAC', hash: 'SHA-256' }
async function digest (data) {
async function digest(data: string): ReturnType<SubtleCrypto['digest']> {
const { subtle } = globalThis.crypto
return subtle.digest(algorithm.hash, ec.encode(data))
}
async function generateHmacKey (secret) {
async function generateHmacKey(secret: string | Uint8Array | ArrayBuffer) {
const { subtle } = globalThis.crypto
return subtle.importKey('raw', typeof secret === 'string' ? ec.encode(secret) : secret, algorithm, false, ['sign'])
return subtle.importKey(
'raw',
typeof secret === 'string' ? ec.encode(secret) : secret,
algorithm,
false,
['sign'],
)
}
function arrayBufferToHexString (arrayBuffer) {
function arrayBufferToHexString(arrayBuffer: ArrayBuffer) {
const byteArray = new Uint8Array(arrayBuffer)
let hexString = ''
for (let i = 0; i < byteArray.length; i++) {
@ -71,27 +84,35 @@ function arrayBufferToHexString (arrayBuffer) {
return hexString
}
async function hash (key, data) {
async function hash(key: Parameters<typeof generateHmacKey>[0], data: string) {
const { subtle } = globalThis.crypto
return subtle.sign(algorithm, await generateHmacKey(key), ec.encode(data))
}
function percentEncode(c) {
return `%${c.charCodeAt(0).toString(16).toUpperCase()}`
}
/**
* @see https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html
* @param {Record<string,string>} param0
* @returns {Promise<URL>} the signed URL
*/
export default async function createSignedURL ({
accountKey, accountSecret, sessionToken,
export default async function createSignedURL({
accountKey,
accountSecret,
sessionToken,
bucketName,
Key, Region,
Key,
Region,
expires,
uploadId, partNumber,
}) {
uploadId,
partNumber,
}: {
accountKey: string
accountSecret: string
sessionToken: string
bucketName: string
Key: string
Region: string
expires: string | number
uploadId?: string
partNumber?: string | number
}): Promise<URL> {
const Service = 's3'
const host = `${bucketName}.${Service}.${Region}.amazonaws.com`
/**
@ -100,7 +121,7 @@ export default async function createSignedURL ({
*
* @see https://tc39.es/ecma262/#sec-encodeuri-uri
*/
const CanonicalUri = `/${encodeURI(Key).replace(/[;?:@&=+$,#!'()*]/g, percentEncode)}`
const CanonicalUri = `/${encodeURI(Key).replace(/[;?:@&=+$,#!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)}`
const payload = 'UNSIGNED-PAYLOAD'
const requestDateTime = new Date().toISOString().replace(/[-:]|\.\d+/g, '') // YYYYMMDDTHHMMSSZ
@ -113,14 +134,17 @@ export default async function createSignedURL ({
url.searchParams.set('X-Amz-Content-Sha256', payload)
url.searchParams.set('X-Amz-Credential', `${accountKey}/${scope}`)
url.searchParams.set('X-Amz-Date', requestDateTime)
url.searchParams.set('X-Amz-Expires', expires)
url.searchParams.set('X-Amz-Expires', expires as string)
// We are signing on the client, so we expect there's going to be a session token:
url.searchParams.set('X-Amz-Security-Token', sessionToken)
url.searchParams.set('X-Amz-SignedHeaders', 'host')
// Those two are present only for Multipart Uploads:
if (partNumber) url.searchParams.set('partNumber', partNumber)
if (partNumber) url.searchParams.set('partNumber', partNumber as string)
if (uploadId) url.searchParams.set('uploadId', uploadId)
url.searchParams.set('x-id', partNumber && uploadId ? 'UploadPart' : 'PutObject')
url.searchParams.set(
'x-id',
partNumber && uploadId ? 'UploadPart' : 'PutObject',
)
// Step 1: Create a canonical request
const canonical = createCanonicalRequest({

View file

@ -1,934 +0,0 @@
import BasePlugin from '@uppy/core/lib/BasePlugin.js'
import { RequestClient } from '@uppy/companion-client'
import EventManager from '@uppy/utils/lib/EventManager'
import { RateLimitedQueue } from '@uppy/utils/lib/RateLimitedQueue'
import { filterNonFailedFiles, filterFilesToEmitUploadStarted } from '@uppy/utils/lib/fileFilters'
import { createAbortError } from '@uppy/utils/lib/AbortController'
import MultipartUploader, { pausingUploadReason } from './MultipartUploader.js'
import createSignedURL from './createSignedURL.js'
import packageJson from '../package.json'
function assertServerError (res) {
if (res && res.error) {
const error = new Error(res.message)
Object.assign(error, res.error)
throw error
}
return res
}
function removeMetadataFromURL (urlString) {
const urlObject = new URL(urlString)
urlObject.search = ''
urlObject.hash = ''
return urlObject.href
}
/**
* 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.
*
* @param {import('../types/index.js').AwsS3STSResponse['credentials']} credentials
* @returns {number | undefined}
*/
function getExpiry (credentials) {
const expirationDate = credentials.Expiration
if (expirationDate) {
const timeUntilExpiry = Math.floor((new Date(expirationDate) - Date.now()) / 1000)
if (timeUntilExpiry > 9) {
return timeUntilExpiry
}
}
return undefined
}
function getAllowedMetadata ({ meta, allowedMetaFields, querify = false }) {
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]
}),
)
}
function throwIfAborted (signal) {
if (signal?.aborted) { throw createAbortError('The operation was aborted', { cause: signal.reason }) }
}
class HTTPCommunicationQueue {
#abortMultipartUpload
#cache = new WeakMap()
#createMultipartUpload
#fetchSignature
#getUploadParameters
#listParts
#previousRetryDelay
#requests
#retryDelays
#sendCompletionRequest
#setS3MultipartState
#uploadPartBytes
#getFile
constructor (requests, options, setS3MultipartState, getFile) {
this.#requests = requests
this.#setS3MultipartState = setS3MultipartState
this.#getFile = getFile
this.setOptions(options)
}
setOptions (options) {
const requests = this.#requests
if ('abortMultipartUpload' in options) {
this.#abortMultipartUpload = requests.wrapPromiseFunction(options.abortMultipartUpload, { priority:1 })
}
if ('createMultipartUpload' in options) {
this.#createMultipartUpload = requests.wrapPromiseFunction(options.createMultipartUpload, { priority:-1 })
}
if ('signPart' in options) {
this.#fetchSignature = requests.wrapPromiseFunction(options.signPart)
}
if ('listParts' in options) {
this.#listParts = requests.wrapPromiseFunction(options.listParts)
}
if ('completeMultipartUpload' in options) {
this.#sendCompletionRequest = requests.wrapPromiseFunction(options.completeMultipartUpload, { priority:1 })
}
if ('retryDelays' in options) {
this.#retryDelays = options.retryDelays ?? []
}
if ('uploadPartBytes' in options) {
this.#uploadPartBytes = requests.wrapPromiseFunction(options.uploadPartBytes, { priority:Infinity })
}
if ('getUploadParameters' in options) {
this.#getUploadParameters = requests.wrapPromiseFunction(options.getUploadParameters)
}
}
async #shouldRetry (err, retryDelayIterator) {
const requests = this.#requests
const status = err?.source?.status
// TODO: this retry logic is taken out of Tus. We should have a centralized place for retrying,
// perhaps the rate limited queue, and dedupe all plugins with that.
if (status == null) {
return false
}
if (status === 403 && err.message === 'Request has expired') {
if (!requests.isPaused) {
// We don't want to exhaust the retryDelayIterator as long as there are
// more than one request in parallel, to give slower connection a chance
// to catch up with the expiry set in Companion.
if (requests.limit === 1 || this.#previousRetryDelay == null) {
const next = retryDelayIterator.next()
if (next == null || next.done) {
return false
}
// If there are more than 1 request done in parallel, the RLQ limit is
// decreased and the failed request is requeued after waiting for a bit.
// If there is only one request in parallel, the limit can't be
// decreased, so we iterate over `retryDelayIterator` as we do for
// other failures.
// `#previousRetryDelay` caches the value so we can re-use it next time.
this.#previousRetryDelay = next.value
}
// No need to stop the other requests, we just want to lower the limit.
requests.rateLimit(0)
await new Promise(resolve => setTimeout(resolve, this.#previousRetryDelay))
}
} else if (status === 429) {
// HTTP 429 Too Many Requests => to avoid the whole download to fail, pause all requests.
if (!requests.isPaused) {
const next = retryDelayIterator.next()
if (next == null || next.done) {
return false
}
requests.rateLimit(next.value)
}
} else if (status > 400 && status < 500 && status !== 409) {
// HTTP 4xx, the server won't send anything, it's doesn't make sense to retry
return false
} else if (typeof navigator !== 'undefined' && navigator.onLine === false) {
// The navigator is offline, let's wait for it to come back online.
if (!requests.isPaused) {
requests.pause()
window.addEventListener('online', () => {
requests.resume()
}, { once: true })
}
} else {
// Other error code means the request can be retried later.
const next = retryDelayIterator.next()
if (next == null || next.done) {
return false
}
await new Promise(resolve => setTimeout(resolve, next.value))
}
return true
}
async getUploadId (file, signal) {
let cachedResult
// As the cache is updated asynchronously, there could be a race condition
// where we just miss a new result so we loop here until we get nothing back,
// at which point it's out turn to create a new cache entry.
while ((cachedResult = this.#cache.get(file.data)) != null) {
try {
return await cachedResult
} catch {
// In case of failure, we want to ignore the cached error.
// At this point, either there's a new cached value, or we'll exit the loop a create a new one.
}
}
const promise = this.#createMultipartUpload(this.#getFile(file), signal)
const abortPromise = () => {
promise.abort(signal.reason)
this.#cache.delete(file.data)
}
signal.addEventListener('abort', abortPromise, { once: true })
this.#cache.set(file.data, promise)
promise.then(async (result) => {
signal.removeEventListener('abort', abortPromise)
this.#setS3MultipartState(file, result)
this.#cache.set(file.data, result)
}, () => {
signal.removeEventListener('abort', abortPromise)
this.#cache.delete(file.data)
})
return promise
}
async abortFileUpload (file) {
const result = this.#cache.get(file.data)
if (result == null) {
// If the createMultipartUpload request never was made, we don't
// need to send the abortMultipartUpload request.
return
}
// Remove the cache entry right away for follow-up requests do not try to
// use the soon-to-be aborted chached values.
this.#cache.delete(file.data)
this.#setS3MultipartState(file, Object.create(null))
let awaitedResult
try {
awaitedResult = await result
} catch {
// If the cached result rejects, there's nothing to abort.
return
}
await this.#abortMultipartUpload(this.#getFile(file), awaitedResult)
}
async #nonMultipartUpload (file, chunk, signal) {
const {
method = 'POST',
url,
fields,
headers,
} = await this.#getUploadParameters(this.#getFile(file), { signal }).abortOn(signal)
let body
const data = chunk.getData()
if (method.toUpperCase() === 'POST') {
const formData = new FormData()
Object.entries(fields).forEach(([key, value]) => formData.set(key, value))
formData.set('file', data)
body = formData
} else {
body = data
}
const { onProgress, onComplete } = chunk
const result = await this.#uploadPartBytes({
signature: { url, headers, method },
body,
size: data.size,
onProgress,
onComplete,
signal,
}).abortOn(signal)
return 'location' in result ? result : {
location: removeMetadataFromURL(url),
...result,
}
}
/**
* @param {import("@uppy/core").UppyFile} file
* @param {import("../types/chunk").Chunk[]} chunks
* @param {AbortSignal} signal
* @returns {Promise<void>}
*/
async uploadFile (file, chunks, signal) {
throwIfAborted(signal)
if (chunks.length === 1 && !chunks[0].shouldUseMultipart) {
return this.#nonMultipartUpload(file, chunks[0], signal)
}
const { uploadId, key } = await this.getUploadId(file, signal)
throwIfAborted(signal)
try {
const parts = await Promise.all(chunks.map((chunk, i) => this.uploadChunk(file, i + 1, chunk, signal)))
throwIfAborted(signal)
return await this.#sendCompletionRequest(
this.#getFile(file),
{ key, uploadId, parts, signal },
signal,
).abortOn(signal)
} catch (err) {
if (err?.cause !== pausingUploadReason && err?.name !== 'AbortError') {
// We purposefully don't wait for the promise and ignore its status,
// because we want the error `err` to bubble up ASAP to report it to the
// user. A failure to abort is not that big of a deal anyway.
this.abortFileUpload(file)
}
throw err
}
}
restoreUploadFile (file, uploadIdAndKey) {
this.#cache.set(file.data, uploadIdAndKey)
}
async resumeUploadFile (file, chunks, signal) {
throwIfAborted(signal)
if (chunks.length === 1 && chunks[0] != null && !chunks[0].shouldUseMultipart) {
return this.#nonMultipartUpload(file, chunks[0], signal)
}
const { uploadId, key } = await this.getUploadId(file, signal)
throwIfAborted(signal)
const alreadyUploadedParts = await this.#listParts(
this.#getFile(file),
{ uploadId, key, signal },
signal,
).abortOn(signal)
throwIfAborted(signal)
const parts = await Promise.all(
chunks
.map((chunk, i) => {
const partNumber = i + 1
const alreadyUploadedInfo = alreadyUploadedParts.find(({ PartNumber }) => PartNumber === partNumber)
if (alreadyUploadedInfo == null) {
return this.uploadChunk(file, partNumber, chunk, signal)
}
// Already uploaded chunks are set to null. If we are restoring the upload, we need to mark it as already uploaded.
chunk?.setAsUploaded?.()
return { PartNumber: partNumber, ETag: alreadyUploadedInfo.ETag }
}),
)
throwIfAborted(signal)
return this.#sendCompletionRequest(
this.#getFile(file),
{ key, uploadId, parts, signal },
signal,
).abortOn(signal)
}
/**
*
* @param {import("@uppy/core").UppyFile} file
* @param {number} partNumber
* @param {import("../types/chunk").Chunk} chunk
* @param {AbortSignal} signal
* @returns {Promise<object>}
*/
async uploadChunk (file, partNumber, chunk, signal) {
throwIfAborted(signal)
const { uploadId, key } = await this.getUploadId(file, signal)
const signatureRetryIterator = this.#retryDelays.values()
const chunkRetryIterator = this.#retryDelays.values()
const shouldRetrySignature = () => {
const next = signatureRetryIterator.next()
if (next == null || next.done) {
return null
}
return next.value
}
for (;;) {
throwIfAborted(signal)
const chunkData = chunk.getData()
const { onProgress, onComplete } = chunk
let signature
try {
signature = await this.#fetchSignature(this.#getFile(file), {
uploadId, key, partNumber, body: chunkData, signal,
}).abortOn(signal)
} catch (err) {
const timeout = shouldRetrySignature()
if (timeout == null || signal.aborted) {
throw err
}
await new Promise(resolve => setTimeout(resolve, timeout))
// eslint-disable-next-line no-continue
continue
}
throwIfAborted(signal)
try {
return {
PartNumber: partNumber,
...await this.#uploadPartBytes({
signature, body: chunkData, size: chunkData.size, onProgress, onComplete, signal,
}).abortOn(signal),
}
} catch (err) {
if (!await this.#shouldRetry(err, chunkRetryIterator)) throw err
}
}
}
}
export default class AwsS3Multipart extends BasePlugin {
static VERSION = packageJson.version
#companionCommunicationQueue
#client
constructor (uppy, opts) {
super(uppy, opts)
this.type = 'uploader'
this.id = this.opts.id || 'AwsS3Multipart'
this.title = 'AWS S3 Multipart'
this.#client = new RequestClient(uppy, opts)
const defaultOptions = {
// TODO: null here means “include all”, [] means include none.
// This is inconsistent with @uppy/aws-s3 and @uppy/transloadit
allowedMetaFields: null,
limit: 6,
shouldUseMultipart: (file) => file.size !== 0, // TODO: Switch default to:
// eslint-disable-next-line no-bitwise
// shouldUseMultipart: (file) => file.size >> 10 >> 10 > 100,
retryDelays: [0, 1000, 3000, 5000],
createMultipartUpload: this.createMultipartUpload.bind(this),
listParts: this.listParts.bind(this),
abortMultipartUpload: this.abortMultipartUpload.bind(this),
completeMultipartUpload: this.completeMultipartUpload.bind(this),
getTemporarySecurityCredentials: false,
signPart: opts?.getTemporarySecurityCredentials ? this.createSignedURL.bind(this) : this.signPart.bind(this),
uploadPartBytes: AwsS3Multipart.uploadPartBytes,
getUploadParameters: opts?.getTemporarySecurityCredentials
? this.createSignedURL.bind(this)
: this.getUploadParameters.bind(this),
companionHeaders: {},
}
this.opts = { ...defaultOptions, ...opts }
if (opts?.prepareUploadParts != null && opts.signPart == null) {
this.opts.signPart = async (file, { uploadId, key, partNumber, body, signal }) => {
const { presignedUrls, headers } = await opts
.prepareUploadParts(file, { uploadId, key, parts: [{ number: partNumber, chunk: body }], signal })
return { url: presignedUrls?.[partNumber], headers: headers?.[partNumber] }
}
}
/**
* Simultaneous upload limiting is shared across all uploads with this plugin.
*
* @type {RateLimitedQueue}
*/
this.requests = this.opts.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)
this.uploaderSockets = Object.create(null)
}
[Symbol.for('uppy test: getClient')] () { return this.#client }
setOptions (newOptions) {
this.#companionCommunicationQueue.setOptions(newOptions)
super.setOptions(newOptions)
this.#setCompanionHeaders()
}
/**
* 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, opts = {}) {
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
}
if (this.uploaderSockets[fileID]) {
this.uploaderSockets[fileID].close()
this.uploaderSockets[fileID] = null
}
}
// TODO: make this a private method in the next major
assertHost (method) {
if (!this.opts.companionUrl) {
throw new Error(`Expected a \`companionUrl\` option containing a Companion address, or if you are not using Companion, a custom \`${method}\` implementation.`)
}
}
createMultipartUpload (file, signal) {
this.assertHost('createMultipartUpload')
throwIfAborted(signal)
const metadata = getAllowedMetadata({ meta: file.meta, allowedMetaFields: this.opts.allowedMetaFields })
return this.#client.post('s3/multipart', {
filename: file.name,
type: file.type,
metadata,
}, { signal }).then(assertServerError)
}
listParts (file, { key, uploadId }, signal) {
this.assertHost('listParts')
throwIfAborted(signal)
const filename = encodeURIComponent(key)
return this.#client.get(`s3/multipart/${uploadId}?key=${filename}`, { signal })
.then(assertServerError)
}
completeMultipartUpload (file, { key, uploadId, parts }, signal) {
this.assertHost('completeMultipartUpload')
throwIfAborted(signal)
const filename = encodeURIComponent(key)
const uploadIdEnc = encodeURIComponent(uploadId)
return this.#client.post(`s3/multipart/${uploadIdEnc}/complete?key=${filename}`, { parts }, { signal })
.then(assertServerError)
}
/**
* @type {import("../types").AwsS3STSResponse | Promise<import("../types").AwsS3STSResponse>}
*/
#cachedTemporaryCredentials
async #getTemporarySecurityCredentials (options) {
throwIfAborted(options?.signal)
if (this.#cachedTemporaryCredentials == null) {
// We do not await it just yet, so concurrent calls do not try to override it:
if (this.opts.getTemporarySecurityCredentials === true) {
this.assertHost('getTemporarySecurityCredentials')
this.#cachedTemporaryCredentials = this.#client.get('s3/sts', null, options).then(assertServerError)
} else {
this.#cachedTemporaryCredentials = this.opts.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
}, (getExpiry(this.#cachedTemporaryCredentials.credentials) || 0) * 500)
}
return this.#cachedTemporaryCredentials
}
async createSignedURL (file, options) {
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, signal } = 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,
signal,
})}`,
// Provide content type header required by S3
headers: {
'Content-Type': file.type,
},
}
}
signPart (file, { uploadId, key, partNumber, signal }) {
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(`s3/multipart/${uploadId}/${partNumber}?key=${filename}`, { signal })
.then(assertServerError)
}
abortMultipartUpload (file, { key, uploadId }, signal) {
this.assertHost('abortMultipartUpload')
const filename = encodeURIComponent(key)
const uploadIdEnc = encodeURIComponent(uploadId)
return this.#client.delete(`s3/multipart/${uploadIdEnc}?key=${filename}`, undefined, { signal })
.then(assertServerError)
}
getUploadParameters (file, options) {
const { meta } = file
const { type, name: filename } = meta
const metadata = getAllowedMetadata({ meta, allowedMetaFields: this.opts.allowedMetaFields, querify: true })
const query = new URLSearchParams({ filename, type, ...metadata })
return this.#client.get(`s3/params?${query}`, options)
}
static async uploadPartBytes ({ signature: { url, expires, headers, method = 'PUT' }, body, size = body.size, onProgress, onComplete, signal }) {
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.source = { status: 403 }
reject(error)
})
xhr.addEventListener('load', (ev) => {
cleanup()
if (ev.target.status === 403 && ev.target.responseText.includes('<Message>Request has expired</Message>')) {
const error = new Error('Request has expired')
error.source = ev.target
reject(error)
return
} if (ev.target.status < 200 || ev.target.status >= 300) {
const error = new Error('Non 2xx')
error.source = ev.target
reject(error)
return
}
// todo make a proper onProgress API (breaking change)
onProgress?.({ loaded: size, lengthComputable: true })
// NOTE This must be allowed by CORS.
const etag = ev.target.getResponseHeader('ETag')
const location = ev.target.getResponseHeader('Location')
if (method.toUpperCase() === 'POST' && location === null) {
// Not being able to read the Location header is not a fatal error.
// eslint-disable-next-line no-console
console.warn('AwsS3/Multipart: 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-multipart#S3-Bucket-Configuration for instructions.')
}
if (etag === null) {
reject(new Error('AwsS3/Multipart: 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-multipart#S3-Bucket-Configuration for instructions.'))
return
}
onComplete?.(etag)
resolve({
ETag: etag,
...(location ? { location } : undefined),
})
})
xhr.addEventListener('error', (ev) => {
cleanup()
const error = new Error('Unknown error')
error.source = ev.target
reject(error)
})
xhr.send(body)
})
}
#setS3MultipartState = (file, { key, uploadId }) => {
const cFile = this.uppy.getFile(file.id)
if (cFile == null) {
// file was removed from store
return
}
this.uppy.setFileState(file.id, {
s3Multipart: {
...cFile.s3Multipart,
key,
uploadId,
},
})
}
#getFile = (file) => {
return this.uppy.getFile(file.id) || file
}
#uploadLocalFile (file) {
return new Promise((resolve, reject) => {
const onProgress = (bytesUploaded, bytesTotal) => {
this.uppy.emit('upload-progress', this.uppy.getFile(file.id), {
uploader: this,
bytesUploaded,
bytesTotal,
})
}
const onError = (err) => {
this.uppy.log(err)
this.uppy.emit('upload-error', file, err)
this.resetUploaderReferences(file.id)
reject(err)
}
const onSuccess = (result) => {
const uploadResp = {
body: {
...result,
},
uploadURL: result.location,
}
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()
}
const onPartComplete = (part) => {
this.uppy.emit('s3-multipart:part-uploaded', this.#getFile(file), part)
}
const upload = new MultipartUploader(file.data, {
// .bind to pass the file object to each handler.
companionComm: this.#companionCommunicationQueue,
log: (...args) => this.uppy.log(...args),
getChunkSize: this.opts.getChunkSize ? this.opts.getChunkSize.bind(this) : null,
onProgress,
onError,
onSuccess,
onPartComplete,
file,
shouldUseMultipart: this.opts.shouldUseMultipart,
...file.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.id} was removed`)
})
eventManager.onCancelAll(file.id, ({ reason } = {}) => {
if (reason === 'user') {
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()
})
}
// eslint-disable-next-line class-methods-use-this
#getCompanionClientArgs (file) {
return {
...file.remote.body,
protocol: 's3-multipart',
size: file.data.size,
metadata: file.meta,
}
}
#upload = async (fileIDs) => {
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) => {
if (removedFile.id === file.id) controller.abort()
}
this.uppy.on('file-removed', removedHandler)
const uploadPromise = this.uppy.getRequestClientForFile(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.all(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.companionHeaders)
}
#setResumableUploadsCapability = (boolean) => {
const { capabilities } = this.uppy.getState()
this.uppy.setState({
capabilities: {
...capabilities,
resumableUploads: boolean,
},
})
}
#resetResumableCapability = () => {
this.#setResumableUploadsCapability(true)
}
install () {
this.#setResumableUploadsCapability(true)
this.uppy.addPreProcessor(this.#setCompanionHeaders)
this.uppy.addUploader(this.#upload)
this.uppy.on('cancel-all', this.#resetResumableCapability)
}
uninstall () {
this.uppy.removePreProcessor(this.#setCompanionHeaders)
this.uppy.removeUploader(this.#upload)
this.uppy.off('cancel-all', this.#resetResumableCapability)
}
}

View file

@ -3,7 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import 'whatwg-fetch'
import nock from 'nock'
import Core from '@uppy/core'
import AwsS3Multipart from './index.js'
import AwsS3Multipart from './index.ts'
import type { Body } from './utils.ts'
const KB = 1024
const MB = KB * KB
@ -12,36 +13,41 @@ describe('AwsS3Multipart', () => {
beforeEach(() => nock.disableNetConnect())
it('Registers AwsS3Multipart upload plugin', () => {
const core = new Core()
const core = new Core<any, Body>()
core.use(AwsS3Multipart)
const pluginNames = core[Symbol.for('uppy test: getPlugins')]('uploader').map((plugin) => plugin.constructor.name)
// @ts-expect-error private property
const pluginNames = core[Symbol.for('uppy test: getPlugins')](
'uploader',
).map((plugin: AwsS3Multipart<any, Body>) => plugin.constructor.name)
expect(pluginNames).toContain('AwsS3Multipart')
})
describe('companionUrl assertion', () => {
it('Throws an error for main functions if configured without companionUrl', () => {
const core = new Core()
const core = new Core<any, Body>()
core.use(AwsS3Multipart)
const awsS3Multipart = core.getPlugin('AwsS3Multipart')
const awsS3Multipart = core.getPlugin('AwsS3Multipart')!
const err = 'Expected a `companionUrl` option'
const file = {}
const opts = {}
expect(() => awsS3Multipart.opts.createMultipartUpload(file)).toThrow(
err,
)
expect(() => awsS3Multipart.opts.createMultipartUpload(file)).toThrow(err)
expect(() => awsS3Multipart.opts.listParts(file, opts)).toThrow(err)
expect(() => awsS3Multipart.opts.completeMultipartUpload(file, opts)).toThrow(err)
expect(() => awsS3Multipart.opts.abortMultipartUpload(file, opts)).toThrow(err)
expect(() =>
awsS3Multipart.opts.completeMultipartUpload(file, opts),
).toThrow(err)
expect(() =>
awsS3Multipart.opts.abortMultipartUpload(file, opts),
).toThrow(err)
expect(() => awsS3Multipart.opts.signPart(file, opts)).toThrow(err)
})
})
describe('non-multipart upload', () => {
it('should handle POST uploads', async () => {
const core = new Core()
const core = new Core<any, Body>()
core.use(AwsS3Multipart, {
shouldUseMultipart: false,
limit: 0,
@ -71,7 +77,7 @@ describe('AwsS3Multipart', () => {
source: 'vi',
name: 'multitest.dat',
type: 'application/octet-stream',
data: new File([new Uint8Array(fileSize)], {
data: new File([new Uint8Array(fileSize)], '', {
type: 'application/octet-stream',
}),
})
@ -87,6 +93,7 @@ describe('AwsS3Multipart', () => {
ETag: 'test',
location: 'http://example.com',
},
status: 200,
uploadURL: 'http://example.com',
})
@ -95,11 +102,11 @@ describe('AwsS3Multipart', () => {
})
describe('without companionUrl (custom main functions)', () => {
let core
let awsS3Multipart
let core: Core<any, Body>
let awsS3Multipart: AwsS3Multipart<any, Body>
beforeEach(() => {
core = new Core()
core = new Core<any, Body>()
core.use(AwsS3Multipart, {
limit: 0,
createMultipartUpload: vi.fn(() => {
@ -110,17 +117,19 @@ describe('AwsS3Multipart', () => {
}),
completeMultipartUpload: vi.fn(async () => ({ location: 'test' })),
abortMultipartUpload: vi.fn(),
prepareUploadParts: vi.fn(async (file, { parts }) => {
const presignedUrls = {}
parts.forEach(({ number }) => {
presignedUrls[
number
] = `https://bucket.s3.us-east-2.amazonaws.com/test/upload/multitest.dat?partNumber=${number}&uploadId=6aeb1980f3fc7ce0b5454d25b71992&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIATEST%2F20210729%2Fus-east-2%2Fs3%2Faws4_request&X-Amz-Date=20210729T014044Z&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=test`
})
return { presignedUrls, headers: { 1: { 'Content-MD5': 'foo' } } }
}),
prepareUploadParts: vi.fn(
async (file, { parts }: { parts: { number: number }[] }) => {
const presignedUrls: Record<number, string> = {}
parts.forEach(({ number }) => {
presignedUrls[number] =
`https://bucket.s3.us-east-2.amazonaws.com/test/upload/multitest.dat?partNumber=${number}&uploadId=6aeb1980f3fc7ce0b5454d25b71992&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIATEST%2F20210729%2Fus-east-2%2Fs3%2Faws4_request&X-Amz-Date=20210729T014044Z&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=test`
})
return { presignedUrls, headers: { 1: { 'Content-MD5': 'foo' } } }
},
),
listParts: undefined as any,
})
awsS3Multipart = core.getPlugin('AwsS3Multipart')
awsS3Multipart = core.getPlugin('AwsS3Multipart') as any
})
it('Calls the prepareUploadParts function totalChunks / limit times', async () => {
@ -137,15 +146,23 @@ describe('AwsS3Multipart', () => {
const fileSize = 5 * MB + 1 * MB
scope
.options((uri) => uri.includes('test/upload/multitest.dat?partNumber=1'))
.reply(function replyFn () {
expect(this.req.headers['access-control-request-headers']).toEqual('Content-MD5')
.options((uri) =>
uri.includes('test/upload/multitest.dat?partNumber=1'),
)
.reply(function replyFn() {
expect(this.req.headers['access-control-request-headers']).toEqual(
'Content-MD5',
)
return [200, '']
})
scope
.options((uri) => uri.includes('test/upload/multitest.dat?partNumber=2'))
.reply(function replyFn () {
expect(this.req.headers['access-control-request-headers']).toBeUndefined()
.options((uri) =>
uri.includes('test/upload/multitest.dat?partNumber=2'),
)
.reply(function replyFn() {
expect(
this.req.headers['access-control-request-headers'],
).toBeUndefined()
return [200, '']
})
scope
@ -159,7 +176,7 @@ describe('AwsS3Multipart', () => {
source: 'vi',
name: 'multitest.dat',
type: 'application/octet-stream',
data: new File([new Uint8Array(fileSize)], {
data: new File([new Uint8Array(fileSize)], '', {
type: 'application/octet-stream',
}),
})
@ -167,7 +184,7 @@ describe('AwsS3Multipart', () => {
await core.upload()
expect(
awsS3Multipart.opts.prepareUploadParts.mock.calls.length,
(awsS3Multipart.opts as any).prepareUploadParts.mock.calls.length,
).toEqual(2)
scope.done()
@ -201,14 +218,17 @@ describe('AwsS3Multipart', () => {
source: 'vi',
name: 'multitest.dat',
type: 'application/octet-stream',
data: new File([new Uint8Array(fileSize)], {
data: new File([new Uint8Array(fileSize)], '', {
type: 'application/octet-stream',
}),
})
await core.upload()
function validatePartData ({ parts }, expected) {
function validatePartData(
{ parts }: { parts: { number: number; chunk: unknown }[] },
expected: number[],
) {
expect(parts.map((part) => part.number)).toEqual(expected)
for (const part of parts) {
@ -216,13 +236,25 @@ describe('AwsS3Multipart', () => {
}
}
expect(awsS3Multipart.opts.prepareUploadParts.mock.calls.length).toEqual(10)
expect(
(awsS3Multipart.opts as any).prepareUploadParts.mock.calls.length,
).toEqual(10)
validatePartData(awsS3Multipart.opts.prepareUploadParts.mock.calls[0][1], [1])
validatePartData(awsS3Multipart.opts.prepareUploadParts.mock.calls[1][1], [2])
validatePartData(awsS3Multipart.opts.prepareUploadParts.mock.calls[2][1], [3])
validatePartData(
(awsS3Multipart.opts as any).prepareUploadParts.mock.calls[0][1],
[1],
)
validatePartData(
(awsS3Multipart.opts as any).prepareUploadParts.mock.calls[1][1],
[2],
)
validatePartData(
(awsS3Multipart.opts as any).prepareUploadParts.mock.calls[2][1],
[3],
)
const completeCall = awsS3Multipart.opts.completeMultipartUpload.mock.calls[0][1]
const completeCall = (awsS3Multipart.opts as any).completeMultipartUpload
.mock.calls[0][1]
expect(completeCall.parts).toEqual([
{ ETag: 'test', PartNumber: 1 },
@ -254,13 +286,21 @@ describe('AwsS3Multipart', () => {
.options((uri) => uri.includes('test/upload/multitest.dat'))
.reply(200, '')
scope
.put((uri) => uri.includes('test/upload/multitest.dat') && !uri.includes('partNumber=7'))
.put(
(uri) =>
uri.includes('test/upload/multitest.dat') &&
!uri.includes('partNumber=7'),
)
.reply(200, '', { ETag: 'test' })
// Fail the part 7 upload once, then let it succeed
let calls = 0
scope
.put((uri) => uri.includes('test/upload/multitest.dat') && uri.includes('partNumber=7'))
.put(
(uri) =>
uri.includes('test/upload/multitest.dat') &&
uri.includes('partNumber=7'),
)
.reply(() => (calls++ === 0 ? [500] : [200, '', { ETag: 'test' }]))
scope.persist()
@ -271,14 +311,25 @@ describe('AwsS3Multipart', () => {
awsS3Multipart.setOptions({
retryDelays: [10],
createMultipartUpload: vi.fn((file) => {
const multipartUploader = awsS3Multipart.uploaders[file.id]
// @ts-expect-error protected property
const multipartUploader = awsS3Multipart.uploaders[file.id]!
const testChunkState = multipartUploader.chunkState[6]
let busy = false
let done = false
busySpy = vi.fn((value) => { busy = value })
doneSpy = vi.fn((value) => { done = value })
Object.defineProperty(testChunkState, 'busy', { get: () => busy, set: busySpy })
Object.defineProperty(testChunkState, 'done', { get: () => done, set: doneSpy })
busySpy = vi.fn((value) => {
busy = value
})
doneSpy = vi.fn((value) => {
done = value
})
Object.defineProperty(testChunkState, 'busy', {
get: () => busy,
set: busySpy,
})
Object.defineProperty(testChunkState, 'done', {
get: () => done,
set: doneSpy,
})
return {
uploadId: '6aeb1980f3fc7ce0b5454d25b71992',
@ -291,7 +342,7 @@ describe('AwsS3Multipart', () => {
source: 'vi',
name: 'multitest.dat',
type: 'application/octet-stream',
data: new File([new Uint8Array(fileSize)], {
data: new File([new Uint8Array(fileSize)], '', {
type: 'application/octet-stream',
}),
})
@ -299,19 +350,23 @@ describe('AwsS3Multipart', () => {
await core.upload()
// The chunk should be marked as done once
expect(doneSpy.mock.calls.length).toEqual(1)
expect(doneSpy.mock.calls[0][0]).toEqual(true)
expect(doneSpy!.mock.calls.length).toEqual(1)
expect(doneSpy!.mock.calls[0][0]).toEqual(true)
// Any changes that set busy to false should only happen after the chunk has been marked done,
// otherwise a race condition occurs (see PR #3955)
const doneCallOrderNumber = doneSpy.mock.invocationCallOrder[0]
for (const [index, callArgs] of busySpy.mock.calls.entries()) {
const doneCallOrderNumber = doneSpy!.mock.invocationCallOrder[0]
for (const [index, callArgs] of busySpy!.mock.calls.entries()) {
if (callArgs[0] === false) {
expect(busySpy.mock.invocationCallOrder[index]).toBeGreaterThan(doneCallOrderNumber)
expect(busySpy!.mock.invocationCallOrder[index]).toBeGreaterThan(
doneCallOrderNumber,
)
}
}
expect(awsS3Multipart.opts.prepareUploadParts.mock.calls.length).toEqual(10)
expect(
(awsS3Multipart.opts as any).prepareUploadParts.mock.calls.length,
).toEqual(10)
})
})
@ -323,36 +378,41 @@ describe('AwsS3Multipart', () => {
}
})
const signPart = vi
.fn(async (file, { partNumber }) => {
return { url: `https://bucket.s3.us-east-2.amazonaws.com/test/upload/multitest.dat?partNumber=${partNumber}&uploadId=6aeb1980f3fc7ce0b5454d25b71992&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIATEST%2F20210729%2Fus-east-2%2Fs3%2Faws4_request&X-Amz-Date=20210729T014044Z&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=test` }
})
const signPart = vi.fn(async (file, { partNumber }) => {
return {
url: `https://bucket.s3.us-east-2.amazonaws.com/test/upload/multitest.dat?partNumber=${partNumber}&uploadId=6aeb1980f3fc7ce0b5454d25b71992&X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIATEST%2F20210729%2Fus-east-2%2Fs3%2Faws4_request&X-Amz-Date=20210729T014044Z&X-Amz-Expires=600&X-Amz-SignedHeaders=host&X-Amz-Signature=test`,
}
})
const uploadPartBytes = vi.fn()
afterEach(() => vi.clearAllMocks())
afterEach(() => {
vi.clearAllMocks()
})
it('retries uploadPartBytes when it fails once', async () => {
const core = new Core()
.use(AwsS3Multipart, {
createMultipartUpload,
completeMultipartUpload: vi.fn(async () => ({ location: 'test' })),
const core = new Core<any, Body>().use(AwsS3Multipart, {
createMultipartUpload,
completeMultipartUpload: vi.fn(async () => ({ location: 'test' })),
abortMultipartUpload: vi.fn(() => {
// eslint-disable-next-line no-throw-literal
abortMultipartUpload: vi.fn(() => { throw 'should ignore' }),
signPart,
uploadPartBytes:
uploadPartBytes
// eslint-disable-next-line prefer-promise-reject-errors
.mockImplementationOnce(() => Promise.reject({ source: { status: 500 } })),
})
const awsS3Multipart = core.getPlugin('AwsS3Multipart')
throw 'should ignore'
}),
signPart,
uploadPartBytes: uploadPartBytes.mockImplementationOnce(() =>
// eslint-disable-next-line prefer-promise-reject-errors
Promise.reject({ source: { status: 500 } }),
),
listParts: undefined as any,
})
const awsS3Multipart = core.getPlugin('AwsS3Multipart')!
const fileSize = 5 * MB + 1 * MB
core.addFile({
source: 'vi',
name: 'multitest.dat',
type: 'application/octet-stream',
data: new File([new Uint8Array(fileSize)], {
data: new File([new Uint8Array(fileSize)], '', {
type: 'application/octet-stream',
}),
})
@ -363,18 +423,19 @@ describe('AwsS3Multipart', () => {
})
it('calls `upload-error` when uploadPartBytes fails after all retries', async () => {
const core = new Core()
.use(AwsS3Multipart, {
retryDelays: [10],
createMultipartUpload,
completeMultipartUpload: vi.fn(async () => ({ location: 'test' })),
abortMultipartUpload: vi.fn(),
signPart,
uploadPartBytes: uploadPartBytes
// eslint-disable-next-line prefer-promise-reject-errors
.mockImplementation(() => Promise.reject({ source: { status: 500 } })),
})
const awsS3Multipart = core.getPlugin('AwsS3Multipart')
const core = new Core<any, Body>().use(AwsS3Multipart, {
retryDelays: [10],
createMultipartUpload,
completeMultipartUpload: vi.fn(async () => ({ location: 'test' })),
abortMultipartUpload: vi.fn(),
signPart,
uploadPartBytes: uploadPartBytes.mockImplementation(() =>
// eslint-disable-next-line prefer-promise-reject-errors
Promise.reject({ source: { status: 500 } }),
),
listParts: undefined as any,
})
const awsS3Multipart = core.getPlugin('AwsS3Multipart')!
const fileSize = 5 * MB + 1 * MB
const mock = vi.fn()
core.on('upload-error', mock)
@ -383,7 +444,7 @@ describe('AwsS3Multipart', () => {
source: 'vi',
name: 'multitest.dat',
type: 'application/octet-stream',
data: new File([new Uint8Array(fileSize)], {
data: new File([new Uint8Array(fileSize)], '', {
type: 'application/octet-stream',
}),
})
@ -396,19 +457,20 @@ describe('AwsS3Multipart', () => {
})
describe('dynamic companionHeader', () => {
let core
let awsS3Multipart
let core: Core<any, any>
let awsS3Multipart: AwsS3Multipart<any, any>
const oldToken = 'old token'
const newToken = 'new token'
beforeEach(() => {
core = new Core()
core = new Core<any, Body>()
core.use(AwsS3Multipart, {
companionUrl: '',
companionHeaders: {
authorization: oldToken,
},
})
awsS3Multipart = core.getPlugin('AwsS3Multipart')
awsS3Multipart = core.getPlugin('AwsS3Multipart') as any
})
it('companionHeader is updated before uploading file', async () => {
@ -420,23 +482,29 @@ describe('AwsS3Multipart', () => {
await core.upload()
// @ts-expect-error private property
const client = awsS3Multipart[Symbol.for('uppy test: getClient')]()
expect(client[Symbol.for('uppy test: getCompanionHeaders')]().authorization).toEqual(newToken)
expect(
client[Symbol.for('uppy test: getCompanionHeaders')]().authorization,
).toEqual(newToken)
})
})
describe('dynamic companionHeader using setOption', () => {
let core
let awsS3Multipart
let core: Core<any, Body>
let awsS3Multipart: AwsS3Multipart<any, Body>
const newToken = 'new token'
it('companionHeader is updated before uploading file', async () => {
core = new Core()
core = new Core<any, Body>()
core.use(AwsS3Multipart)
/* Set up preprocessor */
core.addPreProcessor(() => {
awsS3Multipart = core.getPlugin('AwsS3Multipart')
awsS3Multipart = core.getPlugin('AwsS3Multipart') as AwsS3Multipart<
any,
Body
>
awsS3Multipart.setOptions({
companionHeaders: {
authorization: newToken,
@ -446,15 +514,18 @@ describe('AwsS3Multipart', () => {
await core.upload()
// @ts-expect-error private property
const client = awsS3Multipart[Symbol.for('uppy test: getClient')]()
expect(client[Symbol.for('uppy test: getCompanionHeaders')]().authorization).toEqual(newToken)
expect(
client[Symbol.for('uppy test: getCompanionHeaders')]().authorization,
).toEqual(newToken)
})
})
describe('file metadata across custom main functions', () => {
let core
const createMultipartUpload = vi.fn(file => {
let core: Core<any, Body>
const createMultipartUpload = vi.fn((file) => {
core.setFileMeta(file.id, {
...file.meta,
createMultipartUpload: true,
@ -487,8 +558,10 @@ describe('AwsS3Multipart', () => {
listParts: true,
})
const partKeys = Object.keys(file.meta).filter(metaKey => metaKey.startsWith('part'))
return partKeys.map(metaKey => ({
const partKeys = Object.keys(file.meta).filter((metaKey) =>
metaKey.startsWith('part'),
)
return partKeys.map((metaKey) => ({
PartNumber: file.meta[metaKey],
ETag: metaKey,
Size: 5 * MB,
@ -508,7 +581,6 @@ describe('AwsS3Multipart', () => {
expect(file.meta.createMultipartUpload).toBe(true)
expect(file.meta.signPart).toBe(true)
expect(file.meta.abortingPart).toBe(5)
return {}
})
beforeEach(() => {
@ -520,14 +592,13 @@ describe('AwsS3Multipart', () => {
})
it('preserves file metadata if upload is completed', async () => {
core = new Core()
.use(AwsS3Multipart, {
createMultipartUpload,
signPart,
listParts,
completeMultipartUpload,
abortMultipartUpload,
})
core = new Core<any, Body>().use(AwsS3Multipart, {
createMultipartUpload,
signPart,
listParts,
completeMultipartUpload,
abortMultipartUpload,
})
nock('https://bucket.s3.us-east-2.amazonaws.com')
.defaultReplyHeaders({
@ -545,7 +616,7 @@ describe('AwsS3Multipart', () => {
source: 'vi',
name: 'multitest.dat',
type: 'application/octet-stream',
data: new File([new Uint8Array(fileSize)], {
data: new File([new Uint8Array(fileSize)], '', {
type: 'application/octet-stream',
}),
})
@ -565,7 +636,9 @@ describe('AwsS3Multipart', () => {
abortingPart: partData.partNumber,
})
core.removeFile(file.id)
return {}
return {
url: undefined as any as string,
}
}
core.setFileMeta(file.id, {
@ -579,14 +652,13 @@ describe('AwsS3Multipart', () => {
}
})
core = new Core()
.use(AwsS3Multipart, {
createMultipartUpload,
signPart: signPartWithAbort,
listParts,
completeMultipartUpload,
abortMultipartUpload,
})
core = new Core<any, Body>().use(AwsS3Multipart, {
createMultipartUpload,
signPart: signPartWithAbort,
listParts,
completeMultipartUpload,
abortMultipartUpload,
})
nock('https://bucket.s3.us-east-2.amazonaws.com')
.defaultReplyHeaders({
@ -604,7 +676,7 @@ describe('AwsS3Multipart', () => {
source: 'vi',
name: 'multitest.dat',
type: 'application/octet-stream',
data: new File([new Uint8Array(fileSize)], {
data: new File([new Uint8Array(fileSize)], '', {
type: 'application/octet-stream',
}),
})
@ -649,14 +721,13 @@ describe('AwsS3Multipart', () => {
}
})
core = new Core()
.use(AwsS3Multipart, {
createMultipartUpload,
signPart: signPartWithPause,
listParts,
completeMultipartUpload: completeMultipartUploadAfterPause,
abortMultipartUpload,
})
core = new Core<any, Body>().use(AwsS3Multipart, {
createMultipartUpload,
signPart: signPartWithPause,
listParts,
completeMultipartUpload: completeMultipartUploadAfterPause,
abortMultipartUpload,
})
nock('https://bucket.s3.us-east-2.amazonaws.com')
.defaultReplyHeaders({
@ -674,7 +745,7 @@ describe('AwsS3Multipart', () => {
source: 'vi',
name: 'multitest.dat',
type: 'application/octet-stream',
data: new File([new Uint8Array(fileSize)], {
data: new File([new Uint8Array(fileSize)], '', {
type: 'application/octet-stream',
}),
})

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,28 @@
import { createAbortError } from '@uppy/utils/lib/AbortController'
import type { Body as _Body } from '@uppy/utils/lib/UppyFile'
import type { AwsS3Part } from './index'
export function throwIfAborted(signal?: AbortSignal | null): void {
if (signal?.aborted) {
throw createAbortError('The operation was aborted', {
cause: signal.reason,
})
}
}
export type UploadResult = { key: string; uploadId: string }
export type UploadResultWithSignal = UploadResult & { signal?: AbortSignal }
export type MultipartUploadResult = UploadResult & { parts: AwsS3Part[] }
export type MultipartUploadResultWithSignal = MultipartUploadResult & {
signal?: AbortSignal
}
export type UploadPartBytesResult = {
ETag: string
location?: string
}
export interface Body extends _Body {
location: string
}

View file

@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.shared",
"compilerOptions": {
"noImplicitAny": false,
"outDir": "./lib",
"paths": {
"@uppy/companion-client": ["../companion-client/src/index.js"],
"@uppy/companion-client/lib/*": ["../companion-client/src/*"],
"@uppy/utils/lib/*": ["../utils/src/*"],
"@uppy/core": ["../core/src/index.js"],
"@uppy/core/lib/*": ["../core/src/*"]
},
"resolveJsonModule": false,
"rootDir": "./src",
"skipLibCheck": true
},
"include": ["./src/**/*.*"],
"exclude": ["./src/**/*.test.ts"],
"references": [
{
"path": "../companion-client/tsconfig.build.json"
},
{
"path": "../utils/tsconfig.build.json"
},
{
"path": "../core/tsconfig.build.json"
}
]
}

View file

@ -0,0 +1,26 @@
{
"extends": "../../../tsconfig.shared",
"compilerOptions": {
"emitDeclarationOnly": false,
"noEmit": true,
"paths": {
"@uppy/companion-client": ["../companion-client/src/index.js"],
"@uppy/companion-client/lib/*": ["../companion-client/src/*"],
"@uppy/utils/lib/*": ["../utils/src/*"],
"@uppy/core": ["../core/src/index.js"],
"@uppy/core/lib/*": ["../core/src/*"],
},
},
"include": ["./package.json", "./src/**/*.*"],
"references": [
{
"path": "../companion-client/tsconfig.build.json",
},
{
"path": "../utils/tsconfig.build.json",
},
{
"path": "../core/tsconfig.build.json",
},
],
}

View file

@ -49,7 +49,10 @@ import locale from './locale.ts'
import type BasePlugin from './BasePlugin.ts'
import type { Restrictions, ValidateableFile } from './Restricter.ts'
type Processor = (fileIDs: string[], uploadID: string) => Promise<void> | void
type Processor = (
fileIDs: string[],
uploadID: string,
) => Promise<unknown> | void
type FileRemoveReason = 'user' | 'cancel-all'