|
@@ -1,7 +1,11 @@
|
|
|
+const { AbortController, createAbortError } = require('@uppy/utils/lib/AbortController')
|
|
|
+const delay = require('@uppy/utils/lib/delay')
|
|
|
+
|
|
|
const MB = 1024 * 1024
|
|
|
|
|
|
const defaultOptions = {
|
|
|
limit: 1,
|
|
|
+ retryDelays: [0, 1000, 3000, 5000],
|
|
|
getChunkSize (file) {
|
|
|
return Math.ceil(file.size / 10000)
|
|
|
},
|
|
@@ -14,11 +18,6 @@ const defaultOptions = {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-function remove (arr, el) {
|
|
|
- const i = arr.indexOf(el)
|
|
|
- if (i !== -1) arr.splice(i, 1)
|
|
|
-}
|
|
|
-
|
|
|
class MultipartUploader {
|
|
|
constructor (file, options) {
|
|
|
this.options = {
|
|
@@ -31,6 +30,7 @@ class MultipartUploader {
|
|
|
}
|
|
|
|
|
|
this.file = file
|
|
|
+ this.abortController = new AbortController()
|
|
|
|
|
|
this.key = this.options.key || null
|
|
|
this.uploadId = this.options.uploadId || null
|
|
@@ -45,15 +45,26 @@ class MultipartUploader {
|
|
|
// aborting it immediately after it finishes.
|
|
|
this.createdPromise = Promise.reject() // eslint-disable-line prefer-promise-reject-errors
|
|
|
this.isPaused = false
|
|
|
+ this.partsInProgress = 0
|
|
|
this.chunks = null
|
|
|
this.chunkState = null
|
|
|
- this.uploading = []
|
|
|
|
|
|
this._initChunks()
|
|
|
|
|
|
this.createdPromise.catch(() => {}) // silence uncaught rejection warning
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * Was this upload aborted?
|
|
|
+ *
|
|
|
+ * If yes, we may need to throw an AbortError.
|
|
|
+ *
|
|
|
+ * @returns {boolean}
|
|
|
+ */
|
|
|
+ _aborted () {
|
|
|
+ return this.abortController.signal.aborted
|
|
|
+ }
|
|
|
+
|
|
|
_initChunks () {
|
|
|
const chunks = []
|
|
|
const desiredChunkSize = this.options.getChunkSize(this.file)
|
|
@@ -79,6 +90,8 @@ class MultipartUploader {
|
|
|
this.options.createMultipartUpload()
|
|
|
)
|
|
|
return this.createdPromise.then((result) => {
|
|
|
+ if (this._aborted()) throw createAbortError()
|
|
|
+
|
|
|
const valid = typeof result === 'object' && result &&
|
|
|
typeof result.uploadId === 'string' &&
|
|
|
typeof result.key === 'string'
|
|
@@ -103,6 +116,8 @@ class MultipartUploader {
|
|
|
key: this.key
|
|
|
})
|
|
|
).then((parts) => {
|
|
|
+ if (this._aborted()) throw createAbortError()
|
|
|
+
|
|
|
parts.forEach((part) => {
|
|
|
const i = part.PartNumber - 1
|
|
|
this.chunkState[i] = {
|
|
@@ -128,7 +143,7 @@ class MultipartUploader {
|
|
|
_uploadParts () {
|
|
|
if (this.isPaused) return
|
|
|
|
|
|
- const need = this.options.limit - this.uploading.length
|
|
|
+ const need = this.options.limit - this.partsInProgress
|
|
|
if (need === 0) return
|
|
|
|
|
|
// All parts are uploaded.
|
|
@@ -149,7 +164,57 @@ class MultipartUploader {
|
|
|
}
|
|
|
|
|
|
candidates.forEach((index) => {
|
|
|
- this._uploadPart(index)
|
|
|
+ this._uploadPartRetryable(index).catch((err) => {
|
|
|
+ this._onError(err)
|
|
|
+ })
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ _retryable ({ before, attempt, after }) {
|
|
|
+ const { retryDelays } = this.options
|
|
|
+ const { signal } = this.abortController
|
|
|
+
|
|
|
+ if (before) before()
|
|
|
+
|
|
|
+ function shouldRetry (err) {
|
|
|
+ if (err.source && typeof err.source.status === 'number') {
|
|
|
+ const { status } = err.source
|
|
|
+ // 0 probably indicates network failure
|
|
|
+ return status === 0 || status === 409 || status === 423 || (status >= 500 && status < 600)
|
|
|
+ }
|
|
|
+ return false
|
|
|
+ }
|
|
|
+
|
|
|
+ const doAttempt = (retryAttempt) =>
|
|
|
+ attempt().catch((err) => {
|
|
|
+ if (this._aborted()) throw createAbortError()
|
|
|
+
|
|
|
+ if (shouldRetry(err) && retryAttempt < retryDelays.length) {
|
|
|
+ return delay(retryDelays[retryAttempt], { signal })
|
|
|
+ .then(() => doAttempt(retryAttempt + 1))
|
|
|
+ } else {
|
|
|
+ throw err
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ return doAttempt(0).then((result) => {
|
|
|
+ if (after) after()
|
|
|
+ return result
|
|
|
+ }, (err) => {
|
|
|
+ if (after) after()
|
|
|
+ throw err
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ _uploadPartRetryable (index) {
|
|
|
+ return this._retryable({
|
|
|
+ before: () => {
|
|
|
+ this.partsInProgress += 1
|
|
|
+ },
|
|
|
+ attempt: () => this._uploadPart(index),
|
|
|
+ after: () => {
|
|
|
+ this.partsInProgress -= 1
|
|
|
+ }
|
|
|
})
|
|
|
}
|
|
|
|
|
@@ -170,11 +235,15 @@ class MultipartUploader {
|
|
|
if (!valid) {
|
|
|
throw new TypeError('AwsS3/Multipart: Got incorrect result from `prepareUploadPart()`, expected an object `{ url }`.')
|
|
|
}
|
|
|
+
|
|
|
return result
|
|
|
}).then(({ url, headers }) => {
|
|
|
- this._uploadPartBytes(index, url, headers)
|
|
|
- }, (err) => {
|
|
|
- this._onError(err)
|
|
|
+ if (this._aborted()) {
|
|
|
+ this.chunkState[index].busy = false
|
|
|
+ throw createAbortError()
|
|
|
+ }
|
|
|
+
|
|
|
+ return this._uploadPartBytes(index, url, headers)
|
|
|
})
|
|
|
}
|
|
|
|
|
@@ -202,6 +271,13 @@ class MultipartUploader {
|
|
|
|
|
|
_uploadPartBytes (index, url, headers) {
|
|
|
const body = this.chunks[index]
|
|
|
+ const { signal } = this.abortController
|
|
|
+
|
|
|
+ let defer
|
|
|
+ const promise = new Promise((resolve, reject) => {
|
|
|
+ defer = { resolve, reject }
|
|
|
+ })
|
|
|
+
|
|
|
const xhr = new XMLHttpRequest()
|
|
|
xhr.open('PUT', url, true)
|
|
|
if (headers) {
|
|
@@ -211,7 +287,13 @@ class MultipartUploader {
|
|
|
}
|
|
|
xhr.responseType = 'text'
|
|
|
|
|
|
- this.uploading.push(xhr)
|
|
|
+ function cleanup () {
|
|
|
+ signal.removeEventListener('abort', onabort)
|
|
|
+ }
|
|
|
+ function onabort () {
|
|
|
+ xhr.abort()
|
|
|
+ }
|
|
|
+ signal.addEventListener('abort', onabort)
|
|
|
|
|
|
xhr.upload.addEventListener('progress', (ev) => {
|
|
|
if (!ev.lengthComputable) return
|
|
@@ -220,16 +302,20 @@ class MultipartUploader {
|
|
|
})
|
|
|
|
|
|
xhr.addEventListener('abort', (ev) => {
|
|
|
- remove(this.uploading, ev.target)
|
|
|
+ cleanup()
|
|
|
this.chunkState[index].busy = false
|
|
|
+
|
|
|
+ defer.reject(createAbortError())
|
|
|
})
|
|
|
|
|
|
xhr.addEventListener('load', (ev) => {
|
|
|
- remove(this.uploading, ev.target)
|
|
|
+ cleanup()
|
|
|
this.chunkState[index].busy = false
|
|
|
|
|
|
if (ev.target.status < 200 || ev.target.status >= 300) {
|
|
|
- this._onError(new Error('Non 2xx'))
|
|
|
+ const error = new Error('Non 2xx')
|
|
|
+ error.source = ev.target
|
|
|
+ defer.reject(error)
|
|
|
return
|
|
|
}
|
|
|
|
|
@@ -238,23 +324,26 @@ class MultipartUploader {
|
|
|
// NOTE This must be allowed by CORS.
|
|
|
const etag = ev.target.getResponseHeader('ETag')
|
|
|
if (etag === null) {
|
|
|
- this._onError(new Error('AwsS3/Multipart: Could not read the ETag header. This likely means CORS is not configured correctly on the S3 Bucket. Seee https://uppy.io/docs/aws-s3-multipart#S3-Bucket-Configuration for instructions.'))
|
|
|
+ defer.reject(new Error('AwsS3/Multipart: Could not read the ETag header. This likely means CORS is not configured correctly on the S3 Bucket. Seee https://uppy.io/docs/aws-s3-multipart#S3-Bucket-Configuration for instructions.'))
|
|
|
return
|
|
|
}
|
|
|
|
|
|
this._onPartComplete(index, etag)
|
|
|
+ defer.resolve()
|
|
|
})
|
|
|
|
|
|
xhr.addEventListener('error', (ev) => {
|
|
|
- remove(this.uploading, ev.target)
|
|
|
+ cleanup()
|
|
|
this.chunkState[index].busy = false
|
|
|
|
|
|
const error = new Error('Unknown error')
|
|
|
error.source = ev.target
|
|
|
- this._onError(error)
|
|
|
+ defer.reject(error)
|
|
|
})
|
|
|
|
|
|
xhr.send(body)
|
|
|
+
|
|
|
+ return promise
|
|
|
}
|
|
|
|
|
|
_completeUpload () {
|
|
@@ -275,9 +364,8 @@ class MultipartUploader {
|
|
|
}
|
|
|
|
|
|
_abortUpload () {
|
|
|
- this.uploading.slice().forEach(xhr => {
|
|
|
- xhr.abort()
|
|
|
- })
|
|
|
+ this.abortController.abort()
|
|
|
+
|
|
|
this.createdPromise.then(() => {
|
|
|
this.options.abortMultipartUpload({
|
|
|
key: this.key,
|
|
@@ -286,10 +374,13 @@ class MultipartUploader {
|
|
|
}, () => {
|
|
|
// if the creation failed we do not need to abort
|
|
|
})
|
|
|
- this.uploading = []
|
|
|
}
|
|
|
|
|
|
_onError (err) {
|
|
|
+ if (err && err.name === 'AbortError') {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
this.options.onError(err)
|
|
|
}
|
|
|
|
|
@@ -303,10 +394,10 @@ class MultipartUploader {
|
|
|
}
|
|
|
|
|
|
pause () {
|
|
|
- const inProgress = this.uploading.slice()
|
|
|
- inProgress.forEach((xhr) => {
|
|
|
- xhr.abort()
|
|
|
- })
|
|
|
+ this.abortController.abort()
|
|
|
+ // Swap it out for a new controller, because this instance may be resumed later.
|
|
|
+ this.abortController = new AbortController()
|
|
|
+
|
|
|
this.isPaused = true
|
|
|
}
|
|
|
|