@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
258 lines (257 loc) • 9.65 kB
JavaScript
import { fetchWithNetworkError, hasProperty as has, NetworkError, } from '@uppy/utils';
import Emitter from 'component-emitter';
import { 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, test) {
if (typeof status !== 'string') {
return false;
}
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();
}
get status() {
return this.#status;
}
set status(status) {
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 = 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] = 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) {
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() {
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).
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]);
});
}
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() {
this.closed = true;
if (this.#sse) {
this.#sse.close();
this.#sse = null;
}
clearInterval(this.pollInterval);
this.pollInterval = null;
}
}
export default TransloaditAssembly;