94 lines
3.0 KiB
TypeScript
94 lines
3.0 KiB
TypeScript
|
|
/**
|
||
|
|
* 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();
|
||
|
|
}
|