@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
156 lines (140 loc) • 5.09 kB
JavaScript
import { assert } from "../../../core/assert.js";
/**
* Aggregates `getStats()` snapshots from one or more named sources (typically
* a `Transport`, `Channel`, or `NetworkPeer.channel_for(peer)`) and reports
* recent throughput rates.
*
* Per-source stats are queried via the source's `getStats()` method. A "sample"
* captures the current totals at a wall-clock moment; the meter retains a
* sliding window of samples and computes bytes-per-second / packets-per-second
* over the window.
*
* No timer of its own — caller invokes {@link sample} per tick (or whenever).
*
* @author Alex Goldring
* @copyright Company Named Limited (c) 2025
*/
export class BandwidthMeter {
#sources;
#samples;
/**
* @param {{ window_seconds?: number }} [options]
*/
constructor({ window_seconds = 5 } = {}) {
assert.isNumber(window_seconds, 'window_seconds');
if (window_seconds <= 0) throw new Error('BandwidthMeter: window_seconds must be > 0');
/** @readonly */
this.window_seconds = window_seconds;
/**
* Registered sources. Each is { name: string, source: { getStats() } }.
* @type {{name: string, source: any}[]}
* @private
*/
this.#sources = [];
/**
* Per-window samples (oldest first). Each entry: { time_ms, totals }.
* @type {{time_ms: number, totals: {bytes_in: number, bytes_out: number, packets_in: number, packets_out: number}}[]}
* @private
*/
this.#samples = [];
}
/**
* @param {string} name human-readable label
* @param {{ getStats(): {bytes_in: number, bytes_out: number, packets_in: number, packets_out: number} }} source
*/
add_source(name, source) {
assert.isString(name, 'name');
assert.ok(source && typeof source.getStats === 'function', 'source must implement getStats()');
this.#sources.push({ name, source });
}
/**
* Capture a fresh sample at the given wall-clock time (caller-supplied so
* tests can use a deterministic clock; in production typically `Date.now()`).
*
* @param {number} now_ms
*/
sample(now_ms) {
assert.isNumber(now_ms, 'now_ms');
const totals = this.#sum_totals();
this.#samples.push({ time_ms: now_ms, totals });
// Drop samples older than the window, but keep one straddling expired
// sample if it's the only thing standing between us and "only the
// just-pushed sample is left" (which would silently make __rate return
// 0 after a long quiet stretch). Once we have 2+ in-window samples the
// straddle is no longer needed and would bias the rate, so drop it.
const cutoff = now_ms - this.window_seconds * 1000;
while (this.#samples.length >= 2 && this.#samples[1].time_ms < cutoff) {
this.#samples.shift();
}
if (this.#samples.length >= 3 && this.#samples[0].time_ms < cutoff) {
this.#samples.shift();
}
}
/**
* Cumulative totals across all sources, right now (re-queries getStats()).
* Independent of the sample window.
*/
cumulative() {
return this.#sum_totals();
}
/**
* Bytes-per-second received, averaged over the sample window.
* Returns 0 if fewer than 2 samples have been taken.
*/
rate_bytes_in() {
return this.#rate('bytes_in');
}
rate_bytes_out() {
return this.#rate('bytes_out');
}
rate_packets_in() {
return this.#rate('packets_in');
}
rate_packets_out() {
return this.#rate('packets_out');
}
/**
* Per-source breakdown of cumulative totals (re-queries getStats()).
* @returns {Array<{name: string, totals: object}>}
*/
per_source() {
const result = [];
for (let i = 0; i < this.#sources.length; i++) {
const s = this.#sources[i];
result.push({ name: s.name, totals: s.source.getStats() });
}
return result;
}
/**
* Drop all retained samples. Source registrations are preserved.
*/
reset_samples() {
this.#samples.length = 0;
}
/**
* @private
*/
#sum_totals() {
let bytes_in = 0, bytes_out = 0, packets_in = 0, packets_out = 0;
for (let i = 0; i < this.#sources.length; i++) {
const s = this.#sources[i].source.getStats();
bytes_in += s.bytes_in;
bytes_out += s.bytes_out;
packets_in += s.packets_in;
packets_out += s.packets_out;
}
return { bytes_in, bytes_out, packets_in, packets_out };
}
/**
* @private
*/
#rate(key) {
const n = this.#samples.length;
if (n < 2) return 0;
const oldest = this.#samples[0];
const newest = this.#samples[n - 1];
const dt_seconds = (newest.time_ms - oldest.time_ms) / 1000;
if (dt_seconds <= 0) return 0;
return (newest.totals[key] - oldest.totals[key]) / dt_seconds;
}
}