import type { StuffboxClient } from "../client.js"; import { StuffboxError } from "../errors.js"; import type { Asset, CompleteUploadInput, CreateUploadInput, RequestOptions, UploadSession, } from "../types.js"; export type UploadPhase = | "creating" | "uploading" | "completing" | "complete"; export interface UploadProgress { phase: UploadPhase; loaded: number; total: number; percent: number; } export interface UploadTransportOptions { signal?: AbortSignal; } /** * The deliberately narrow boundary used by browser upload code. Implement it * with same-origin endpoints so app access and refresh tokens remain on the * integrating application's server. */ export interface UploadTransport { createUpload( input: CreateUploadInput, options?: UploadTransportOptions, ): Promise; completeUpload( uploadId: string, input?: CompleteUploadInput, options?: UploadTransportOptions, ): Promise; } export interface UploadFileOptions { sha256?: string; signal?: AbortSignal; onProgress?: (progress: UploadProgress) => void; } /** * Adapts an SDK client to the upload-only browser transport. Only use this when * the client's credential is explicitly browser-safe and user-scoped. An app * bearer or refresh token belongs behind a same-origin proxy instead. */ export function createSdkUploadTransport(client: StuffboxClient): UploadTransport { return { createUpload: (input, options) => client.createUpload(input, options as RequestOptions | undefined), completeUpload: (uploadId, input, options) => client.completeUpload( uploadId, input, options as RequestOptions | undefined, ), }; } function progress( phase: UploadPhase, loaded: number, total: number, ): UploadProgress { return { phase, loaded, total, percent: total === 0 ? 0 : Math.min(100, (loaded / total) * 100), }; } function putFile( session: UploadSession, file: File, signal: AbortSignal | undefined, onProgress: ((value: UploadProgress) => void) | undefined, ): Promise { return new Promise((resolve, reject) => { if (typeof XMLHttpRequest === "undefined") { reject( new StuffboxError({ code: "browser_api_unavailable", message: "Direct upload requires XMLHttpRequest in a browser", status: 0, }), ); return; } const xhr = new XMLHttpRequest(); let settled = false; const cleanup = () => { signal?.removeEventListener("abort", abort); xhr.upload.onprogress = null; xhr.onload = null; xhr.onerror = null; xhr.onabort = null; }; const succeed = () => { if (settled) return; settled = true; cleanup(); resolve(); }; const fail = (error: StuffboxError) => { if (settled) return; settled = true; cleanup(); reject(error); }; const abort = () => { xhr.abort(); fail( new StuffboxError({ code: "request_aborted", message: "Upload was aborted", status: 0, }), ); }; if (signal?.aborted) { abort(); return; } xhr.open(session.method, session.uploadUrl, true); for (const [name, value] of Object.entries(session.requiredHeaders)) { xhr.setRequestHeader(name, value); } xhr.upload.onprogress = (event) => { const loaded = Math.min(event.loaded, file.size); onProgress?.(progress("uploading", loaded, file.size)); }; xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { onProgress?.(progress("uploading", file.size, file.size)); succeed(); return; } fail( new StuffboxError({ code: "object_upload_failed", message: `Object storage rejected the upload with status ${xhr.status}`, status: xhr.status, }), ); }; xhr.onerror = () => { fail( new StuffboxError({ code: "network_error", message: "The direct object-storage upload failed", status: 0, }), ); }; xhr.onabort = () => { fail( new StuffboxError({ code: "request_aborted", message: "Upload was aborted", status: 0, }), ); }; signal?.addEventListener("abort", abort, { once: true }); onProgress?.(progress("uploading", 0, file.size)); xhr.send(file); }); } /** Creates a session, PUTs bytes directly, then asks Stuffbox to verify it. */ export async function uploadFile( transport: UploadTransport, file: File, options: UploadFileOptions = {}, ): Promise { const transportOptions: UploadTransportOptions = options.signal ? { signal: options.signal } : {}; options.onProgress?.(progress("creating", 0, file.size)); const session = await transport.createUpload( { filename: file.name, mimeType: file.type || "application/octet-stream", size: file.size, ...(options.sha256 ? { sha256: options.sha256 } : {}), }, transportOptions, ); await putFile(session, file, options.signal, options.onProgress); options.onProgress?.(progress("completing", file.size, file.size)); const asset = await transport.completeUpload( session.id, session.completionToken ? { completionToken: session.completionToken } : undefined, transportOptions, ); options.onProgress?.(progress("complete", file.size, file.size)); return asset; }