/** * Adaptive, application-path connection measurement for preflight. * * The transport is deliberately injectable: production uses HTTP requests to * the ProctorKit API while tests supply deterministic samples without moving * real bytes. The result distinguishes representative throughput from a * conservative lower percentile; pass/fail policy should use the conservative * value so one unusually fast request cannot hide an unstable connection. */ export interface ConnectionTransferSample { bytes: number; durationMs: number; mbps: number; } export interface ConnectionProbeTransport { latency(timeoutMs: number): Promise; download(bytes: number, timeoutMs: number): Promise; upload(bytes: number, timeoutMs: number): Promise; } export interface ConnectionQualityOptions { /** Hard ceiling for latency + download + upload together. Default: 10s. */ maxDurationMs?: number; /** Number of idle round-trip samples. Default: 5. */ latencySamples?: number; /** Adaptive payload ladder. Default targets readiness around 2 Mbps. */ payloadSizes?: number[]; /** A transfer lasting this long is considered representative. Default: 750ms. */ stableSampleMs?: number; /** Injectable monotonic clock. */ now?: () => number; } export interface ConnectionQualityResult { downloadMbps: number | null; uploadMbps: number | null; conservativeDownloadMbps: number | null; conservativeUploadMbps: number | null; latencyMs: number | null; jitterMs: number | null; downloadSamples: ConnectionTransferSample[]; uploadSamples: ConnectionTransferSample[]; latencySamples: number[]; totalDurationMs: number; } export interface HttpConnectionProbeOptions { downloadUrl: string; uploadUrl: string; headers?: Record; fetchFn?: typeof fetch; now?: () => number; } const DEFAULT_PAYLOAD_SIZES = [100_000, 500_000, 1_000_000, 2_500_000]; /** Measure connection quality without letting a stalled transport freeze the UI. */ export async function measureConnectionQuality( transport: ConnectionProbeTransport, options: ConnectionQualityOptions = {}, ): Promise { const now = options.now ?? defaultNow; const startedAt = now(); const maxDurationMs = Math.max(1_000, options.maxDurationMs ?? 10_000); const deadline = startedAt + maxDurationMs; const stableSampleMs = Math.max(100, options.stableSampleMs ?? 750); const payloadSizes = sanitisePayloadSizes(options.payloadSizes ?? DEFAULT_PAYLOAD_SIZES); const latencyCount = Math.max(1, Math.floor(options.latencySamples ?? 5)); // Keep latency useful but bounded. The remaining budget is split evenly so // a slow download cannot consume the upload phase, which is the more // important direction for continuous recordings. const latencyDeadline = Math.min(deadline, startedAt + Math.min(2_000, maxDurationMs * 0.2)); const latencySamples: number[] = []; for (let i = 0; i < latencyCount; i += 1) { const remaining = latencyDeadline - now(); if (remaining <= 25) break; const sample = await safeWithin(remaining, (timeoutMs) => transport.latency(timeoutMs)); if (sample !== null && Number.isFinite(sample) && sample >= 0) { latencySamples.push(sample); } } const afterLatency = now(); const downloadDeadline = afterLatency + Math.max(0, (deadline - afterLatency) / 2); const downloadSamples = await measureDirection( (bytes, timeoutMs) => transport.download(bytes, timeoutMs), payloadSizes, stableSampleMs, downloadDeadline, now, ); const uploadSamples = await measureDirection( (bytes, timeoutMs) => transport.upload(bytes, timeoutMs), payloadSizes, stableSampleMs, deadline, now, ); const usableDownloads = representativeSamples(downloadSamples, stableSampleMs); const usableUploads = representativeSamples(uploadSamples, stableSampleMs); return { downloadMbps: roundedPercentile(usableDownloads.map((sample) => sample.mbps), 0.5), uploadMbps: roundedPercentile(usableUploads.map((sample) => sample.mbps), 0.5), conservativeDownloadMbps: roundedPercentile( usableDownloads.map((sample) => sample.mbps), 0.25, ), conservativeUploadMbps: roundedPercentile( usableUploads.map((sample) => sample.mbps), 0.25, ), latencyMs: roundedPercentile(latencySamples, 0.5), jitterMs: calculateJitter(latencySamples), downloadSamples, uploadSamples, latencySamples, totalDurationMs: Math.max(0, Math.round(now() - startedAt)), }; } /** Build the real browser transport for the ProctorKit network-test routes. */ export function createHttpConnectionProbe( options: HttpConnectionProbeOptions, ): ConnectionProbeTransport { const fetchFn = options.fetchFn ?? globalThis.fetch; const now = options.now ?? defaultNow; if (typeof fetchFn !== "function") { throw new Error("Connection measurement requires fetch"); } const request = async ( url: string, init: RequestInit, timeoutMs: number, consume: (response: Response) => Promise, ): Promise<{ value: T; durationMs: number }> => { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), Math.max(1, timeoutMs)); const startedAt = now(); try { const response = await fetchFn(url, { ...init, cache: "no-store", signal: controller.signal, headers: { ...(options.headers ?? {}), ...(init.headers ?? {}), }, }); if (!response.ok) throw new Error(`Connection probe returned HTTP ${response.status}`); const value = await consume(response); return { value, durationMs: Math.max(0.1, now() - startedAt) }; } finally { clearTimeout(timer); } }; return { async latency(timeoutMs) { const { durationMs } = await request( withBytes(options.downloadUrl, 0), { method: "GET" }, timeoutMs, (response) => response.arrayBuffer(), ); return durationMs; }, async download(bytes, timeoutMs) { const { value: body, durationMs } = await request( withBytes(options.downloadUrl, bytes), { method: "GET" }, timeoutMs, (response) => response.arrayBuffer(), ); return transferSample(body.byteLength, durationMs); }, async upload(bytes, timeoutMs) { const body = new Uint8Array(bytes); const { durationMs } = await request( options.uploadUrl, { method: "POST", body, headers: { "content-type": "application/octet-stream" }, }, timeoutMs, (response) => response.arrayBuffer(), ); return transferSample(bytes, durationMs); }, }; } export function transferSample(bytes: number, durationMs: number): ConnectionTransferSample { const safeBytes = Math.max(0, bytes); const safeDuration = Math.max(0.1, durationMs); return { bytes: safeBytes, durationMs: safeDuration, mbps: (safeBytes * 8) / safeDuration / 1_000, }; } async function measureDirection( run: (bytes: number, timeoutMs: number) => Promise, payloadSizes: number[], stableSampleMs: number, deadline: number, now: () => number, ): Promise { const samples: ConnectionTransferSample[] = []; for (const bytes of payloadSizes) { const remaining = deadline - now(); if (remaining <= 25) break; const sample = await safeWithin(remaining, (timeoutMs) => run(bytes, timeoutMs)); if (!isValidTransfer(sample)) continue; samples.push(sample); if (sample.durationMs >= stableSampleMs) { // One confirmation at the representative size reduces one-off spikes. const confirmationRemaining = deadline - now(); if (confirmationRemaining > 25) { const confirmation = await safeWithin(confirmationRemaining, (timeoutMs) => run(bytes, timeoutMs), ); if (isValidTransfer(confirmation)) samples.push(confirmation); } break; } } return samples; } async function safeWithin( timeoutMs: number, run: (timeoutMs: number) => Promise, ): Promise { let timer: ReturnType | undefined; const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve(null), Math.max(1, timeoutMs)); }); try { return await Promise.race([run(Math.max(1, timeoutMs)), timeout]); } catch { return null; } finally { if (timer) clearTimeout(timer); } } function representativeSamples( samples: ConnectionTransferSample[], stableSampleMs: number, ): ConnectionTransferSample[] { const stable = samples.filter((sample) => sample.durationMs >= stableSampleMs); if (stable.length > 0) return stable; const largest = Math.max(0, ...samples.map((sample) => sample.bytes)); return samples.filter((sample) => sample.bytes === largest); } function sanitisePayloadSizes(values: number[]): number[] { const sizes = [...new Set(values.map((value) => Math.floor(value)))] .filter((value) => Number.isFinite(value) && value > 0) .sort((a, b) => a - b); return sizes.length > 0 ? sizes : [...DEFAULT_PAYLOAD_SIZES]; } function isValidTransfer( sample: ConnectionTransferSample | null, ): sample is ConnectionTransferSample { return ( sample !== null && Number.isFinite(sample.bytes) && sample.bytes > 0 && Number.isFinite(sample.durationMs) && sample.durationMs > 0 && Number.isFinite(sample.mbps) && sample.mbps > 0 ); } function roundedPercentile(values: number[], percentile: number): number | null { const valid = values.filter((value) => Number.isFinite(value)).sort((a, b) => a - b); if (valid.length === 0) return null; const index = (valid.length - 1) * percentile; const lower = valid[Math.floor(index)]!; const upper = valid[Math.ceil(index)]!; const value = lower + (upper - lower) * (index - Math.floor(index)); return Math.round(value * 10) / 10; } function calculateJitter(samples: number[]): number | null { if (samples.length < 2) return null; const differences: number[] = []; for (let i = 1; i < samples.length; i += 1) { differences.push(Math.abs(samples[i]! - samples[i - 1]!)); } return roundedPercentile(differences, 0.5); } function withBytes(rawUrl: string, bytes: number): string { if (rawUrl.includes("{bytes}")) { return rawUrl.replaceAll("{bytes}", String(bytes)); } const url = new URL(rawUrl); url.searchParams.set("bytes", String(bytes)); url.searchParams.set("_", `${Date.now()}-${Math.random()}`); return url.toString(); } function defaultNow(): number { return typeof performance !== "undefined" ? performance.now() : Date.now(); }