UNPKG

@uppy/aws-s3-multipart

Version:

Upload to Amazon S3 with Uppy and S3's Multipart upload strategy

817 lines (686 loc) 25.3 kB
import BasePlugin from '@uppy/core/lib/BasePlugin.js' import { Socket, Provider, RequestClient } from '@uppy/companion-client' import EventTracker from '@uppy/utils/lib/EventTracker' import emitSocketProgress from '@uppy/utils/lib/emitSocketProgress' import getSocketHost from '@uppy/utils/lib/getSocketHost' import { RateLimitedQueue } from '@uppy/utils/lib/RateLimitedQueue' import { createAbortError } from '@uppy/utils/lib/AbortController' import packageJson from '../package.json' import MultipartUploader from './MultipartUploader.js' function assertServerError (res) { if (res && res.error) { const error = new Error(res.message) Object.assign(error, res.error) throw error } return res } function throwIfAborted (signal) { if (signal?.aborted) { throw createAbortError('The operation was aborted', { cause: signal.reason }) } } class HTTPCommunicationQueue { #abortMultipartUpload #cache = new WeakMap() #createMultipartUpload #fetchSignature #listParts #previousRetryDelay #requests #retryDelayIterator #sendCompletionRequest #setS3MultipartState #uploadPartBytes constructor (requests, options, setS3MultipartState) { this.#requests = requests this.#setS3MultipartState = setS3MultipartState this.setOptions(options) } setOptions (options) { const requests = this.#requests if ('abortMultipartUpload' in options) { this.#abortMultipartUpload = requests.wrapPromiseFunction(options.abortMultipartUpload) } 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) } if ('retryDelays' in options) { this.#retryDelayIterator = options.retryDelays?.values() } if ('uploadPartBytes' in options) { this.#uploadPartBytes = requests.wrapPromiseFunction(options.uploadPartBytes, { priority:Infinity }) } } async #shouldRetry (err) { 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 = this.#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 = this.#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 = this.#retryDelayIterator?.next() if (next == null || next.done) { return false } await new Promise(resolve => setTimeout(resolve, next.value)) } return true } async getUploadId (file, signal) { const cachedResult = this.#cache.get(file.data) if (cachedResult != null) { return cachedResult } const promise = this.#createMultipartUpload(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) }) 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. await this.#abortMultipartUpload(file, await result) } } async uploadFile (file, chunks, signal) { throwIfAborted(signal) const { uploadId, key } = await this.getUploadId(file, signal) throwIfAborted(signal) const parts = await Promise.all(chunks.map((chunk, i) => this.uploadChunk(file, i + 1, chunk, signal))) throwIfAborted(signal) return this.#sendCompletionRequest(file, { key, uploadId, parts, signal }).abortOn(signal) } async resumeUploadFile (file, chunks, signal) { throwIfAborted(signal) const { uploadId, key } = await this.getUploadId(file, signal) throwIfAborted(signal) const alreadyUploadedParts = await this.#listParts(file, { uploadId, key, 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) return alreadyUploadedInfo == null ? this.uploadChunk(file, partNumber, chunk, signal) : { PartNumber: partNumber, ETag: alreadyUploadedInfo.ETag } }), ) throwIfAborted(signal) return this.#sendCompletionRequest(file, { key, uploadId, parts, signal }).abortOn(signal) } async uploadChunk (file, partNumber, body, signal) { throwIfAborted(signal) const { uploadId, key } = await this.getUploadId(file, signal) throwIfAborted(signal) for (;;) { const signature = await this.#fetchSignature(file, { uploadId, key, partNumber, body, signal }).abortOn(signal) throwIfAborted(signal) try { return { PartNumber: partNumber, ...await this.#uploadPartBytes(signature, body, signal).abortOn(signal), } } catch (err) { if (!await this.#shouldRetry(err)) throw err } } } } export default class AwsS3Multipart extends BasePlugin { static VERSION = packageJson.version #queueRequestSocketToken #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: this is currently opt-in for backward compat, switch to opt-out in the next major allowedMetaFields: null, limit: 6, 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), signPart: this.signPart.bind(this), uploadPartBytes: AwsS3Multipart.uploadPartBytes, 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] } } } this.upload = this.upload.bind(this) /** * 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.uploaders = Object.create(null) this.uploaderEvents = Object.create(null) this.uploaderSockets = Object.create(null) this.#queueRequestSocketToken = this.requests.wrapPromiseFunction(this.#requestSocketToken, { priority: -1 }) } [Symbol.for('uppy test: getClient')] () { return this.#client } setOptions (newOptions) { this.#companionCommunicationQueue.setOptions(newOptions) return super.setOptions(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, 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 = file.meta ? Object.fromEntries( (this.opts.allowedMetaFields ?? Object.keys(file.meta)) .filter(key => file.meta[key] != null) .map(key => [key, String(file.meta[key])]), ) : {} 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) } 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) } static async uploadPartBytes ({ url, expires, headers }, body, 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('PUT', 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', body.onProgress) 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 } body.onProgress?.(body.size) // NOTE This must be allowed by CORS. const etag = ev.target.getResponseHeader('ETag') 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 } body.onComplete?.(etag) resolve({ ETag: etag, }) }) 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) this.uppy.setFileState(file.id, { s3Multipart: { ...cFile.s3Multipart, key, uploadId, }, }) } uploadFile (file) { return new Promise((resolve, reject) => { const onProgress = (bytesUploaded, bytesTotal) => { this.uppy.emit('upload-progress', file, { 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 uploadObject = upload // eslint-disable-line no-use-before-define const uploadResp = { body: { ...result, }, uploadURL: result.location, } this.resetUploaderReferences(file.id) const cFile = this.uppy.getFile(file.id) this.uppy.emit('upload-success', cFile || file, uploadResp) if (result.location) { this.uppy.log(`Download ${file.name} from ${result.location}`) } resolve(uploadObject) } const onPartComplete = (part) => { const cFile = this.uppy.getFile(file.id) if (!cFile) { return } this.uppy.emit('s3-multipart:part-uploaded', cFile, 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, ...file.s3Multipart, }) this.uploaders[file.id] = upload this.uploaderEvents[file.id] = new EventTracker(this.uppy) this.onFileRemove(file.id, (removed) => { upload.abort() this.resetUploaderReferences(file.id, { abort: true }) resolve(`upload ${removed.id} was removed`) }) this.onCancelAll(file.id, ({ reason } = {}) => { if (reason === 'user') { upload.abort() this.resetUploaderReferences(file.id, { abort: true }) } resolve(`upload ${file.id} was canceled`) }) this.onFilePause(file.id, (isPaused) => { if (isPaused) { upload.pause() } else { upload.start() } }) this.onPauseAll(file.id, () => { upload.pause() }) this.onResumeAll(file.id, () => { upload.start() }) // Don't double-emit upload-started for Golden Retriever-restored files that were already started if (!file.progress.uploadStarted || !file.isRestored) { upload.start() this.uppy.emit('upload-started', file) } }) } #requestSocketToken = async (file) => { const Client = file.remote.providerOptions.provider ? Provider : RequestClient const client = new Client(this.uppy, file.remote.providerOptions) const opts = { ...this.opts } if (file.tus) { // Install file-specific upload overrides. Object.assign(opts, file.tus) } if (file.remote.url == null) { throw new Error('Cannot connect to an undefined URL') } const res = await client.post(file.remote.url, { ...file.remote.body, protocol: 's3-multipart', size: file.data.size, metadata: file.meta, }) return res.token } async uploadRemote (file) { this.resetUploaderReferences(file.id) // Don't double-emit upload-started for Golden Retriever-restored files that were already started if (!file.progress.uploadStarted || !file.isRestored) { this.uppy.emit('upload-started', file) } try { if (file.serverToken) { return this.connectToServerSocket(file) } const serverToken = await this.#queueRequestSocketToken(file) this.uppy.setFileState(file.id, { serverToken }) return this.connectToServerSocket(this.uppy.getFile(file.id)) } catch (err) { this.uppy.emit('upload-error', file, err) throw err } } async connectToServerSocket (file) { return new Promise((resolve, reject) => { let queuedRequest const token = file.serverToken const host = getSocketHost(file.remote.companionUrl) const socket = new Socket({ target: `${host}/api/${token}` }) this.uploaderSockets[file.id] = socket this.uploaderEvents[file.id] = new EventTracker(this.uppy) this.onFileRemove(file.id, () => { queuedRequest.abort() socket.send('cancel', {}) this.resetUploaderReferences(file.id, { abort: true }) resolve(`upload ${file.id} was removed`) }) this.onFilePause(file.id, (isPaused) => { if (isPaused) { // Remove this file from the queue so another file can start in its place. queuedRequest.abort() socket.send('pause', {}) } else { // Resuming an upload should be queued, else you could pause and then // resume a queued upload to make it skip the queue. queuedRequest.abort() queuedRequest = this.requests.run(() => { socket.send('resume', {}) return () => {} }) } }) this.onPauseAll(file.id, () => { queuedRequest.abort() socket.send('pause', {}) }) this.onCancelAll(file.id, ({ reason } = {}) => { if (reason === 'user') { queuedRequest.abort() socket.send('cancel', {}) this.resetUploaderReferences(file.id) } resolve(`upload ${file.id} was canceled`) }) this.onResumeAll(file.id, () => { queuedRequest.abort() if (file.error) { socket.send('pause', {}) } queuedRequest = this.requests.run(() => { socket.send('resume', {}) }) }) this.onRetry(file.id, () => { // Only do the retry if the upload is actually in progress; // else we could try to send these messages when the upload is still queued. // We may need a better check for this since the socket may also be closed // for other reasons, like network failures. if (socket.isOpen) { socket.send('pause', {}) socket.send('resume', {}) } }) this.onRetryAll(file.id, () => { if (socket.isOpen) { socket.send('pause', {}) socket.send('resume', {}) } }) socket.on('progress', (progressData) => emitSocketProgress(this, progressData, file)) socket.on('error', (errData) => { this.uppy.emit('upload-error', file, new Error(errData.error)) this.resetUploaderReferences(file.id) queuedRequest.done() reject(new Error(errData.error)) }) socket.on('success', (data) => { const uploadResp = { uploadURL: data.url, } this.uppy.emit('upload-success', file, uploadResp) this.resetUploaderReferences(file.id) queuedRequest.done() resolve() }) queuedRequest = this.requests.run(() => { if (file.isPaused) { socket.send('pause', {}) } return () => {} }) }) } async upload (fileIDs) { if (fileIDs.length === 0) return undefined const promises = fileIDs.map((id) => { const file = this.uppy.getFile(id) if (file.isRemote) { return this.uploadRemote(file) } return this.uploadFile(file) }) return Promise.all(promises) } #setCompanionHeaders = () => { this.#client.setCompanionHeaders(this.opts.companionHeaders) } onFileRemove (fileID, cb) { this.uploaderEvents[fileID].on('file-removed', (file) => { if (fileID === file.id) cb(file.id) }) } onFilePause (fileID, cb) { this.uploaderEvents[fileID].on('upload-pause', (targetFileID, isPaused) => { if (fileID === targetFileID) { cb(isPaused) } }) } onRetry (fileID, cb) { this.uploaderEvents[fileID].on('upload-retry', (targetFileID) => { if (fileID === targetFileID) { cb() } }) } onRetryAll (fileID, cb) { this.uploaderEvents[fileID].on('retry-all', () => { if (!this.uppy.getFile(fileID)) return cb() }) } onPauseAll (fileID, cb) { this.uploaderEvents[fileID].on('pause-all', () => { if (!this.uppy.getFile(fileID)) return cb() }) } onCancelAll (fileID, eventHandler) { this.uploaderEvents[fileID].on('cancel-all', (...args) => { if (!this.uppy.getFile(fileID)) return eventHandler(...args) }) } onResumeAll (fileID, cb) { this.uploaderEvents[fileID].on('resume-all', () => { if (!this.uppy.getFile(fileID)) return cb() }) } install () { const { capabilities } = this.uppy.getState() this.uppy.setState({ capabilities: { ...capabilities, resumableUploads: true, }, }) this.uppy.addPreProcessor(this.#setCompanionHeaders) this.uppy.addUploader(this.upload) } uninstall () { const { capabilities } = this.uppy.getState() this.uppy.setState({ capabilities: { ...capabilities, resumableUploads: false, }, }) this.uppy.removePreProcessor(this.#setCompanionHeaders) this.uppy.removeUploader(this.upload) } }