import type { CandidateIdentity, IngestBatchRequest, IngestBatchResponse, SessionEvent, } from "@a4anthony/proctorkit-types"; import type { EventQueue } from "../queue/event-queue.js"; import { DeliveryReadinessError } from "../delivery-readiness.js"; import { newId } from "../internal/ids.js"; import { EVENT_REQUEST_TIMEOUT_MS, RequestDeadlineError, withRequestDeadline, } from "../internal/request-deadline.js"; export interface UploaderOptions { queue: EventQueue; ingestUrl: string; sessionId: string; appId?: string; candidate?: () => CandidateIdentity | undefined; batchSize?: number; batchIntervalMs?: number; maxRetries?: number; fetchImpl?: typeof fetch; newBatchId?: () => string; } export interface UploaderEvents { onUploaded?: (batchId: string, count: number, accepted: string[], rejected: string[]) => void; onUploadFailed?: (batchId: string, attempt: number, error: Error) => void; } interface SendBatchResult { progress: boolean; accepted: string[]; rejected: string[]; error?: Error; status?: number; timedOut?: boolean; invalidResponse?: boolean; } /** * Drains an {@link EventQueue} to an HTTP ingest endpoint. * * Each batch carries a stable `batchId` in both the body and the * `idempotency-key` header so the server can dedupe retries. Events * are acked locally only after the server confirms them in `accepted` * (or rejects them as permanently bad in `rejected`). * * Retries 5xx, 408 and 429 with exponential backoff and jitter, up to * `maxRetries`. Request-level 4xx responses leave the queue intact; only * event IDs explicitly returned in `accepted` or `rejected` are acknowledged. * Aborts in-flight requests on `stop()`. * * `flush({ keepalive: true })` is the one-shot path used on `pagehide`: * it uses `fetch keepalive` so the request survives navigation, and * does not retry — the page is about to be gone. */ export class Uploader { private readonly opts: Required< Omit > & { fetchImpl: typeof fetch; newBatchId: () => string; appId: string | null; candidate: () => CandidateIdentity | undefined; }; private events: UploaderEvents = {}; private timer: ReturnType | null = null; private draining = false; private stopped = false; private controller: AbortController | null = null; constructor(options: UploaderOptions) { this.opts = { queue: options.queue, ingestUrl: options.ingestUrl, sessionId: options.sessionId, batchSize: options.batchSize ?? 50, batchIntervalMs: options.batchIntervalMs ?? 2_000, maxRetries: options.maxRetries ?? 5, fetchImpl: options.fetchImpl ?? fetch.bind(globalThis), newBatchId: options.newBatchId ?? (() => newId("batch")), appId: options.appId ?? null, candidate: options.candidate ?? (() => undefined), }; } /** Replaces the lifecycle handlers. The Uploader keeps a single set; pass `{}` to clear. */ on(events: UploaderEvents): void { this.events = events; } /** Starts the periodic drain timer. No-op if already started or stopped. */ start(): void { if (this.timer || this.stopped) return; this.timer = setInterval(() => { void this.drain(); }, this.opts.batchIntervalMs); } /** * Drains the queue in batches until empty, no progress was made, or * a send fails. Re-entrant calls are guarded — only one drain runs * at a time even under concurrent invocations. */ async drain(): Promise { if (this.draining || this.stopped) return; this.draining = true; try { while (!this.stopped) { const events = await this.opts.queue.peek(this.opts.batchSize); if (events.length === 0) return; const sizeBefore = await this.opts.queue.size(); const result = await this.sendBatch(events, { keepalive: false }); if (!result.progress) return; const sizeAfter = await this.opts.queue.size(); if (sizeAfter >= sizeBefore) return; } } finally { this.draining = false; } } /** * Sends one batch. With `keepalive: true` the request survives page * navigation but is capped by the browser to ~64KB total in-flight; * we therefore send at most one batch and do not retry on failure. */ async flush(options: { keepalive: boolean }): Promise { const events = await this.opts.queue.peek(this.opts.batchSize); if (events.length === 0) return; await this.sendBatch(events, { keepalive: options.keepalive, noRetry: options.keepalive }); } /** * Prove that the real ingest route accepts an event before SDK readiness is * announced. This uses the same request builder, auth headers, response * validation, and retry path as normal queue delivery. It is intentionally * not configurable by SDK consumers. */ async verifyDelivery(event: SessionEvent, timeoutMs: number): Promise { const result = await this.sendBatch([event], { keepalive: false, ackQueue: false, maxAttempts: 2, timeoutMs, }); if (result.accepted.includes(event.id)) return; if (result.rejected.includes(event.id)) { throw new DeliveryReadinessError( "ingest-rejected", "The evidence ingest route rejected the delivery canary.", { status: result.status }, ); } if (result.timedOut) { throw new DeliveryReadinessError( "ingest-timeout", "Timed out while verifying evidence delivery.", { cause: result.error }, ); } if (result.status === 401 || result.status === 403) { throw new DeliveryReadinessError( "ingest-not-authorized", "The evidence ingest route did not authorize this application and origin.", { status: result.status, cause: result.error }, ); } if (result.status !== undefined && result.status >= 400 && result.status < 500) { throw new DeliveryReadinessError( "ingest-rejected", "The evidence ingest route rejected the delivery canary.", { status: result.status, cause: result.error }, ); } if (result.invalidResponse) { throw new DeliveryReadinessError( "ingest-invalid-response", "The evidence ingest route returned an invalid acknowledgement.", { cause: result.error }, ); } throw new DeliveryReadinessError( "ingest-unreachable", "The evidence ingest route could not be reached.", { cause: result.error, status: result.status }, ); } /** Stops the drain timer and aborts any in-flight non-keepalive request. */ async stop(): Promise { this.stopped = true; if (this.timer) { clearInterval(this.timer); this.timer = null; } this.controller?.abort(); } private async sendBatch( events: SessionEvent[], { keepalive, noRetry = false, ackQueue = true, maxAttempts = this.opts.maxRetries, timeoutMs, }: { keepalive: boolean; noRetry?: boolean; ackQueue?: boolean; maxAttempts?: number; timeoutMs?: number; }, ): Promise { const batchId = this.opts.newBatchId(); const candidate = this.opts.candidate(); const body: IngestBatchRequest = { batchId, sessionId: this.opts.sessionId, events, ...(candidate ? { candidate } : {}), }; const controller = keepalive ? null : new AbortController(); this.controller = controller; const signal = controller?.signal; let timedOut = false; const deadlineAt = timeoutMs === undefined ? undefined : Date.now() + timeoutMs; const timeout = controller && timeoutMs !== undefined ? setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs) : null; let lastError: Error | undefined; let lastStatus: number | undefined; let invalidResponse = false; try { for (let attempt = 1; attempt <= maxAttempts; attempt++) { if (this.stopped && !keepalive) { return { progress: false, accepted: [], rejected: [] }; } let res: Response; let parsedResponse: IngestBatchResponse | undefined; let parseError: Error | undefined; try { const attemptTimeoutMs = deadlineAt === undefined ? EVENT_REQUEST_TIMEOUT_MS : Math.max(1, Math.min(EVENT_REQUEST_TIMEOUT_MS, deadlineAt - Date.now())); const response = keepalive ? await this.opts.fetchImpl(this.opts.ingestUrl, { method: "POST", headers: { "content-type": "application/json", "idempotency-key": batchId, ...(this.opts.appId ? { "x-app-id": this.opts.appId } : {}), }, body: JSON.stringify(body), keepalive, }) : await withRequestDeadline( "event-ingest", attemptTimeoutMs, async (requestSignal) => { const request = await this.opts.fetchImpl(this.opts.ingestUrl, { method: "POST", headers: { "content-type": "application/json", "idempotency-key": batchId, ...(this.opts.appId ? { "x-app-id": this.opts.appId } : {}), }, body: JSON.stringify(body), keepalive: false, signal: requestSignal, }); if (request.ok) { try { parsedResponse = (await request.json()) as IngestBatchResponse; } catch (error) { parseError = error instanceof Error ? error : new Error(String(error)); } } return request; }, signal, ); res = response; } catch (err) { const error = err instanceof Error ? err : new Error(String(err)); lastError = error; if (error instanceof RequestDeadlineError) timedOut = true; if (error.name === "AbortError") break; this.events.onUploadFailed?.(batchId, attempt, error); if (noRetry || attempt === maxAttempts) break; await sleep(backoffMs(attempt), signal); continue; } lastStatus = res.status; timedOut = false; if (res.ok) { let parsed: IngestBatchResponse; if (keepalive) { try { parsed = (await res.json()) as IngestBatchResponse; } catch (error) { parseError = error instanceof Error ? error : new Error(String(error)); parsed = { batchId, accepted: [], rejected: [] }; } } else { parsed = parsedResponse ?? { batchId, accepted: [], rejected: [] }; } if (parseError) { invalidResponse = true; lastError = parseError; this.events.onUploadFailed?.(batchId, attempt, lastError); if (noRetry || attempt === maxAttempts) break; await sleep(backoffMs(attempt), signal); continue; } const accepted = Array.isArray(parsed.accepted) ? parsed.accepted : []; const rejected = Array.isArray(parsed.rejected) ? parsed.rejected : []; if (accepted.length === 0 && rejected.length === 0) { invalidResponse = true; lastError = new Error("malformed response: empty accepted and rejected"); this.events.onUploadFailed?.(batchId, attempt, lastError); } else { const toAck = [...accepted, ...rejected]; if (ackQueue) await this.opts.queue.ack(toAck); this.events.onUploaded?.(batchId, accepted.length, accepted, rejected); return { progress: toAck.length > 0, accepted, rejected, status: res.status, }; } } else if ( res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429 ) { lastError = new Error(`non-retryable ${res.status}`); this.events.onUploadFailed?.(batchId, attempt, lastError); // Request-level auth, origin, and validation failures are not event // rejections. Keep the queue intact so a configuration fix can // recover the evidence instead of silently deleting it. return { progress: false, accepted: [], rejected: [], error: lastError, status: res.status, }; } else { lastError = new Error(`retryable ${res.status}`); this.events.onUploadFailed?.(batchId, attempt, lastError); } if (noRetry || attempt === maxAttempts) break; await sleep(backoffMs(attempt), signal); } } catch (err) { const error = err instanceof Error ? err : new Error(String(err)); lastError = error; if (error.name !== "AbortError" || timedOut) { this.events.onUploadFailed?.(batchId, Math.max(1, maxAttempts), error); } } finally { if (timeout) clearTimeout(timeout); if (this.controller === controller) this.controller = null; } return { progress: false, accepted: [], rejected: [], ...(lastError ? { error: lastError } : {}), ...(lastStatus !== undefined ? { status: lastStatus } : {}), ...(timedOut ? { timedOut: true } : {}), ...(invalidResponse ? { invalidResponse: true } : {}), }; } } function backoffMs(attempt: number): number { const base = Math.min(30_000, 500 * Math.pow(2, attempt - 1)); const jitter = Math.random() * base * 0.25; return base + jitter; } function sleep(ms: number, signal?: AbortSignal): Promise { return new Promise((resolve) => { if (signal?.aborted) { resolve(); return; } const id = setTimeout(resolve, ms); signal?.addEventListener( "abort", () => { clearTimeout(id); resolve(); }, { once: true }, ); }); }