/** * Throughput tracking for an in-flight transfer. * * Smooths byte-rate via exponential moving average over the last `windowMs` * window, recomputed on every progress sample. Returns `bytesPerSecond` and * `etaSeconds` when total size is known. */ export interface ProgressSample { bytesPerSecond: number; etaSeconds: number | undefined; percent: number | undefined; } export class ProgressTracker { private bytesProcessed = 0; private startedAt = -1; private lastSampleAt = -1; private lastBytes = 0; /** EMA of byte rate (bytes per second). */ private rateEma = 0; /** Smoothing factor in [0, 1]. Higher = more responsive, less smooth. */ private readonly alpha: number; constructor( private readonly bytesTotal: number | undefined, /** Window over which EMA decay equals 1 - 1/e (~63 %). */ windowMs = 5000, /** Sample period — sampling more often than this returns the cached EMA. */ private readonly sampleEveryMs = 250, ) { // Convert window to per-sample alpha: alpha = 1 - exp(-dt/window) // Approximate over `sampleEveryMs` per tick. this.alpha = 1 - Math.exp(-this.sampleEveryMs / windowMs); } /** Record bytes processed since last call. */ add(bytes: number): void { if (this.startedAt < 0) { this.startedAt = nowMs(); this.lastSampleAt = this.startedAt; } this.bytesProcessed += bytes; } /** Total bytes accounted-for so far. */ get totalProcessed(): number { return this.bytesProcessed; } /** Total elapsed wall-clock since first byte. */ get elapsedMs(): number { if (this.startedAt < 0) return 0; return nowMs() - this.startedAt; } /** Compute a smoothed sample. Cheap to call repeatedly. */ sample(): ProgressSample { const now = nowMs(); if (this.startedAt < 0) { return { bytesPerSecond: 0, etaSeconds: undefined, percent: this.percent() }; } const dt = now - this.lastSampleAt; if (dt >= this.sampleEveryMs) { const deltaBytes = this.bytesProcessed - this.lastBytes; const instantRate = dt > 0 ? (deltaBytes * 1000) / dt : 0; this.rateEma = this.rateEma === 0 ? instantRate : this.alpha * instantRate + (1 - this.alpha) * this.rateEma; this.lastSampleAt = now; this.lastBytes = this.bytesProcessed; } const bps = Math.max(0, this.rateEma); let etaSeconds: number | undefined; if (this.bytesTotal !== undefined && bps > 0) { const remaining = Math.max(0, this.bytesTotal - this.bytesProcessed); etaSeconds = remaining / bps; } else if (this.bytesTotal !== undefined && this.bytesProcessed >= this.bytesTotal) { etaSeconds = 0; } return { bytesPerSecond: bps, etaSeconds, percent: this.percent() }; } private percent(): number | undefined { if (this.bytesTotal === undefined) return undefined; if (this.bytesTotal === 0) return 1; return Math.min(1, this.bytesProcessed / this.bytesTotal); } } function nowMs(): number { return typeof performance !== 'undefined' ? performance.now() : Date.now(); }