/** * TASK-0103 — `uploadFile`: the ONE upload service every path feeds. * * drag-drop (page canvas) · paste (Ctrl+V image) · file-block "Add file" * * Pipeline (spec Requirements §1): * 1. PLAN PRE-CHECK — size vs `effectiveMaxFileSize`; an over-limit file is * rejected here with a clear message ("50 MB per file on your plan") and * NO request is sent (E2E asserts zero session calls). * 2. SESSION — `POST /files/uploads` → signed PUT URL. * 3. PUT — XHR (NOT `fetch`: XHR is the only browser API with byte-level * `upload.progress` events) with progress (fraction + speed) + cancel. * A 403 (expired signature) auto-retries ONCE with a fresh session. * 4. COMPLETE — `PUT /files/uploads/:id/complete` → SCANNING + pipeline. * 5. POLL — `GET /files/:id` every 2 s, max 60 s, until the worker takes * the file to a terminal status (the block also self-polls on mount, so * a lost promise can never strand a skeleton forever). * * Cancel: before complete → abort the XHR + `POST …/cancel` (server deletes * the partial object + row) + reject `UploadError("CANCELLED")`. While * polling → stop + reject (the file keeps processing server-side; the caller * removes the block). * * Testable: the XHR factory + poll `get` are injectable, so the state machine * (retry-once, cancel, monotonic progress, poll timeout) is unit-tested * without a network (spec Tests §Unit). */ import { filesApi, type FileDetail, type FileStatus } from "./api"; /* ------------------------------------------------------------ types */ export type UploadPhase = | "precheck" | "uploading" | "completing" | "processing" | "ready" | "error" | "cancelled"; export interface UploadProgress { phase: UploadPhase; /** 0..1 (1 = byte transfer done; `processing` may still report 1). */ fraction: number; loaded: number; total: number; /** Bytes/sec over the last progress interval (0 before the first tick). */ speedBps: number; } export interface UploadResult { /** The final known file detail (READY, or the last SCANNING read on timeout). */ file: FileDetail; /** True when the 60 s poll window closed with the file still processing. */ stillProcessing: boolean; } export interface UploadOptions { pageId?: string | null; blockId?: string | null; /** TASK-0104: re-upload as a new version (accepted, versioned there). */ replaceFileId?: string; workspaceId?: string; /** The plan limit for THIS page session (fetched once; see effectiveMaxFileSize). */ maxFileSize: number; onProgress?: (p: UploadProgress) => void; /** * Fired once the FIRST session opens (before the PUT). The caller uses this * to insert the `file` block (by fileId) so the progress bar renders on it * immediately (spec §1: block appears → progress bar visible). */ onSession?: (fileId: string) => void; /** Abort a poll (the XHR is always aborted on cancel()). */ poll?: { intervalMs?: number; timeoutMs?: number; get?: (fileId: string, workspaceId?: string) => Promise }; } /** Terminal-ish error the upload can fail with (UI maps code → card state). */ export class UploadError extends Error { code: string; status?: number; /** The failed file row id, when a session existed (cancel/complete paths). */ fileId?: string; constructor(code: string, message: string, status?: number, fileId?: string) { super(message); this.name = "UploadError"; this.code = code; this.status = status; this.fileId = fileId; } } export interface UploadHandle { /** The session/file id (known after the first session opens). */ readonly fileId: string | null; promise: Promise; /** Cancel: aborts the PUT (or stops polling) + calls the cancel API pre-complete. */ cancel: () => void; } /* ------------------------------------------------------- XHR abstraction */ /** Minimal structural XHR (the unit test implements the same shape). */ export interface XhrLike { open(method: string, url: string): void; setRequestHeader(name: string, value: string): void; upload: { addEventListener(type: "progress", cb: (e: { loaded: number; total: number }) => void): void; }; send(body: Blob): void; abort(): void; status: number; responseText: string; onload: (() => void) | null; onerror: (() => void) | null; onabort: (() => void) | null; } export type XhrFactory = () => XhrLike; const defaultXhr: XhrFactory = () => new globalThis.XMLHttpRequest() as unknown as XhrLike; function parseApiError(status: number, text: string): UploadError { let code = "UPLOAD_FAILED"; let message = `Upload failed (HTTP ${status})`; try { const body = JSON.parse(text) as { error?: { code?: string; message?: string } }; if (body.error?.code) code = body.error.code; if (body.error?.message) message = body.error.message; } catch { // S3 bodies are XML (or empty) — keep the HTTP-level message. } return new UploadError(code, message, status); } /* ----------------------------------------------------------- the flow */ export function uploadFile(file: File, options: UploadOptions, xhrFactory: XhrFactory = defaultXhr): UploadHandle { let cancelled = false; let xhr: XhrLike | null = null; let pollTimer: ReturnType | null = null; let fileRowId: string | null = null; let completed = false; // true once the server confirmed the object let sessionSeen = false; // onSession fires once, on the first session const notify = (p: UploadProgress) => options.onProgress?.(p); const rejectCancelled = () => { const err = new UploadError("CANCELLED", "Upload cancelled.", undefined, fileRowId ?? undefined); err.name = "UploadError"; return err; }; const openSession = async (): Promise<{ uploadUrl: string; headers: Record; fileId: string; }> => { const session = await filesApi.openSession({ name: file.name, mimeType: file.type || "application/octet-stream", size: file.size, pageId: options.pageId ?? null, blockId: options.blockId ?? null, replaceFileId: options.replaceFileId, workspaceId: options.workspaceId, }); fileRowId = session.file.id; // The FIRST session only: the caller inserts the `file` block keyed by // this id so the progress bar renders on it as soon as bytes start. if (!sessionSeen) { sessionSeen = true; options.onSession?.(session.file.id); } return { uploadUrl: session.uploadUrl, headers: session.headers, fileId: session.file.id }; }; /** One signed PUT. Resolves on 2xx; rejects UploadError otherwise. */ const putOnce = (session: { uploadUrl: string; headers: Record }): Promise => new Promise((resolve, reject) => { const x = xhrFactory(); xhr = x; let lastLoaded = 0; let lastTime = 0; x.open("PUT", session.uploadUrl); for (const [name, value] of Object.entries(session.headers)) x.setRequestHeader(name, value); x.upload.addEventListener("progress", (e) => { if (cancelled) return; const now = Date.now(); const speedBps = lastTime > 0 && now > lastTime ? Math.max(0, e.loaded - lastLoaded) / ((now - lastTime) / 1000) : 0; lastLoaded = e.loaded; lastTime = now; // Monotonic by construction: XHR `loaded` never decreases and the // fraction is clamped to 1 (the unit test asserts this on a replay). notify({ phase: "uploading", loaded: e.loaded, total: Math.max(e.total, file.size), fraction: Math.min(e.loaded / Math.max(e.total, file.size), 1), speedBps, }); }); x.onload = () => { xhr = null; if (cancelled) return reject(rejectCancelled()); if (x.status >= 200 && x.status < 300) return resolve(); reject(parseApiError(x.status, x.responseText)); }; x.onerror = () => { xhr = null; reject(cancelled ? rejectCancelled() : new UploadError("NETWORK_ERROR", "Network error during upload.")); }; x.onabort = () => { xhr = null; reject(rejectCancelled()); }; x.send(file); }); const putWithRetry = async (): Promise => { try { const session = await openSession(); await putOnce(session); notify({ phase: "completing", fraction: 1, loaded: file.size, total: file.size, speedBps: 0 }); } catch (err) { // Spec §2: a 403 PUT (expired signature) auto-retries ONCE with a fresh // session. Anything else is terminal for this attempt. if (err instanceof UploadError && err.code !== "CANCELLED" && err.status === 403) { const retry = await openSession(); await putOnce(retry); notify({ phase: "completing", fraction: 1, loaded: file.size, total: file.size, speedBps: 0 }); return; } throw err; } }; /** Poll `GET /files/:id` every interval until terminal or the window closes. */ const poll = async (fileId: string): Promise => { const intervalMs = options.poll?.intervalMs ?? 2000; const timeoutMs = options.poll?.timeoutMs ?? 60_000; const get = options.poll?.get ?? ((id: string, ws?: string) => filesApi.get(id, { workspaceId: ws })); const started = Date.now(); let last: FileDetail | null = null; for (;;) { if (cancelled) throw rejectCancelled(); last = await get(fileId, options.workspaceId); if (last.status !== "UPLOADING" && last.status !== "SCANNING") return last; if (Date.now() - started >= timeoutMs) return last; await new Promise((resolve) => { pollTimer = setTimeout(resolve, intervalMs); }); if (cancelled && pollTimer) clearTimeout(pollTimer); } }; const promise = (async (): Promise => { // ── 1. pre-check: no request is sent for an over-limit file ───────── if (file.size > options.maxFileSize) { const limit = Math.floor(options.maxFileSize / (1024 * 1024)); throw new UploadError("FILE_TOO_LARGE", `${file.name} is larger than the ${limit} MB per-file limit on your plan.`); } // ── 2–4. session → PUT (retry once on 403) → complete ──────────────── await putWithRetry(); completed = true; const done = await filesApi.complete(fileRowId!, { workspaceId: options.workspaceId }); notify({ phase: "processing", fraction: 1, loaded: file.size, total: file.size, speedBps: 0 }); // ── 5. poll the pipeline (2 s / 60 s) ──────────────────────────────── const detail = await poll(done.id); if (detail.status === "READY") { notify({ phase: "ready", fraction: 1, loaded: file.size, total: file.size, speedBps: 0 }); return { file: detail, stillProcessing: false }; } if (detail.status === "VIRUS_DETECTED" || detail.status === "FAILED") { notify({ phase: "error", fraction: 1, loaded: file.size, total: file.size, speedBps: 0 }); throw new UploadError(detail.status, detail.status === "VIRUS_DETECTED" ? "Blocked by virus scan." : "Processing failed.", undefined, detail.id); } // still SCANNING after the window — the block keeps its own refetch. notify({ phase: "processing", fraction: 1, loaded: file.size, total: file.size, speedBps: 0 }); return { file: detail, stillProcessing: true }; })(); // Never let a rejection escape the handle unhandled if the caller dropped it. void promise.catch(() => undefined); const cancel = () => { if (cancelled) return; cancelled = true; if (pollTimer) clearTimeout(pollTimer); if (xhr) xhr.abort(); // Server-side cleanup: only once a session existed and complete has NOT // run (after complete the row belongs to the pipeline; deleting it is // TASK-0104's job, not the upload's). if (fileRowId && !completed) { void filesApi.cancel(fileRowId, { workspaceId: options.workspaceId }).catch(() => undefined); } }; return { get fileId() { return fileRowId; }, promise, cancel, }; } /** * Status the block renders from — the FileBlock self-polls with this on mount * (react-query `refetchInterval`) while the file is not terminal, so an upload * whose promise was dropped (tab closed) still resolves visually. */ export const isTerminalStatus = (status: FileStatus): boolean => status === "READY" || status === "VIRUS_DETECTED" || status === "FAILED";