UNPKG

@uppy/transloadit

Version:

The Transloadit plugin can be used to upload files to Transloadit for all kinds of processing, such as transcoding video, resizing images, zipping/unzipping, and more

233 lines (232 loc) 8.62 kB
import fetchWithNetworkError from '@uppy/utils/lib/fetchWithNetworkError'; import has from '@uppy/utils/lib/hasProperty'; import NetworkError from '@uppy/utils/lib/NetworkError'; import Emitter from 'component-emitter'; const ASSEMBLY_UPLOADING = 'ASSEMBLY_UPLOADING'; const ASSEMBLY_EXECUTING = 'ASSEMBLY_EXECUTING'; const ASSEMBLY_COMPLETED = 'ASSEMBLY_COMPLETED'; const statusOrder = [ASSEMBLY_UPLOADING, ASSEMBLY_EXECUTING, ASSEMBLY_COMPLETED]; /** * Check that an assembly status is equal to or larger than some desired status. * It checks for things that are larger so that a comparison like this works, * when the old assembly status is UPLOADING but the new is FINISHED: * * !isStatus(oldStatus, ASSEMBLY_EXECUTING) && isStatus(newState, ASSEMBLY_EXECUTING) * * …so that we can emit the 'executing' event even if the execution step was so * fast that we missed it. */ function isStatus(status, test) { return statusOrder.indexOf(status) >= statusOrder.indexOf(test); } class TransloaditAssembly extends Emitter { #rateLimitedQueue; #fetchWithNetworkError; #previousFetchStatusStillPending = false; #sse = null; status; pollInterval; closed; constructor(assembly, rateLimitedQueue) { super(); // The current assembly status. this.status = assembly; // The interval timer for full status updates. this.pollInterval = null; // Whether this assembly has been closed (finished or errored) this.closed = false; this.#rateLimitedQueue = rateLimitedQueue; this.#fetchWithNetworkError = rateLimitedQueue.wrapPromiseFunction(fetchWithNetworkError); } connect() { this.#connectServerSentEvents(); this.#beginPolling(); } #onFinished() { this.emit('finished'); this.close(); } #connectServerSentEvents() { this.#sse = new EventSource(`${this.status.websocket_url}?assembly=${this.status.assembly_id}`); this.#sse.addEventListener('open', () => { clearInterval(this.pollInterval); this.pollInterval = null; }); /* * The event "message" is a special case, as it * will capture events without an event field * as well as events that have the specific type * other event type. */ this.#sse.addEventListener('message', (e) => { if (e.data === 'assembly_finished') { this.#onFinished(); } if (e.data === 'assembly_uploading_finished') { this.emit('executing'); } if (e.data === 'assembly_upload_meta_data_extracted') { this.emit('metadata'); this.#fetchStatus({ diff: false }); } }); this.#sse.addEventListener('assembly_upload_finished', (e) => { const file = JSON.parse(e.data); this.status.uploads.push(file); this.emit('upload', file); }); this.#sse.addEventListener('assembly_result_finished', (e) => { const [stepName, result] = JSON.parse(e.data); (this.status.results[stepName] ??= []).push(result); this.emit('result', stepName, result); }); this.#sse.addEventListener('assembly_execution_progress', (e) => { const details = JSON.parse(e.data); this.emit('execution-progress', details); }); this.#sse.addEventListener('assembly_error', (e) => { try { this.#onError(JSON.parse(e.data)); } catch { this.#onError(new Error(e.data)); } // Refetch for updated status code this.#fetchStatus({ diff: false }); }); } #onError(assemblyOrError) { this.emit('error', Object.assign(new Error(assemblyOrError.message), assemblyOrError)); this.close(); } /** * Begin polling for assembly status changes. This sends a request to the * assembly status endpoint every so often, if SSE connection failed. * If the SSE connection fails or takes a long time, we won't miss any * events. */ #beginPolling() { this.pollInterval = setInterval(() => { this.#fetchStatus(); }, 2000); } /** * Reload assembly status. Useful if SSE doesn't work. * * Pass `diff: false` to avoid emitting diff events, instead only emitting * 'status'. */ async #fetchStatus({ diff = true } = {}) { if (this.closed || this.#rateLimitedQueue.isPaused || this.#previousFetchStatusStillPending) return; try { this.#previousFetchStatusStillPending = true; const response = await this.#fetchWithNetworkError(this.status.assembly_ssl_url); this.#previousFetchStatusStillPending = false; if (this.closed) return; if (response.status === 429) { this.#rateLimitedQueue.rateLimit(2_000); return; } if (!response.ok) { this.#onError(new NetworkError(response.statusText)); return; } const status = await response.json(); // Avoid updating if we closed during this request's lifetime. if (this.closed) return; this.emit('status', status); if (diff) { this.updateStatus(status); } else { this.status = status; } } catch (err) { this.#onError(err); } } update() { return this.#fetchStatus({ diff: true }); } /** * Update this assembly's status with a full new object. Events will be * emitted for status changes, new files, and new results. */ updateStatus(next) { this.#diffStatus(this.status, next); this.status = next; } /** * Diff two assembly statuses, and emit the events necessary to go from `prev` * to `next`. */ #diffStatus(prev, next) { const prevStatus = prev.ok; const nextStatus = next.ok; if (next.error && !prev.error) { return this.#onError(next); } // Desired emit order: // - executing // - (n × upload) // - metadata // - (m × result) // - finished // The below checks run in this order, that way even if we jump from // UPLOADING straight to FINISHED all the events are emitted as expected. const nowExecuting = isStatus(nextStatus, ASSEMBLY_EXECUTING) && !isStatus(prevStatus, ASSEMBLY_EXECUTING); if (nowExecuting) { // Without SSE, this is our only way to tell if uploading finished. // Hence, we emit this just before the 'upload's and before the 'metadata' // event for the most intuitive ordering, corresponding to the _usual_ // ordering (if not guaranteed) that you'd get on SSE. this.emit('executing'); } // Only emit if the upload is new (not in prev.uploads). Object.keys(next.uploads) .filter((upload) => !has(prev.uploads, upload)) .forEach((upload) => { // @ts-ignore either the types are wrong or the tests are wrong. // types think next.uploads is an array, but the tests pass an object. this.emit('upload', next.uploads[upload]); }); if (nowExecuting) { this.emit('metadata'); } // Find new results. Object.keys(next.results).forEach((stepName) => { const nextResults = next.results[stepName]; const prevResults = prev.results[stepName]; nextResults .filter((n) => !prevResults || !prevResults.some((p) => p.id === n.id)) .forEach((result) => { this.emit('result', stepName, result); }); }); if (isStatus(nextStatus, ASSEMBLY_COMPLETED) && !isStatus(prevStatus, ASSEMBLY_COMPLETED)) { this.emit('finished'); } return undefined; } /** * Stop updating this assembly. */ close() { this.closed = true; if (this.#sse) { this.#sse.close(); this.#sse = null; } clearInterval(this.pollInterval); this.pollInterval = null; } } export default TransloaditAssembly;