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

329 lines (277 loc) 9.21 kB
// @ts-ignore untyped import type { RateLimitedQueue, WrapPromiseFunctionType } from '@uppy/utils' import { fetchWithNetworkError, hasProperty as has, NetworkError, } from '@uppy/utils' import Emitter from 'component-emitter' import { type AssemblyFile, type AssemblyResponse, type AssemblyResult, getAssemblyUrlSsl, } from './index.js' 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: unknown, test: string) { if (typeof status !== 'string') { return false } return statusOrder.indexOf(status) >= statusOrder.indexOf(test) } class TransloaditAssembly extends Emitter { #rateLimitedQueue: RateLimitedQueue #fetchWithNetworkError: WrapPromiseFunctionType<typeof fetchWithNetworkError> #previousFetchStatusStillPending = false #sse: EventSource | null = null #status: AssemblyResponse pollInterval: ReturnType<typeof setInterval> | null closed: boolean constructor(assembly: AssemblyResponse, rateLimitedQueue: 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(): void { this.#connectServerSentEvents() this.#beginPolling() } #onFinished() { this.emit('finished') this.close() } get status(): AssemblyResponse { return this.#status } set status(status: AssemblyResponse) { this.#status = status this.emit('status', status) } #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: AssemblyFile = JSON.parse(e.data) this.status = { ...this.status, uploads: [...(this.status.uploads ?? []), file], } this.emit('upload', file) }) this.#sse.addEventListener('assembly_result_finished', (e) => { const [stepName, result]: [string, AssemblyResult] = JSON.parse(e.data) this.status = { ...this.status, results: { ...this.status.results, [stepName]: [...(this.status.results?.[stepName] ?? []), 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: AssemblyResponse | NetworkError | Error) { 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 statusUrl = getAssemblyUrlSsl(this.status) const response = await this.#fetchWithNetworkError(statusUrl) 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 if (diff) { this.updateStatus(status) } else { this.status = status } } catch (err) { this.#onError(err) } } update(): Promise<void> { 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: AssemblyResponse): void { 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: AssemblyResponse, next: AssemblyResponse) { 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). const prevUploads = prev.uploads const nextUploads = next.uploads if (nextUploads != null && prevUploads != null) { Object.keys(nextUploads) .filter((upload) => !has(prevUploads, upload)) .forEach((upload) => { // This is a bit confusing. Not sure why Object.keys was chosen here, because nextUploads is an Array. Object.keys returns strings for array keys ("0", "1", etc.). Typescript expects arrays to be indexed with a number, not a string nextUploads[0], even though JavaScript is fine with it, so we need to type assert here: this.emit('upload', nextUploads[upload as unknown as number]) }) } if (nowExecuting) { this.emit('metadata') } // Find new results. const nextResultsMap = next.results const prevResultsMap = prev.results if (nextResultsMap != null && prevResultsMap != null) { Object.keys(nextResultsMap).forEach((stepName) => { const nextResults = nextResultsMap[stepName] ?? [] const prevResults = prevResultsMap[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(): void { this.closed = true if (this.#sse) { this.#sse.close() this.#sse = null } clearInterval(this.pollInterval!) this.pollInterval = null } } export default TransloaditAssembly