Skip to content

Commit 20f4600

Browse files
authored
feat: Support multi-part uploads in JS API (#115)
* Refactor session.createComponent into Uploader class. * Move creating ComponentLocation to after upload finishes. * Emit a component-added event after upload finishes. * Add support for multi-part uploads. * Automatically retry failed upload requests.
1 parent 2165d89 commit 20f4600

15 files changed

+1059
-154
lines changed

.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ coverage/
33
build/
44
dist/
55
.eggs/
6+
.yarn/
67
renovate.json

source/session.ts

Lines changed: 19 additions & 148 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,8 @@ import {
1010
ServerValidationError,
1111
ServerError,
1212
AbortError,
13-
CreateComponentError,
1413
} from "./error.js";
15-
import { SERVER_LOCATION_ID } from "./constant.js";
1614

17-
import normalizeString from "./util/normalize_string.js";
1815
import type {
1916
ActionResponse,
2017
CallOptions,
@@ -37,31 +34,12 @@ import type {
3734
UpdateResponse,
3835
} from "./types.js";
3936
import { convertToIsoString } from "./util/convert_to_iso_string.js";
37+
import { Uploader } from "./uploader.js";
4038

4139
const logger = loglevel.getLogger("ftrack_api");
4240

4341
const ENCODE_DATETIME_FORMAT = "YYYY-MM-DDTHH:mm:ss";
4442

45-
/**
46-
* Create component from *file* and add to server location.
47-
*
48-
* @param {fileName} The name of the file.
49-
* @return {array} Array with [basename, extension] from filename.
50-
*/
51-
function splitFileExtension(fileName: string) {
52-
let basename = fileName || "";
53-
let extension =
54-
fileName.slice((Math.max(0, fileName.lastIndexOf(".")) || Infinity) + 1) ||
55-
"";
56-
57-
if (extension.length) {
58-
extension = `.${extension}`;
59-
basename = fileName.slice(0, -1 * extension.length) || "";
60-
}
61-
62-
return [basename, extension];
63-
}
64-
6543
/**
6644
* ftrack API session
6745
* @class Session
@@ -927,136 +905,29 @@ export class Session {
927905
* @return {Promise} Promise resolved with the response when creating
928906
* Component and ComponentLocation.
929907
*/
930-
createComponent<T extends Data = Data>(
908+
async createComponent(
931909
file: Blob,
932910
options: CreateComponentOptions = {}
933911
): Promise<
934-
[CreateResponse<T>, CreateResponse<T>, GetUploadMetadataResponse]
912+
readonly [CreateResponse, CreateResponse, GetUploadMetadataResponse]
935913
> {
936-
const componentName = options.name ?? (file as File).name;
937-
938-
let normalizedFileName;
939-
if (componentName) {
940-
normalizedFileName = normalizeString(componentName);
941-
}
942-
943-
if (!normalizedFileName) {
944-
throw new CreateComponentError("Component name is missing.");
945-
}
946-
const fileNameParts = splitFileExtension(normalizedFileName);
947-
const defaultProgress = (progress: number) => progress;
948-
const defaultAbort = () => {};
949-
950-
if (options.xhr) {
951-
logger.warn(
952-
"[session.createComponent] options.xhr is deprecated, use options.signal for aborting uploads."
953-
);
954-
}
955-
956-
const data = options.data || {};
957-
const onProgress = options.onProgress || defaultProgress;
958-
const xhr = options.xhr || new XMLHttpRequest();
959-
const onAborted = options.onAborted || defaultAbort;
960-
961-
const fileType = data.file_type || fileNameParts[1];
962-
const fileName = data.name || fileNameParts[0];
963-
const fileSize = data.size || file.size;
964-
const componentId = data.id || uuidV4();
965-
const componentLocationId = uuidV4();
966-
let url: string;
967-
let headers: Record<string, string> = {};
968-
969-
const handleAbortSignal = () => {
970-
xhr.abort();
971-
options.signal?.removeEventListener("abort", handleAbortSignal);
972-
};
973-
options.signal?.addEventListener("abort", handleAbortSignal);
974-
975-
const updateOnProgressCallback = (
976-
oEvent: ProgressEvent<XMLHttpRequestEventTarget>
977-
) => {
978-
let progress = 0;
979-
980-
if (oEvent.lengthComputable) {
981-
progress = Math.floor((oEvent.loaded / oEvent.total) * 100);
982-
}
983-
984-
onProgress(progress);
985-
};
986-
987-
logger.debug("Registering component and fetching upload metadata.");
988-
989-
const component = Object.assign(data, {
990-
id: componentId,
991-
name: fileName,
992-
file_type: fileType,
993-
size: fileSize,
994-
});
995-
const componentLocation = {
996-
id: componentLocationId,
997-
component_id: componentId,
998-
resource_identifier: componentId,
999-
location_id: SERVER_LOCATION_ID,
1000-
};
1001-
1002-
const componentAndLocationPromise = this.call<
1003-
[CreateResponse<T>, CreateResponse<T>, GetUploadMetadataResponse]
1004-
>([
1005-
operation.create("FileComponent", component),
1006-
operation.create("ComponentLocation", componentLocation),
1007-
{
1008-
action: "get_upload_metadata",
1009-
file_name: `${fileName}${fileType}`,
1010-
file_size: fileSize,
1011-
component_id: componentId,
1012-
},
1013-
]).then((response) => {
1014-
url = response[2].url;
1015-
headers = response[2].headers;
1016-
return response;
1017-
});
1018-
1019-
return componentAndLocationPromise.then(() => {
1020-
logger.debug(`Uploading file to: ${url}`);
1021-
1022-
return new Promise((resolve, reject) => {
1023-
// wait until file is uploaded
1024-
xhr.upload.addEventListener("progress", updateOnProgressCallback);
1025-
xhr.open("PUT", url, true);
1026-
xhr.onabort = () => {
1027-
onAborted();
1028-
this.delete("FileComponent", [componentId]).then(() => {
1029-
reject(
1030-
new CreateComponentError(
1031-
"Upload aborted by client",
1032-
"UPLOAD_ABORTED"
1033-
)
1034-
);
1035-
});
1036-
};
914+
return new Promise((resolve, reject) => {
915+
const uploader = new Uploader(this, file, {
916+
...options,
917+
onError(error) {
918+
reject(error);
919+
},
920+
onComplete() {
921+
// TODO: Deprecate createComponent response.
922+
resolve([
923+
uploader.createComponentResponse!,
924+
uploader.createComponentLocationResponse!,
925+
uploader.uploadMetadata!,
926+
]);
927+
},
928+
});
1037929

1038-
for (const key in headers) {
1039-
if (headers.hasOwnProperty(key) && key !== "Content-Length") {
1040-
xhr.setRequestHeader(key, headers[key]);
1041-
}
1042-
}
1043-
xhr.onload = () => {
1044-
if (xhr.status >= 400) {
1045-
reject(
1046-
new CreateComponentError(`Failed to upload file: ${xhr.status}`)
1047-
);
1048-
}
1049-
resolve(xhr.response);
1050-
};
1051-
xhr.onerror = () => {
1052-
this.delete("FileComponent", [componentId]).then(() => {
1053-
reject(
1054-
new CreateComponentError(`Failed to upload file: ${xhr.status}`)
1055-
);
1056-
});
1057-
};
1058-
xhr.send(file);
1059-
}).then(() => componentAndLocationPromise);
930+
uploader.start();
1060931
});
1061932
}
1062933
}

source/types.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,12 +107,27 @@ export interface DelayedJobResponse {
107107
export interface EncodeMediaResponse {
108108
job_id: string;
109109
}
110-
export interface GetUploadMetadataResponse {
110+
111+
export interface SinglePartGetUploadMetadataResponse {
111112
url: string;
112-
component_id?: string;
113+
component_id: string;
113114
headers: Data;
114115
}
115116

117+
export interface MultiPartUploadPart {
118+
signed_url: string;
119+
part_number: number;
120+
}
121+
export interface MultiPartGetUploadMetadataResponse {
122+
component_id: string;
123+
urls: MultiPartUploadPart[];
124+
upload_id: string;
125+
}
126+
127+
export type GetUploadMetadataResponse =
128+
| SinglePartGetUploadMetadataResponse
129+
| MultiPartGetUploadMetadataResponse;
130+
116131
export interface SendReviewSessionInviteResponse {
117132
sent: true;
118133
}

0 commit comments

Comments
 (0)