Skip to content

Commit

Permalink
Merge branch 'main' of https://github.com/fal-ai/fal-js into feat/req…
Browse files Browse the repository at this point in the history
…uest-abort-signal-support
  • Loading branch information
drochetti committed Nov 26, 2024
2 parents 5fede25 + 8b2f66b commit 5b2860b
Show file tree
Hide file tree
Showing 3 changed files with 421 additions and 39 deletions.
2 changes: 1 addition & 1 deletion libs/client/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@fal-ai/client",
"description": "The fal.ai client for JavaScript and TypeScript",
"version": "1.2.0-alpha.7",
"version": "1.2.0-alpha.6",
"license": "MIT",
"repository": {
"type": "git",
Expand Down
123 changes: 117 additions & 6 deletions libs/client/src/storage.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { getRestApiUrl, RequiredConfig } from "./config";
import { dispatchRequest } from "./request";
import { isPlainObject } from "./utils";

/**
* File support for the client. This interface establishes the contract for
* uploading files to the server and transforming the input to replace file
Expand Down Expand Up @@ -53,20 +52,42 @@ function getExtensionFromContentType(contentType: string): string {
/**
* Initiate the upload of a file to the server. This returns the URL to upload
* the file to and the URL of the file once it is uploaded.
*
* @param file the file to upload
* @returns the URL to upload the file to and the URL of the file once it is uploaded.
*/
async function initiateUpload(
file: Blob,
config: RequiredConfig,
contentType: string,
): Promise<InitiateUploadResult> {
const filename =
file.name || `${Date.now()}.${getExtensionFromContentType(contentType)}`;

return await dispatchRequest<InitiateUploadData, InitiateUploadResult>({
method: "POST",
// NOTE: We want to test V3 without making it the default at the API level
targetUrl: `${getRestApiUrl()}/storage/upload/initiate?storage_type=fal-cdn-v3`,
input: {
content_type: contentType,
file_name: filename,
},
config,
});
}

/**
* Initiate the multipart upload of a file to the server. This returns the URL to upload
* the file to and the URL of the file once it is uploaded.
*/
async function initiateMultipartUpload(
file: Blob,
config: RequiredConfig,
contentType: string,
): Promise<InitiateUploadResult> {
const contentType = file.type || "application/octet-stream";
const filename =
file.name || `${Date.now()}.${getExtensionFromContentType(contentType)}`;

return await dispatchRequest<InitiateUploadData, InitiateUploadResult>({
method: "POST",
targetUrl: `${getRestApiUrl()}/storage/upload/initiate`,
targetUrl: `${getRestApiUrl()}/storage/upload/initiate-multipart?storage_type=fal-cdn-v3`,
input: {
content_type: contentType,
file_name: filename,
Expand All @@ -75,6 +96,88 @@ async function initiateUpload(
});
}

type MultipartObject = {
partNumber: number;
etag: string;
};

async function partUploadRetries(
uploadUrl: string,
chunk: Blob,
config: RequiredConfig,
tries: number = 3,

Check failure on line 108 in libs/client/src/storage.ts

View workflow job for this annotation

GitHub Actions / build

Type number trivially inferred from a number literal, remove type annotation
): Promise<MultipartObject> {
if (tries === 0) {
throw new Error("Part upload failed, retries exhausted");
}

const { fetch, responseHandler } = config;

try {
const response = await fetch(uploadUrl, {
method: "PUT",
body: chunk,
});

return (await responseHandler(response)) as MultipartObject;
} catch (error) {
return await partUploadRetries(uploadUrl, chunk, config, tries - 1);
}
}

async function multipartUpload(
file: Blob,
config: RequiredConfig,
): Promise<string> {
const { fetch, responseHandler } = config;
const contentType = file.type || "application/octet-stream";
const { upload_url: uploadUrl, file_url: url } =
await initiateMultipartUpload(file, config, contentType);

// Break the file into 10MB chunks
const chunkSize = 10 * 1024 * 1024;
const chunks = Math.ceil(file.size / chunkSize);

const parsedUrl = new URL(uploadUrl);

const responses: MultipartObject[] = [];

try {

Check failure on line 145 in libs/client/src/storage.ts

View workflow job for this annotation

GitHub Actions / build

Unnecessary try/catch wrapper
for (let i = 0; i < chunks; i++) {
const start = i * chunkSize;
const end = Math.min(start + chunkSize, file.size);

const chunk = file.slice(start, end);

const partNumber = i + 1;
// {uploadUrl}/{part_number}?uploadUrlParams=...
const partUploadUrl = `${parsedUrl.origin}${parsedUrl.pathname}/${partNumber}${parsedUrl.search}`;

responses.push(await partUploadRetries(partUploadUrl, chunk, config));
}
} catch (error) {
throw error;
}

// Complete the upload
const completeUrl = `${parsedUrl.origin}${parsedUrl.pathname}/complete${parsedUrl.search}`;
const response = await fetch(completeUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
parts: responses.map((mpart) => ({
partNumber: mpart.partNumber,
etag: mpart.etag,
})),
}),
});
await responseHandler(response);

return url;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type KeyValuePair = [string, any];

Expand All @@ -87,10 +190,18 @@ export function createStorageClient({
}: StorageClientDependencies): StorageClient {
const ref: StorageClient = {
upload: async (file: Blob) => {
// Check for 90+ MB file size to do multipart upload
if (file.size > 90 * 1024 * 1024) {
return await multipartUpload(file, config);
}

const contentType = file.type || "application/octet-stream";

const { fetch, responseHandler } = config;
const { upload_url: uploadUrl, file_url: url } = await initiateUpload(
file,
config,
contentType,
);
const response = await fetch(uploadUrl, {
method: "PUT",
Expand Down
Loading

0 comments on commit 5b2860b

Please sign in to comment.