/** * Main-thread uploader for screen-recording chunks. Separate from the * event Uploader (which runs in the worker) because chunks are large * binary blobs that we don't want to structured-clone across the * worker boundary on every timeslice. * * Architecture: * - Single in-flight POST at a time per chunk number (chunks upload * in order). If a chunk's POST is in flight when the next chunk * arrives, the new one queues. * - On 5xx / network error: exponential backoff retry up to maxRetries. * - On 4xx: drop and emit chunk-dropped (the chunk isn't valid; * no point retrying). * - Byte cap on the queue: when queued bytes + the new chunk would * exceed maxQueueBytes, drop the oldest queued chunk (FIFO) and * emit chunk-dropped("overflow") until we're under budget. * - On stop(): abort the in-flight request and clear the queue. */ import { RequestDeadlineError, mediaRequestTimeoutMs, withRequestDeadline, } from "../internal/request-deadline.js"; export interface ChunkUploaderConfig { sessionId: string; /** * The events ingest URL (eg. `http://localhost:3001/ingest`). The * uploader derives the origin from this and joins it with * `pathTemplate`. */ ingestUrl: string; /** * URL path template under the ingest origin. Supports `{sessionId}` * and `{n}` placeholders (n is the chunk number). Default: * `/recordings/sessions/{sessionId}/chunks/{n}` — used for screen * share. Webcam photos use a different path; the WebcamObserver * passes its own template. */ pathTemplate?: string; /** Public app key (`pk_live_…`) for org routing. */ appId?: string; /** * Per-device fingerprint stamped by the SDK at construction * time. Sent on every chunk upload via `x-fingerprint-id` so * the server can attribute chunks to the device that * uploaded them (per-device counters live further down the * road; today this is just attribution). */ fingerprintId?: string; /** * Max bytes of queued + in-flight chunks held in memory. When * exceeded, the oldest queued chunk is dropped. Default: 50 MB. */ maxQueueBytes?: number; /** Per-chunk retry budget for 5xx / network errors. Default: 5. */ maxRetries?: number; /** Called when a chunk POST returns 2xx. */ onUploaded?: (chunkNumber: number, byteSize: number) => void; /** * Called when a chunk is dropped without being uploaded. `reason`: * - "max-retries": ran out of retries (network or 5xx). * - "rejected": server returned 4xx. * - "overflow": queue hit maxQueueBytes; oldest evicted. * - "aborted": stop() called while this chunk was queued. */ onDropped?: (chunkNumber: number, reason: ChunkDropReason, detail?: ChunkDropDetail) => void; } export type ChunkDropReason = "max-retries" | "rejected" | "overflow" | "aborted"; export interface ChunkDropDetail { httpStatus?: number; serverError?: string; statusText?: string; retryCount?: number; message?: string; timedOut?: boolean; timeoutMs?: number; } interface QueuedChunk { chunkNumber: number; blob: Blob; attemptCount: number; extraHeaders?: Record; } interface ResolvedConfig { sessionId: string; baseUrl: string; pathTemplate: string; appId: string | undefined; fingerprintId: string | undefined; maxQueueBytes: number; maxRetries: number; onUploaded: ((chunkNumber: number, byteSize: number) => void) | undefined; onDropped: | ((chunkNumber: number, reason: ChunkDropReason, detail?: ChunkDropDetail) => void) | undefined; } export class ChunkUploader { private readonly config: ResolvedConfig; private readonly queue: QueuedChunk[] = []; private queuedBytes = 0; private inFlight = false; private currentAbort: AbortController | null = null; private stopped = false; constructor(config: ChunkUploaderConfig) { this.config = resolveConfig(config); } /** * Enqueue a chunk for upload. Returns immediately. If the queue is * over budget the oldest queued chunks are dropped to make room. * * `extraHeaders` are merged into the POST request alongside the * mandatory content-type and x-app-id headers. Used by callers that * want to ship per-upload metadata without round-tripping through a * database. */ upload(chunkNumber: number, blob: Blob, extraHeaders?: Record): void { if (this.stopped) return; const bytes = blob.size; // Evict from the front of the queue while adding this would exceed // the cap. The currently-in-flight chunk isn't counted because we // can't safely abort it half-way without leaving the server with a // partial upload — accept slight over-cap during one chunk's flight. while (this.queue.length > 0 && this.queuedBytes + bytes > this.config.maxQueueBytes) { const dropped = this.queue.shift()!; this.queuedBytes -= dropped.blob.size; this.config.onDropped?.(dropped.chunkNumber, "overflow", { message: "queued media exceeded maxQueueBytes", }); } this.queue.push({ chunkNumber, blob, attemptCount: 0, ...(extraHeaders ? { extraHeaders } : {}), }); this.queuedBytes += bytes; void this.processNext(); } /** * Abort the in-flight request, clear the queue, and emit a dropped * event for everything we couldn't send. Idempotent. */ stop(): void { if (this.stopped) return; this.stopped = true; this.currentAbort?.abort(); this.currentAbort = null; for (const q of this.queue) { this.config.onDropped?.(q.chunkNumber, "aborted", { message: "uploader stopped before queued chunk uploaded", }); } this.queue.length = 0; this.queuedBytes = 0; } /** * Wait for the queue to drain — every queued chunk gets uploaded * (or explicitly dropped via the existing retry/4xx/overflow paths) * and no upload is in flight. Does NOT cancel anything; the * complement to `stop()`. Use this on graceful session end so the * tail chunk has a chance to reach the server before we tear down. * * Resolves when `inFlight` is false and `queue.length` is 0. * Rejects if `timeoutMs` elapses first — the caller can then choose * to call `stop()` to give up, accepting that whatever's still * queued will be dropped with reason "aborted". * * Default timeout: 30s. A flaky network can stretch a single chunk * through several backoff attempts (1s + 2s + 4s + 8s + 16s = 31s * worst-case for 5 retries), so the default sits just above that — * one full retry budget before the caller bails. */ async drain(timeoutMs = 30_000): Promise { if (this.stopped) return; if (!this.inFlight && this.queue.length === 0) return; const start = Date.now(); // Polling rather than event-driven because send() doesn't expose // hooks. Cheap — every 50ms while we wait, capped by timeoutMs. while (true) { if (this.stopped) return; if (!this.inFlight && this.queue.length === 0) return; if (Date.now() - start >= timeoutMs) { this.currentAbort?.abort(); throw new RequestDeadlineError("media-chunk-drain", timeoutMs); } await new Promise((resolve) => setTimeout(resolve, 50)); } } /** * Internal worker loop: pull the next chunk and POST it. Re-runs * itself after each completion until the queue is empty. */ private async processNext(): Promise { if (this.inFlight || this.stopped) return; const next = this.queue.shift(); if (!next) return; this.queuedBytes -= next.blob.size; this.inFlight = true; try { await this.send(next); } finally { this.inFlight = false; // Tail-call to keep the queue flowing without recursion depth. if (!this.stopped) void this.processNext(); } } private async send(chunk: QueuedChunk): Promise { const path = this.config.pathTemplate .replace("{sessionId}", encodeURIComponent(this.config.sessionId)) .replace("{n}", String(chunk.chunkNumber)); const url = `${this.config.baseUrl}${path}`; const controller = new AbortController(); this.currentAbort = controller; let lastTimeout: RequestDeadlineError | undefined; while (chunk.attemptCount <= this.config.maxRetries) { if (this.stopped) { this.config.onDropped?.(chunk.chunkNumber, "aborted", { retryCount: chunk.attemptCount, message: "uploader stopped while chunk was active", }); return; } try { const headers: Record = { "content-type": chunk.blob.type || "application/octet-stream", }; if (this.config.appId) headers["x-app-id"] = this.config.appId; if (this.config.fingerprintId) { headers["x-fingerprint-id"] = this.config.fingerprintId; } if (chunk.extraHeaders) { for (const [k, v] of Object.entries(chunk.extraHeaders)) { headers[k] = v; } } let serverError: string | undefined; const response = await withRequestDeadline( "media-chunk-upload", mediaRequestTimeoutMs(chunk.blob.size), async (signal) => { const result = await fetch(url, { method: "POST", headers, body: chunk.blob, signal, credentials: "omit", }); if (result.status >= 400 && result.status < 500) { serverError = await responseError(result); } return result; }, controller.signal, ); lastTimeout = undefined; if (response.ok) { this.config.onUploaded?.(chunk.chunkNumber, chunk.blob.size); this.currentAbort = null; return; } if (response.status >= 400 && response.status < 500) { // Validation / auth error — retrying won't help. this.config.onDropped?.(chunk.chunkNumber, "rejected", { httpStatus: response.status, statusText: response.statusText, serverError, retryCount: chunk.attemptCount, }); this.currentAbort = null; return; } // 5xx: fall through to retry. chunk.attemptCount += 1; } catch (err) { if (err instanceof RequestDeadlineError) { lastTimeout = err; chunk.attemptCount += 1; } else if ((err as Error).name === "AbortError") { this.config.onDropped?.(chunk.chunkNumber, "aborted", { retryCount: chunk.attemptCount, message: "request aborted", }); this.currentAbort = null; return; } else { chunk.attemptCount += 1; } } if (chunk.attemptCount > this.config.maxRetries) break; // Exponential backoff: 1s, 2s, 4s, 8s, capped at 16s. const delayMs = Math.min(1_000 * 2 ** (chunk.attemptCount - 1), 16_000); await sleep(delayMs, controller.signal); } this.config.onDropped?.(chunk.chunkNumber, "max-retries", { retryCount: chunk.attemptCount, message: lastTimeout ? "chunk upload timed out and exhausted retry budget" : "chunk upload exhausted retry budget", ...(lastTimeout ? { timedOut: true, timeoutMs: lastTimeout.timeoutMs } : {}), }); this.currentAbort = null; } } async function responseError(response: Response): Promise { try { const text = await response.clone().text(); if (!text) return undefined; try { const json = JSON.parse(text) as unknown; if (json && typeof json === "object" && "error" in json) { const error = (json as { error?: unknown }).error; return typeof error === "string" ? error.slice(0, 120) : undefined; } } catch { return text.slice(0, 120); } return text.slice(0, 120); } catch { return undefined; } } function resolveConfig(raw: ChunkUploaderConfig): ResolvedConfig { return { sessionId: raw.sessionId, baseUrl: stripTrailingPath(raw.ingestUrl), pathTemplate: raw.pathTemplate ?? "/recordings/sessions/{sessionId}/chunks/{n}", appId: raw.appId, fingerprintId: raw.fingerprintId, maxQueueBytes: raw.maxQueueBytes ?? 50 * 1024 * 1024, maxRetries: raw.maxRetries ?? 5, onUploaded: raw.onUploaded, onDropped: raw.onDropped, }; } /** * Take an events ingest URL like `https://api.example.com/ingest` and * return the origin `https://api.example.com` so we can mount the * recordings path on the same server. If the customer needs a * different host for recordings later, we can add an explicit option. */ function stripTrailingPath(url: string): string { try { const u = new URL(url); return `${u.protocol}//${u.host}`; } catch { // If the URL doesn't parse, just trim the last segment and hope. return url.replace(/\/[^/]*$/, ""); } } function sleep(ms: number, signal: AbortSignal): Promise { return new Promise((resolve) => { if (signal.aborted) { resolve(); return; } const timer = setTimeout(() => { signal.removeEventListener("abort", onAbort); resolve(); }, ms); const onAbort = (): void => { clearTimeout(timer); resolve(); }; signal.addEventListener("abort", onAbort, { once: true }); }); }