shoc-frontend-new/src/api/media-upload-client.ts
2026-09-23 04:17:18 -03:00

160 lines
4.5 KiB
TypeScript

import {
type MediaChunkAckDto,
type MediaUploadCreateDto,
type MediaUploadSessionDto,
} from "@/api/media-upload-contracts";
export interface MediaUploadEndpointSet {
create(dto: MediaUploadCreateDto, signal?: AbortSignal): Promise<MediaUploadSessionDto>;
putChunk(args: {
uploadId: string;
index: number;
blob: Blob;
start: number;
end: number;
totalBytes: number;
sha256: string;
signal?: AbortSignal;
}): Promise<MediaChunkAckDto>;
commit(uploadId: string, signal?: AbortSignal): Promise<MediaUploadSessionDto>;
getStatus(uploadId: string, signal?: AbortSignal): Promise<MediaUploadSessionDto>;
cancel(uploadId: string): Promise<void>;
}
/** 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<string> {
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<string, string>;
}
function parseSessionPayload(payload: unknown): MediaUploadSessionDto {
if (typeof payload !== "object" || payload === null) {
throw new MediaUploadNetworkError();
}
return payload as MediaUploadSessionDto;
}
async function readJsonBody(response: Response): Promise<unknown> {
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<string, string>,
): Promise<unknown> {
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.
}
},
};
}