import { type MediaChunkAckDto, type MediaUploadCreateDto, type MediaUploadSessionDto, } from "@/api/media-upload-contracts"; export interface MediaUploadEndpointSet { create(dto: MediaUploadCreateDto, signal?: AbortSignal): Promise; putChunk(args: { uploadId: string; index: number; blob: Blob; start: number; end: number; totalBytes: number; sha256: string; signal?: AbortSignal; }): Promise; commit(uploadId: string, signal?: AbortSignal): Promise; getStatus(uploadId: string, signal?: AbortSignal): Promise; cancel(uploadId: string): Promise; } /** Network-level failure (fetch rejected) — recoverable by re-reading session status. */ export class MediaUploadNetworkError extends Error { constructor() { super("Upload failed. Check your connection and try again."); this.name = "MediaUploadNetworkError"; } } export function isNetworkUploadError(error: unknown): boolean { return error instanceof MediaUploadNetworkError; } export function isAbortError(error: unknown): boolean { return error instanceof Error && error.name === "AbortError"; } export async function computeSha256Hex(buffer: ArrayBuffer): Promise { const digest = await crypto.subtle.digest("SHA-256", buffer); return Array.from(new Uint8Array(digest)) .map((byte) => byte.toString(16).padStart(2, "0")) .join(""); } interface EndpointOptions { resolveUrl: (suffix: string) => string; headers: () => Record; } function parseSessionPayload(payload: unknown): MediaUploadSessionDto { if (typeof payload !== "object" || payload === null) { throw new MediaUploadNetworkError(); } return payload as MediaUploadSessionDto; } async function readJsonBody(response: Response): Promise { const text = await response.text(); if (!text.trim()) { return {}; } try { return JSON.parse(text) as unknown; } catch { throw new MediaUploadNetworkError(); } } async function requestJson( url: string, init: RequestInit, authHeaders: Record, ): Promise { let response: Response; try { response = await fetch(url, { ...init, headers: { Accept: "application/json", ...authHeaders, ...init.headers }, }); } catch (error) { if (isAbortError(error)) { throw error; } throw new MediaUploadNetworkError(); } const payload = await readJsonBody(response); if (!response.ok) { const error = new Error(`Upload request failed (HTTP ${response.status}).`); error.name = "MediaUploadHttpError"; Object.assign(error, { status: response.status, payload }); throw error; } return payload; } export function createMediaUploadEndpoints({ resolveUrl, headers, }: EndpointOptions): MediaUploadEndpointSet { const sessionUrl = (uploadId: string, suffix = ""): string => resolveUrl([uploadId, suffix].filter(Boolean).join("/")); return { create: async (dto, signal) => parseSessionPayload( await requestJson( resolveUrl(""), { method: "POST", body: JSON.stringify(dto), headers: { "Content-Type": "application/json" }, signal, }, headers(), ), ), putChunk: async ({ uploadId, index, blob, start, end, totalBytes, sha256, signal }) => { const payload = await requestJson( sessionUrl(uploadId, `chunks/${index}`), { method: "PUT", body: blob, headers: { "Content-Type": "application/octet-stream", "Content-Range": `bytes ${start}-${end - 1}/${totalBytes}`, "X-Chunk-SHA256": sha256.toLowerCase(), }, signal, }, headers(), ); return payload as MediaChunkAckDto; }, commit: async (uploadId, signal) => parseSessionPayload( await requestJson(sessionUrl(uploadId, "commit"), { method: "POST", signal }, headers()), ), getStatus: async (uploadId, signal) => parseSessionPayload( await requestJson(sessionUrl(uploadId), { method: "GET", signal }, headers()), ), cancel: async (uploadId) => { try { await fetch(sessionUrl(uploadId), { method: "DELETE", headers: { Accept: "application/json", ...headers() }, }); } catch { // Best-effort cancel — surface nothing when the session is already gone. } }, }; }