type FetchLike = ( input: RequestInfo | URL, init?: RequestInit, ) => Promise; export type RecoveredReadyRecording = { ok: true; finalized: true; recoveredAfterFinalizeError: true; id: string; recordingId: string; status: "ready"; videoUrl: string; durationMs?: number; width?: number; height?: number; hasAudio?: boolean; hasCamera?: boolean; }; export type RecoveredProcessingRecording = { ok: true; finalized: false; recoveredAfterFinalizeError: true; id: string; recordingId: string; status: "processing"; verificationPending: true; videoUrl?: string; }; export type RecoveredFinalizeRecording = | RecoveredReadyRecording | RecoveredProcessingRecording; type ProbeResult = | { ready: true; result: RecoveredReadyRecording } | { ready: false; accepted: true; result: RecoveredProcessingRecording } | { ready: false; terminal: boolean; status?: string }; const DEFAULT_READY_RECOVERY_TIMEOUT_MS = 90_000; const DEFAULT_READY_RECOVERY_INTERVAL_MS = 3_000; const DEFAULT_READY_RECOVERY_FETCH_TIMEOUT_MS = 2_000; function sleep(ms: number, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { if (signal?.aborted) { reject( signal.reason instanceof Error ? signal.reason : new Error("Aborted"), ); return; } const timer = setTimeout(resolve, ms); signal?.addEventListener( "abort", () => { clearTimeout(timer); reject( signal.reason instanceof Error ? signal.reason : new Error("Aborted"), ); }, { once: true }, ); }); } export function isStoredButUnservableFinalizeError(value: unknown): boolean { const message = typeof value === "string" ? value : value instanceof Error ? value.message : value && typeof value === "object" && "message" in value ? String((value as { message?: unknown }).message ?? "") : ""; return /\bstored-but-unservable\b/i.test(message); } function absoluteUploadUrl(uploadUrl: string): URL { if (/^[a-z][a-z0-9+.-]*:/i.test(uploadUrl)) return new URL(uploadUrl); const origin = typeof globalThis.location?.origin === "string" ? globalThis.location.origin : "http://localhost"; return new URL(uploadUrl, origin); } function maybeRelativeUrl(url: URL, original: string): string { if (/^[a-z][a-z0-9+.-]*:/i.test(original)) return url.toString(); return `${url.pathname}${url.search}${url.hash}`; } export function publicRecordingStatusUrl( uploadUrl: string, recordingId: string, ): string { const url = absoluteUploadUrl(uploadUrl); const match = url.pathname.match(/^(.*)\/api\/uploads\/[^/]+\/chunk$/); const basePath = match?.[1] ?? ""; url.pathname = `${basePath}/api/public-recording`; url.search = ""; url.searchParams.set("id", recordingId); return maybeRelativeUrl(url, uploadUrl); } export function authenticatedRecordingStatusUrl( uploadUrl: string, recordingId: string, ): string { const url = absoluteUploadUrl(uploadUrl); const match = url.pathname.match(/^(.*)\/api\/uploads\/[^/]+\/chunk$/); const basePath = match?.[1] ?? ""; url.pathname = `${basePath}/api/uploads/${encodeURIComponent( recordingId, )}/status`; url.search = ""; return maybeRelativeUrl(url, uploadUrl); } function optionalNumber(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } function optionalBoolean(value: unknown): boolean | undefined { return typeof value === "boolean" ? value : undefined; } export function readyRecordingFromPublicPayload( payload: unknown, fallbackRecordingId: string, options: { acceptPendingVerification?: boolean } = {}, ): ProbeResult { const root = payload && typeof payload === "object" ? (payload as Record) : null; const recording = root?.recording && typeof root.recording === "object" ? (root.recording as Record) : null; const status = typeof recording?.status === "string" ? recording.status : undefined; if (!recording) return { ready: false, terminal: false }; if (status === "failed") return { ready: false, terminal: true, status }; if ( options.acceptPendingVerification && status === "processing" && recording.verificationPending === true ) { const id = typeof recording.id === "string" && recording.id ? recording.id : fallbackRecordingId; return { ready: false, accepted: true, result: { ok: true, finalized: false, recoveredAfterFinalizeError: true, id, recordingId: id, status: "processing", verificationPending: true, videoUrl: typeof recording.videoUrl === "string" ? recording.videoUrl : undefined, }, }; } const videoUrl = typeof recording.videoUrl === "string" ? recording.videoUrl : ""; if (status !== "ready" || !videoUrl) { return { ready: false, terminal: false, status }; } const id = typeof recording.id === "string" && recording.id ? recording.id : fallbackRecordingId; return { ready: true, result: { ok: true, finalized: true, recoveredAfterFinalizeError: true, id, recordingId: id, status: "ready", videoUrl, durationMs: optionalNumber(recording.durationMs), width: optionalNumber(recording.width), height: optionalNumber(recording.height), hasAudio: optionalBoolean(recording.hasAudio), hasCamera: optionalBoolean(recording.hasCamera), }, }; } async function fetchWithTimeout( fetchImpl: FetchLike, url: string, init: RequestInit, timeoutMs: number, ): Promise { if (timeoutMs <= 0 || typeof AbortController === "undefined") { return fetchImpl(url, init); } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { return await fetchImpl(url, { ...init, signal: controller.signal }); } finally { clearTimeout(timer); } } export async function waitForReadyRecordingAfterFinalizeError(args: { uploadUrl: string; recordingId: string; authToken?: string | null; preferAuthenticated?: boolean; fetchImpl?: FetchLike; sleepImpl?: (ms: number) => Promise; timeoutMs?: number; intervalMs?: number; fetchTimeoutMs?: number; acceptPendingVerification?: boolean; signal?: AbortSignal; }): Promise { const fetchImpl = args.fetchImpl ?? fetch; const sleepImpl = args.sleepImpl ?? ((ms) => sleep(ms, args.signal)); const timeoutMs = Math.max( 1, args.timeoutMs ?? DEFAULT_READY_RECOVERY_TIMEOUT_MS, ); const intervalMs = Math.max( 1, args.intervalMs ?? DEFAULT_READY_RECOVERY_INTERVAL_MS, ); const attempts = Math.max(1, Math.ceil(timeoutMs / intervalMs)); let url: string; let authenticatedUrl: string | null = null; try { url = publicRecordingStatusUrl(args.uploadUrl, args.recordingId); authenticatedUrl = args.authToken || args.preferAuthenticated ? authenticatedRecordingStatusUrl(args.uploadUrl, args.recordingId) : null; } catch { return null; } for (let attempt = 0; attempt < attempts; attempt += 1) { if (args.signal?.aborted) return null; const urls = authenticatedUrl ? [ { url: authenticatedUrl, authenticated: true }, { url, authenticated: false }, ] : [{ url, authenticated: false }]; for (const endpoint of urls) { const headers: Record = { Accept: "application/json", "X-Agent-Native-Frontend": "1", }; if (endpoint.authenticated && args.authToken) { headers.Authorization = `Bearer ${args.authToken}`; } try { const response = await fetchWithTimeout( fetchImpl, endpoint.url, { method: "GET", headers, credentials: "include", cache: "no-store", }, args.fetchTimeoutMs ?? DEFAULT_READY_RECOVERY_FETCH_TIMEOUT_MS, ); if (response.ok) { const payload = await response.json().catch(() => null); const probe = readyRecordingFromPublicPayload( payload, args.recordingId, { acceptPendingVerification: args.acceptPendingVerification, }, ); if (probe.ready) return probe.result; if ("accepted" in probe && probe.accepted) return probe.result; if ("terminal" in probe && probe.terminal) return null; break; } if ( response.status >= 400 && response.status < 500 && !endpoint.authenticated && !authenticatedUrl ) { return null; } } catch { // Try the public endpoint fallback below, then keep polling. } } if (attempt < attempts - 1) await sleepImpl(intervalMs); } return null; } export function waitForAcceptedRecordingAfterFinalizeError( args: Parameters[0], ): Promise { return waitForReadyRecordingAfterFinalizeError({ ...args, acceptPendingVerification: true, }); }