SPB Git

Repository Public

upload-engine.ts 4.0 KB 64 lines typescript

1// Chunked, resumable transfer of one File to an upload session that already exists on the server. Used by the2// explorer (owner uploads) and by the public deposit page (portal uploads): only the URLs and headers differ.3import { CHUNK_BYTES } from '@spd/shared';4import { ApiError, sha256Hex } from './api.ts';56export interface Transfer {7  file: Blob;8  /** PATCH target for a chunk starting at `offset` (path below /api/v1). */9  chunkPath(offset: number): string;10  /** GET that answers `{ received }` — where the server wants the next byte. */11  statusPath: string;12  headers?: Record<string, string>;13  chunkBytes?: number;14  /** Bytes the server already holds (resuming an earlier session). */15  startAt?: number;16  onProgress?(sent: number, total: number): void;17  signal?: AbortSignal;18}19const sleep = (ms: number, signal?: AbortSignal) => new Promise<void>((res, rej) => {20  const t = setTimeout(res, ms);21  signal?.addEventListener('abort', () => { clearTimeout(t); rej(new DOMException('aborted', 'AbortError')); }, { once: true });22});23async function call<T>(method: string, path: string, body: BodyInit | undefined, headers: Record<string, string>, signal?: AbortSignal): Promise<T> {24  const res = await fetch(`/api/v1${path}`, { method, body, signal, credentials: 'same-origin', headers: { 'X-SPD-CSRF': '1', ...headers } });25  const json = (res.headers.get('content-type') ?? '').includes('json') ? await res.json().catch(() => null) : null;26  if (!res.ok) throw new ApiError(res.status, json?.error ?? 'http_error', json?.message ?? `HTTP ${res.status}`, json ?? {});27  return json as T;28}29/**30 * Send every remaining byte. Each chunk carries its SHA-256; the server refuses a damaged chunk and it is sent again.31 * Network failures and 5xx answers are retried with back-off, re-asking the server for its offset first, so a32 * connection lost in the middle of a chunk never corrupts or duplicates data. Rejects with AbortError when cancelled33 * (pause = abort, then call again with `startAt` from the status endpoint) and with ApiError for refusals (4xx).34 */35export async function transfer(t: Transfer): Promise<void> {36  const total = t.file.size, step = t.chunkBytes ?? CHUNK_BYTES, headers = t.headers ?? {};37  let offset = t.startAt ?? 0, failures = 0;38  t.onProgress?.(offset, total);39  while (offset < total) {40    if (t.signal?.aborted) throw new DOMException('aborted', 'AbortError');41    const chunk = t.file.slice(offset, Math.min(total, offset + step));42    try {43      const buf = await chunk.arrayBuffer();44      const r = await call<{ received: number }>('PATCH', t.chunkPath(offset), buf, { ...headers, 'Content-Type': 'application/octet-stream', 'X-Chunk-SHA256': await sha256Hex(buf) }, t.signal);45      offset = r.received; failures = 0;46      t.onProgress?.(offset, total);47    } catch (e) {48      if ((e as Error).name === 'AbortError') throw e;49      if (e instanceof ApiError && (e.code === 'offset_mismatch' || e.code === 'chunk_corrupt') && typeof e.data.received === 'number') { offset = e.data.received; if (++failures > 8) throw e; continue; }50      if (e instanceof ApiError && e.status >= 400 && e.status < 500 && e.status !== 408 && e.status !== 429) throw e; // refused: quota, closed, expired…51      if (++failures > 8) throw e;52      await sleep(Math.min(30_000, 500 * 2 ** failures), t.signal);53      // the chunk may or may not have landed: ask before sending anything else54      try { offset = (await call<{ received: number }>('GET', t.statusPath, undefined, headers, t.signal)).received; } catch (s) { if ((s as Error).name === 'AbortError' || (s instanceof ApiError && s.status >= 400 && s.status < 500)) throw s; }55      t.onProgress?.(offset, total);56    }57  }58}59/** SHA-256 of a whole file, when it is small enough to hash in memory in the browser; undefined otherwise (the server still verifies every chunk). */60export async function wholeFileSha(file: Blob, limit = 256 * 1024 * 1024): Promise<string | undefined> {61  if (file.size > limit) return undefined;62  try { return await sha256Hex(await file.arrayBuffer()); } catch { return undefined; }63}64
Files main
Menu

Theme