import { RequestDeadlineError, mediaRequestTimeoutMs, withRequestDeadline, } from "../internal/request-deadline.js"; import type { ChunkDropDetail, ChunkDropReason } from "./chunk-uploader.js"; export interface DurableSegment { sequence: number; blob: Blob; timingHeaders?: Record; } export interface SegmentDurability { list(): Promise; listSequences?(): Promise; get?(sequence: number): Promise; put(record: DurableSegment): Promise; remove(sequence: number): Promise; nextSequence?(preferred: number): Promise; } export interface SegmentDurabilityPutResult { evicted: number[]; } export interface SegmentUploaderConfig { sessionId: string; kind: "screen" | "webcam" | "replay"; ingestUrl: string; appId?: string; fingerprintId?: string; maxRetries?: number; maxConcurrency?: number; retryBaseMs?: number; durability: SegmentDurability; fetchImpl?: typeof fetch; onUploaded?: (sequence: number, byteSize: number) => void; onDropped?: (sequence: number, reason: ChunkDropReason, detail?: ChunkDropDetail) => void; } type ResolvedConfig = Required< Pick< SegmentUploaderConfig, | "sessionId" | "kind" | "ingestUrl" | "maxRetries" | "maxConcurrency" | "retryBaseMs" | "durability" | "fetchImpl" > > & Pick; class SegmentHttpError extends Error { constructor( readonly status: number, readonly detail: string, ) { super(`segment upload returned HTTP ${status}: ${detail}`); } } /** * Direct-to-S3 uploader for independently durable MediaRecorder segments. * Capture never waits on this class: every blob is persisted first, then a * bounded worker uploads it. Failed items re-enter at the tail after backoff, * allowing later evidence to reach S3 instead of being held behind one gap. */ export class SegmentUploader { private readonly config: ResolvedConfig; private readonly baseUrl: string; private readonly queue: DurableSegment[] = []; private readonly queued = new Set(); private readonly activeControllers = new Map(); private readonly attempts = new Map(); private readonly retryTimers = new Set>(); private active = 0; private stopped = false; private persistChain: Promise; private readonly restorePromise: Promise; constructor(config: SegmentUploaderConfig) { this.config = { sessionId: config.sessionId, kind: config.kind, ingestUrl: config.ingestUrl, maxRetries: config.maxRetries ?? 5, maxConcurrency: Math.max(1, config.maxConcurrency ?? 1), retryBaseMs: Math.max(1, config.retryBaseMs ?? 1_000), durability: config.durability, fetchImpl: config.fetchImpl ?? fetch.bind(globalThis), appId: config.appId, fingerprintId: config.fingerprintId, onUploaded: config.onUploaded, onDropped: config.onDropped, }; this.baseUrl = originOf(config.ingestUrl); this.restorePromise = this.restore(); this.persistChain = this.restorePromise; } upload(sequence: number, blob: Blob, timingHeaders?: Record): void { if (this.stopped || blob.size === 0) return; this.persistChain = this.persistChain .then(async () => { if (this.stopped) return; const durableSequence = this.config.durability.nextSequence ? await this.config.durability.nextSequence(sequence) : sequence; const record: DurableSegment = { sequence: durableSequence, blob, ...(timingHeaders ? { timingHeaders } : {}), }; const result = await this.config.durability.put(record); for (const evictedSequence of result?.evicted ?? []) { this.queued.delete(evictedSequence); const queuedIndex = this.queue.findIndex((queued) => queued.sequence === evictedSequence); if (queuedIndex >= 0) this.queue.splice(queuedIndex, 1); this.config.onDropped?.(evictedSequence, "overflow", { message: "durable segment storage exceeded its byte limit", }); } this.enqueue(this.queueRecord(record)); }) .catch((error) => { this.config.onDropped?.(sequence, "rejected", { message: error instanceof Error ? error.message : String(error), }); }); } async drain(timeoutMs = 60_000): Promise { if (this.stopped) return; await this.restorePromise.catch(() => undefined); await this.persistChain.catch(() => undefined); const startedAt = Date.now(); while (this.active > 0 || this.queue.length > 0 || this.retryTimers.size > 0) { if (this.stopped) return; if (Date.now() - startedAt >= timeoutMs) { throw new RequestDeadlineError("segment-upload-drain", timeoutMs); } await sleep(10); } } stop(): void { if (this.stopped) return; this.stopped = true; for (const controller of this.activeControllers.values()) { controller.abort(); } this.activeControllers.clear(); for (const timer of this.retryTimers) clearTimeout(timer); this.retryTimers.clear(); this.queue.length = 0; this.queued.clear(); // Intentionally keep every unacknowledged record in IndexedDB. A later // instance restores it; stop is teardown, never evidence deletion. } private async restore(): Promise { try { const records = this.config.durability.listSequences ? (await this.config.durability.listSequences()).map((sequence) => ({ sequence, blob: new Blob([]), })) : await this.config.durability.list(); records.sort((a, b) => a.sequence - b.sequence); for (const record of records) this.enqueue(record); } catch { // The durability implementation reports its own degradation. Live // capture can still enqueue records during this instance. } } private enqueue(record: DurableSegment): void { if (this.stopped || this.queued.has(record.sequence)) return; this.queued.add(record.sequence); this.queue.push(record); this.pump(); } private pump(): void { while (!this.stopped && this.active < this.config.maxConcurrency && this.queue.length > 0) { const record = this.queue.shift()!; this.queued.delete(record.sequence); this.active += 1; void this.sendOnce(record).finally(() => { this.active -= 1; this.pump(); }); } } private async sendOnce(record: DurableSegment): Promise { const attempt = (this.attempts.get(record.sequence) ?? 0) + 1; this.attempts.set(record.sequence, attempt); const controller = new AbortController(); this.activeControllers.set(record.sequence, controller); try { if (this.config.durability.get) { const stored = await this.config.durability.get(record.sequence); if (!stored) { this.attempts.delete(record.sequence); return; } record = stored; } const reservation = await this.postJson<{ attemptId: string | null; chunkNumber: number; storagePath: string; uploadUrl: string | null; uploaded: boolean; }>( "/uploads/segments/reserve", { sessionId: this.config.sessionId, kind: this.config.kind, chunkNumber: record.sequence, fingerprintId: this.config.fingerprintId ?? null, mimeType: record.blob.type || "video/webm", byteSize: record.blob.size, replayRunId: headerString(record.timingHeaders, "x-replay-run-id"), replayPhase: headerString(record.timingHeaders, "x-replay-phase"), }, controller.signal, ); if (!reservation.uploaded) { if (!reservation.uploadUrl) { throw new SegmentHttpError(502, "reservation omitted uploadUrl"); } const put = await withRequestDeadline( "recording-segment-put", mediaRequestTimeoutMs(record.blob.size), (signal) => this.config.fetchImpl(reservation.uploadUrl!, { method: "PUT", body: record.blob, credentials: "omit", signal, }), controller.signal, ); if (!put.ok) { throw new SegmentHttpError(put.status, put.statusText); } const sha256 = await digestBlob(record.blob); await this.postJson( "/uploads/segments/ack", { sessionId: this.config.sessionId, attemptId: reservation.attemptId, kind: this.config.kind, chunkNumber: record.sequence, fingerprintId: this.config.fingerprintId ?? null, mimeType: record.blob.type || "video/webm", byteSize: record.blob.size, etag: put.headers.get("etag"), sha256, recordingStartedAt: headerNumber(record.timingHeaders, "x-recording-started-at"), capturedStartAt: headerNumber(record.timingHeaders, "x-captured-start-at"), capturedEndAt: headerNumber(record.timingHeaders, "x-captured-end-at"), eventCount: headerNumber(record.timingHeaders, "x-replay-event-count"), uncompressedBytes: headerNumber(record.timingHeaders, "x-replay-uncompressed-bytes"), replayRunId: headerString(record.timingHeaders, "x-replay-run-id"), replayPhase: headerString(record.timingHeaders, "x-replay-phase"), }, controller.signal, ); } await this.config.durability.remove(record.sequence); this.attempts.delete(record.sequence); this.config.onUploaded?.(record.sequence, record.blob.size); } catch (error) { if (this.stopped) return; const retryable = isRetryable(error); if (retryable && attempt <= this.config.maxRetries) { const delay = this.config.retryBaseMs * 2 ** Math.max(0, attempt - 1); const timer = setTimeout(() => { this.retryTimers.delete(timer); this.enqueue(this.queueRecord(record)); }, delay); this.retryTimers.add(timer); return; } this.attempts.delete(record.sequence); this.config.onDropped?.( record.sequence, retryable ? "max-retries" : "rejected", errorDetail(error, attempt), ); // Durable record stays in place for refresh/manual recovery. } finally { this.activeControllers.delete(record.sequence); } } private queueRecord(record: DurableSegment): DurableSegment { return this.config.durability.get ? { sequence: record.sequence, blob: new Blob([]) } : record; } private async postJson( path: string, body: Record, signal: AbortSignal, ): Promise { const response = await withRequestDeadline( "recording-segment-control", 12_000, (deadlineSignal) => this.config.fetchImpl(`${this.baseUrl}${path}`, { method: "POST", headers: { "content-type": "application/json", ...(this.config.appId ? { "x-app-id": this.config.appId } : {}), }, body: JSON.stringify(body), credentials: "omit", signal: deadlineSignal, }), signal, ); if (!response.ok) { throw new SegmentHttpError( response.status, await response.text().catch(() => response.statusText), ); } return (await response.json()) as T; } } function originOf(url: string): string { try { return new URL(url).origin; } catch { return url.replace(/\/ingest\/?$/, ""); } } function headerNumber( headers: Record | undefined, name: string, ): number | undefined { const raw = headers?.[name]; if (raw === undefined) return undefined; const parsed = Number(raw); return Number.isFinite(parsed) ? parsed : undefined; } function headerString( headers: Record | undefined, name: string, ): string | undefined { const value = headers?.[name]?.trim(); return value || undefined; } async function digestBlob(blob: Blob): Promise { try { if (!globalThis.crypto?.subtle) return null; const digest = await globalThis.crypto.subtle.digest("SHA-256", await blob.arrayBuffer()); return [...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join(""); } catch { return null; } } function isRetryable(error: unknown): boolean { if (error instanceof SegmentHttpError) { return ( error.status === 403 || error.status === 408 || error.status === 429 || error.status >= 500 ); } return true; } function errorDetail(error: unknown, retryCount: number): ChunkDropDetail { if (error instanceof SegmentHttpError) { return { httpStatus: error.status, serverError: error.detail, retryCount, message: error.message, }; } return { retryCount, message: error instanceof Error ? error.message : String(error), }; } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); }