upload-primitives.tsā¢6.93 kB
import axios from "axios";
import { api } from "../api";
import { FileType, FileSource } from "../api";
export type UploadStatus = "idle" | "preparing" | "uploading" | "finalizing" | "completed" | "error";
export type FileWithPreview = File & { preview?: string };
// Node.js File-like interface for MCP
export interface NodeFileData {
buffer: Buffer;
name: string;
size: number;
type: string;
lastModified: number;
slice(start: number, end: number): { arrayBuffer(): Promise<ArrayBuffer> };
}
// Union type for browser File or Node.js file data
export type FileOrNodeFile = FileWithPreview | NodeFileData;
const extractEtagFromHeaders = (headers: Record<string, any>) => {
const etag = headers.etag || headers.ETag || headers["etag"];
if (!etag) return `""`;
if (typeof etag === "string") return etag;
return etag.toString();
};
const uploadFileChunk = async (params: {
url: string;
data: ArrayBuffer;
onProgress: (progress: number) => void;
signal?: AbortSignal;
}) => {
const { url, data, onProgress, signal } = params;
const response = await axios.request({
method: "PUT",
url,
data,
signal,
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
const progress = progressEvent.loaded / progressEvent.total;
onProgress(progress);
}
},
});
if (response.status !== 200) {
throw new Error("Failed to upload chunk");
}
return JSON.parse(extractEtagFromHeaders(response.headers)) as string;
};
const detectFileType = (mimeType: string): FileType => {
if (mimeType.startsWith("image/")) {
if (mimeType === "image/gif") return FileType.Gif;
return FileType.Image;
}
if (mimeType.startsWith("video/")) return FileType.Video;
return FileType.Unknown;
};
export class UploadPrimitives {
public file: FileOrNodeFile | null = null;
public fileId: string | null = null;
public fileType: FileType | null = null;
public metadata: Record<string, any> | null = null;
public status: UploadStatus = "idle";
public progress = 0;
public error: Error | null = null;
public totalChunks = 0;
public currentChunk = 0;
public completedChunks: Array<{ eTag: string; partNumber: number }> = [];
public uploadInfo: {
chunkCount: number;
chunkSize: number;
fileUploadId: string;
uploadPartUrls: Array<{ url: string; partNumber: number }>;
} | null = null;
constructor(private options?: { disableThumbnail?: boolean; isPrivate?: boolean }) {}
reset() {
this.file = null;
this.fileId = null;
this.fileType = null;
this.metadata = null;
this.status = "idle";
this.progress = 0;
this.error = null;
this.totalChunks = 0;
this.currentChunk = 0;
this.completedChunks = [];
this.uploadInfo = null;
}
async prepareUpload(params: {
file: FileOrNodeFile;
metadata: string;
fileType: FileType;
source: FileSource;
deviceId: string;
}) {
const { file, metadata, fileType, source, deviceId } = params;
this.status = "preparing";
this.error = null;
this.file = file;
this.fileType = fileType;
try {
const response = await api.assets.prepareNewFile({
metadata,
deviceId,
type: fileType,
source,
fileSize: file.size,
isPrivate: this.options?.isPrivate,
});
if (response.status !== "ok") {
throw new Error("Failed to prepare file upload");
}
this.fileId = response.fileId;
this.uploadInfo = {
chunkCount: response.chunkCount,
chunkSize: response.chunkSize,
fileUploadId: response.fileUploadId,
uploadPartUrls: response.uploadPartUrls,
};
this.totalChunks = response.chunkCount;
return response;
} catch (error) {
this.status = "error";
this.error = error instanceof Error ? error : new Error(String(error));
throw error;
}
}
// Helper function to check if file is NodeFileData
private isNodeFile(file: FileOrNodeFile): file is NodeFileData {
return "buffer" in file;
}
async uploadChunk(chunkIndex: number): Promise<{ eTag: string; partNumber: number }> {
if (!this.file || !this.uploadInfo) {
throw new Error("File or upload info not available");
}
this.currentChunk = chunkIndex + 1;
const part = this.uploadInfo.uploadPartUrls[chunkIndex];
if (!part) {
throw new Error("Invalid chunk index");
}
const { url, partNumber } = part;
const { chunkSize } = this.uploadInfo;
const start = chunkIndex * chunkSize;
const end = Math.min(start + chunkSize, this.file.size);
const buffer = await this.file.slice(start, end).arrayBuffer();
let retries = 3;
while (retries > 0) {
try {
const eTag = await uploadFileChunk({
url,
data: buffer,
onProgress: (chunkProgress) => {
const overallProgress = (chunkIndex + chunkProgress) / this.uploadInfo!.chunkCount;
this.progress = overallProgress;
},
});
const completedChunk = { eTag, partNumber };
this.completedChunks.push(completedChunk);
return completedChunk;
} catch (error) {
retries--;
if (retries === 0) {
this.status = "error";
this.error = error instanceof Error ? error : new Error(String(error));
throw new Error(`Failed to upload chunk ${chunkIndex + 1}`);
}
}
}
throw new Error(`Failed to upload chunk ${chunkIndex + 1} - all retries exhausted`);
}
async uploadAllChunks(): Promise<Array<{ eTag: string; partNumber: number }>> {
if (!this.uploadInfo) {
throw new Error("Upload info not available");
}
this.status = "uploading";
const results: Array<{ eTag: string; partNumber: number }> = [];
for (let i = 0; i < this.uploadInfo.chunkCount; i++) {
try {
const result = await this.uploadChunk(i);
results.push(result);
} catch (error) {
this.status = "error";
throw error;
}
}
return results;
}
async finalizeUpload() {
if (!this.fileId || !this.file || this.completedChunks.length === 0) {
throw new Error("Missing data for upload finalization");
}
this.status = "finalizing";
try {
const response = await api.assets.completeUpload({
fileId: this.fileId,
chunks: this.completedChunks,
mimeType: this.file.type,
disableThumbnail: this.options?.disableThumbnail,
});
if (response.status !== "ok") {
throw new Error("Failed to finalize upload");
}
this.status = "completed";
return response;
} catch (error) {
this.status = "error";
this.error = error instanceof Error ? error : new Error(String(error));
throw error;
}
}
}
export const detectFileTypeFromMimeType = detectFileType;