mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
160 lines
4.5 KiB
TypeScript
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.
|
|
}
|
|
},
|
|
};
|
|
}
|