first commit
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
# @suaveplan/network-plugin-transport-manager - Multi-transport orchestration for the Suaveplan mesh: pool, capability negotiation, priority routing, round-robin balancing, health monitoring, pre-emptive switching, session persistence, battery + bandwidth conservation
|
||||
|
||||
## Summary
|
||||
|
||||
Multi-transport orchestration for the Suaveplan mesh. Maintains a pool of registered transports per peer, negotiates capability bitmaps during handshake, routes outbound messages by 5-tier priority (CRITICAL/HIGH/NORMAL/LOW/BULK), distributes NORMAL/BULK traffic across all healthy transports via round-robin, monitors per-transport health (RTT/loss/stability) and auto-pauses degraded paths, pre-emptively switches transports when quality trends predict failure with smooth handoff, persists session state across temporary drops with prioritized recovery, and conserves power and bandwidth via `BatteryAwareScheduler` and `BandwidthConservator`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [analyzer](./src/analyzer/quality-trend-analyzer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [quality-trend-analyzer](./src/analyzer/quality-trend-analyzer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [balancer](./src/balancer/round-robin-balancer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [round-robin-balancer](./src/balancer/round-robin-balancer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [health](./src/health/transport-health-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [transport-health-monitor](./src/health/transport-health-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [negotiator](./src/negotiator/capability-negotiator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [capability-negotiator](./src/negotiator/capability-negotiator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [persistence](./src/persistence/session-persistence-buffer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [session-persistence-buffer](./src/persistence/session-persistence-buffer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [plugin](./src/plugin/transport-manager-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [transport-manager-plugin](./src/plugin/transport-manager-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [pool](./src/pool/transport-pool.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [transport-pool](./src/pool/transport-pool.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [priority](./src/priority/priority-router.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [priority-router](./src/priority/priority-router.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [connection-recovery-manager](./src/recovery/connection-recovery-manager.md) — Generates exponential-backoff delays with bounded full-jitter so the manager can pace reconnection attempts without thu…
|
||||
- [session-continuity-guard](./src/recovery/session-continuity-guard.md) — Owns the session-state machine around the persistence buffer so callers cannot bypass the documented sequence: a sessio…
|
||||
- [bandwidth-conservator](./src/scheduler/bandwidth-conservator.md) — Implements `NET-124`: when the manager is told it has a constrained bandwidth budget, this module reports the recommend…
|
||||
- [battery-aware-scheduler](./src/scheduler/battery-aware-scheduler.md) — Reduces keepalive frequency and prefers low-power transports when the host device is running on battery. Implements `NE…
|
||||
- [preemptive-selector](./src/selector/preemptive-selector.md) — Decides whether to swap a still-functioning transport for a healthier alternative *before* the current one drops to bel…
|
||||
- [transport-selector](./src/selector/transport-selector.md) — Translates a `(priority, commonCapabilities)` ask into a concrete, ordered list of transports drawn from the live pool.…
|
||||
- [error-codes](./src/error-codes.md) — Frozen registry of every error code raised by the transport-manager package. Codes are namespaced under `NET_TM` and re…
|
||||
<!-- end:auto-docs --> - [quality-trend-analyzer](./src/analyzer/quality-trend-analyzer.md) — <!-- begin:auto-docs --> _(no submodules)_- [balancer](./src/balancer/round-robin-balancer.md) — <!-- begin:auto-docs --> _(no submodules)_- [round-robin-balancer](./src/balancer/round-robin-balancer.md) — <!-- begin:auto-docs --> _(no submodules)_- [health](./src/health/transport-health-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport-health-monitor](./src/health/transport-health-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [negotiator](./src/negotiator/capability-negotiator.md) — <!-- begin:auto-docs --> _(no submodules)_- [capability-negotiator](./src/negotiator/capability-negotiator.md) — <!-- begin:auto-docs --> _(no submodules)_- [persistence](./src/persistence/session-persistence-buffer.md) — <!-- begin:auto-docs --> _(no submodules)_- [session-persistence-buffer](./src/persistence/session-persistence-buffer.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/transport-manager-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport-manager-plugin](./src/plugin/transport-manager-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [pool](./src/pool/transport-pool.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport-pool](./src/pool/transport-pool.md) — <!-- begin:auto-docs --> _(no submodules)_- [priority](./src/priority/priority-router.md) — <!-- begin:auto-docs --> _(no submodules)_- [priority-router](./src/priority/priority-router.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection-recovery-manager](./src/recovery/connection-recovery-manager.md) — Generates exponential-backoff delays with bounded full-jitter so the manager can pace reconnection attempts without thu…
|
||||
- [session-continuity-guard](./src/recovery/session-continuity-guard.md) — Owns the session-state machine around the persistence buffer so callers cannot bypass the documented sequence: a sessio…
|
||||
- [bandwidth-conservator](./src/scheduler/bandwidth-conservator.md) — Implements `NET-124`: when the manager is told it has a constrained bandwidth budget, this module reports the recommend…
|
||||
- [battery-aware-scheduler](./src/scheduler/battery-aware-scheduler.md) — Reduces keepalive frequency and prefers low-power transports when the host device is running on battery. Implements `NE…
|
||||
- [preemptive-selector](./src/selector/preemptive-selector.md) — Decides whether to swap a still-functioning transport for a healthier alternative _before_ the current one drops to bel…
|
||||
- [transport-selector](./src/selector/transport-selector.md) — Translates a `(priority, commonCapabilities)` ask into a concrete, ordered list of transports drawn from the live pool.…
|
||||
- [error-codes](./src/error-codes.md) — Frozen registry of every error code raised by the transport-manager package. Codes are namespaced under `NET_TM` and re…
|
||||
- [quality-trend-analyzer](./src/analyzer/quality-trend-analyzer.md) — <!-- begin:auto-docs --> _(no submodules)_- [balancer](./src/balancer/round-robin-balancer.md) — <!-- begin:auto-docs --> _(no submodules)_- [round-robin-balancer](./src/balancer/round-robin-balancer.md) — <!-- begin:auto-docs --> _(no submodules)_- [health](./src/health/transport-health-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport-health-monitor](./src/health/transport-health-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [negotiator](./src/negotiator/capability-negotiator.md) — <!-- begin:auto-docs --> _(no submodules)_- [capability-negotiator](./src/negotiator/capability-negotiator.md) — <!-- begin:auto-docs --> _(no submodules)_- [persistence](./src/persistence/session-persistence-buffer.md) — <!-- begin:auto-docs --> _(no submodules)_- [session-persistence-buffer](./src/persistence/session-persistence-buffer.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/transport-manager-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport-manager-plugin](./src/plugin/transport-manager-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [pool](./src/pool/transport-pool.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport-pool](./src/pool/transport-pool.md) — <!-- begin:auto-docs --> _(no submodules)_- [priority](./src/priority/priority-router.md) — <!-- begin:auto-docs --> _(no submodules)_- [priority-router](./src/priority/priority-router.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection-recovery-manager](./src/recovery/connection-recovery-manager.md) — Generates exponential-backoff delays with bounded full-jitter so the manager can pace reconnection attempts without thu…
|
||||
- [session-continuity-guard](./src/recovery/session-continuity-guard.md) — Owns the session-state machine around the persistence buffer so callers cannot bypass the documented sequence: a sessio…
|
||||
- [bandwidth-conservator](./src/scheduler/bandwidth-conservator.md) — Implements `NET-124`: when the manager is told it has a constrained bandwidth budget, this module reports the recommend…
|
||||
- [battery-aware-scheduler](./src/scheduler/battery-aware-scheduler.md) — Reduces keepalive frequency and prefers low-power transports when the host device is running on battery. Implements `NE…
|
||||
- [preemptive-selector](./src/selector/preemptive-selector.md) — Decides whether to swap a still-functioning transport for a healthier alternative _before_ the current one drops to bel…
|
||||
- [transport-selector](./src/selector/transport-selector.md) — Translates a `(priority, commonCapabilities)` ask into a concrete, ordered list of transports drawn from the live pool.…
|
||||
- [error-codes](./src/error-codes.md) — Frozen registry of every error code raised by the transport-manager package. Codes are namespaced under `NET_TM` and re…
|
||||
- [quality-trend-analyzer](./src/analyzer/quality-trend-analyzer.md) — <!-- begin:auto-docs --> _(no submodules)_- [balancer](./src/balancer/round-robin-balancer.md) — <!-- begin:auto-docs --> _(no submodules)_- [round-robin-balancer](./src/balancer/round-robin-balancer.md) — <!-- begin:auto-docs --> _(no submodules)_- [health](./src/health/transport-health-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport-health-monitor](./src/health/transport-health-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [negotiator](./src/negotiator/capability-negotiator.md) — <!-- begin:auto-docs --> _(no submodules)_- [capability-negotiator](./src/negotiator/capability-negotiator.md) — <!-- begin:auto-docs --> _(no submodules)_- [persistence](./src/persistence/session-persistence-buffer.md) — <!-- begin:auto-docs --> _(no submodules)_- [session-persistence-buffer](./src/persistence/session-persistence-buffer.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/transport-manager-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport-manager-plugin](./src/plugin/transport-manager-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [pool](./src/pool/transport-pool.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport-pool](./src/pool/transport-pool.md) — <!-- begin:auto-docs --> _(no submodules)_- [priority](./src/priority/priority-router.md) — <!-- begin:auto-docs --> _(no submodules)_- [priority-router](./src/priority/priority-router.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection-recovery-manager](./src/recovery/connection-recovery-manager.md) — Generates exponential-backoff delays with bounded full-jitter so the manager can pace reconnection attempts without thu…
|
||||
- [session-continuity-guard](./src/recovery/session-continuity-guard.md) — Owns the session-state machine around the persistence buffer so callers cannot bypass the documented sequence: a sessio…
|
||||
- [bandwidth-conservator](./src/scheduler/bandwidth-conservator.md) — Implements `NET-124`: when the manager is told it has a constrained bandwidth budget, this module reports the recommend…
|
||||
- [battery-aware-scheduler](./src/scheduler/battery-aware-scheduler.md) — Reduces keepalive frequency and prefers low-power transports when the host device is running on battery. Implements `NE…
|
||||
- [preemptive-selector](./src/selector/preemptive-selector.md) — Decides whether to swap a still-functioning transport for a healthier alternative _before_ the current one drops to bel…
|
||||
- [transport-selector](./src/selector/transport-selector.md) — Translates a `(priority, commonCapabilities)` ask into a concrete, ordered list of transports drawn from the live pool.…
|
||||
- [error-codes](./src/error-codes.md) — Frozen registry of every error code raised by the transport-manager package. Codes are namespaced under `NET_TM` and re…
|
||||
- [quality-trend-analyzer](./src/analyzer/quality-trend-analyzer.md) — <!-- begin:auto-docs --> _(no submodules)_- [balancer](./src/balancer/round-robin-balancer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [round-robin-balancer](./src/balancer/round-robin-balancer.md) — <!-- begin:auto-docs --> _(no submodules)_- [health](./src/health/transport-health-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_ - [transport-health-monitor](./src/health/transport-health-monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [negotiator](./src/negotiator/capability-negotiator.md) — <!-- begin:auto-docs --> _(no submodules)_ - [capability-negotiator](./src/negotiator/capability-negotiator.md) — <!-- begin:auto-docs --> _(no submodules)_- [persistence](./src/persistence/session-persistence-buffer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [session-persistence-buffer](./src/persistence/session-persistence-buffer.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/transport-manager-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ - [transport-manager-plugin](./src/plugin/transport-manager-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [pool](./src/pool/transport-pool.md) — <!-- begin:auto-docs --> _(no submodules)_ - [transport-pool](./src/pool/transport-pool.md) — <!-- begin:auto-docs --> _(no submodules)_- [priority](./src/priority/priority-router.md) — <!-- begin:auto-docs --> _(no submodules)_ - [priority-router](./src/priority/priority-router.md) — <!-- begin:auto-docs --> _(no submodules)_- [connection-recovery-manager](./src/recovery/connection-recovery-manager.md) — Generates exponential-backoff delays with bounded full-jitter so the manager can pace reconnection attempts without thu…
|
||||
- [session-continuity-guard](./src/recovery/session-continuity-guard.md) — Owns the session-state machine around the persistence buffer so callers cannot bypass the documented sequence: a sessio…
|
||||
- [bandwidth-conservator](./src/scheduler/bandwidth-conservator.md) — Implements `NET-124`: when the manager is told it has a constrained bandwidth budget, this module reports the recommend…
|
||||
- [battery-aware-scheduler](./src/scheduler/battery-aware-scheduler.md) — Reduces keepalive frequency and prefers low-power transports when the host device is running on battery. Implements `NE…
|
||||
- [preemptive-selector](./src/selector/preemptive-selector.md) — Decides whether to swap a still-functioning transport for a healthier alternative _before_ the current one drops to bel…
|
||||
- [transport-selector](./src/selector/transport-selector.md) — Translates a `(priority, commonCapabilities)` ask into a concrete, ordered list of transports drawn from the live pool.…
|
||||
- [error-codes](./src/error-codes.md) — Frozen registry of every error code raised by the transport-manager package. Codes are namespaced under `NET_TM` and re…
|
||||
@@ -0,0 +1,73 @@
|
||||
# `QualityTrendAnalyzer`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Tracks short rolling windows of latency, packet loss, and bandwidth per transport and reports a smoothed slope over each metric. Pre-emptive switching uses the slope (not the absolute value) to predict failure: a transport at 80 ms RTT that is climbing 10 ms/sample is failing faster than one at 200 ms RTT that is flat.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-metric slope via exponentially-weighted moving average of per-step deltas (cheaper than re-fitting a regression on every sample).
|
||||
- Per-transport rolling window (default size 16) so callers can introspect the recent history for charts and diagnostics.
|
||||
- Configurable `bad` thresholds per metric so different deployments (mobile vs server) can tune sensitivity.
|
||||
- `degrading: boolean` summary flag that the pre-emptive selector consults directly — no need to re-implement threshold logic in callers.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { QualityTrendAnalyzer } from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const analyzer = new QualityTrendAnalyzer();
|
||||
analyzer.record("ws", { latency: 60, loss: 0, bandwidthBps: 1_000_000 });
|
||||
const trend = analyzer.record("ws", {
|
||||
latency: 90,
|
||||
loss: 0.02,
|
||||
bandwidthBps: 500_000,
|
||||
});
|
||||
trend.degrading; // likely true — latency and loss climbing, bandwidth dropping
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { QualityTrendAnalyzer } from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const analyzer = new QualityTrendAnalyzer({
|
||||
windowSize: 32,
|
||||
latencyBadSlope: 10,
|
||||
lossBadSlope: 0.01,
|
||||
bandwidthBadSlope: -2048,
|
||||
emaAlpha: 0.5,
|
||||
});
|
||||
|
||||
// Feed a slow degradation pattern
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
analyzer.record("wrtc", {
|
||||
latency: 50 + i * 6,
|
||||
loss: i * 0.005,
|
||||
bandwidthBps: 2_000_000 - i * 1500,
|
||||
});
|
||||
}
|
||||
const summary = analyzer.window("wrtc");
|
||||
// summary.latency, summary.loss, summary.bandwidthBps each carry the
|
||||
// last 12 samples in arrival order.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class QualityTrendAnalyzer(config?)`
|
||||
- `record(transportId, { latency, loss, bandwidthBps }): QualityTrend` — append a sample, update slopes, return the latest trend snapshot.
|
||||
- `window(transportId): QualityWindow` — frozen-style snapshot of the rolling buffers (cloned).
|
||||
- `reset(transportId?): void` — drop a single transport's window or all of them.
|
||||
- `interface QualityTrend` — `{ transportId, latencySlope, lossSlope, bandwidthSlope, degrading }`.
|
||||
- `interface QualityWindow` — `{ transportId, latency, loss, bandwidthBps }`.
|
||||
- `interface QualityTrendAnalyzerConfig` — `{ windowSize?, latencyBadSlope?, lossBadSlope?, bandwidthBadSlope?, emaAlpha? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Slopes are computed from the _delta_ between this and the previous sample, then EMA-smoothed. This is O(1) per sample and avoids storing the full window for the slope calculation, even though the window itself is kept for diagnostic introspection.
|
||||
- Bandwidth slope is "bad" when it goes more negative than the threshold (default `-1024` bps/sample) — i.e. throughput is collapsing.
|
||||
- The first sample for a transport never produces a slope (no prior delta), so `degrading` is always `false` until at least the second `record` call. Tests pin this boundary explicitly.
|
||||
- The analyzer does not enforce monotonicity or quantize timestamps — callers feed samples at whatever cadence their transport monitor produces them. Slope is "per sample", not "per second", which keeps the analyzer agnostic to sampling rate.
|
||||
@@ -0,0 +1,70 @@
|
||||
# `RoundRobinBalancer`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Spreads NORMAL- and BULK-tier traffic across every healthy transport that matches the negotiated capability intersection so a single path never saturates while siblings sit idle. Pairs with the selector: the selector picks transports for redundant tiers (CRITICAL), while the balancer rotates through transports for fan-out tiers.
|
||||
|
||||
## Features
|
||||
|
||||
- Stable round-robin cursor per capability subset — the same set of capabilities always rotates the same transport list in the same order across calls.
|
||||
- Cursor key normalises to sorted-joined form so semantically-equal capability lists share a cursor regardless of input order.
|
||||
- `preview(caps, n)` returns the next `n` transport ids without losing cursor state for tests / diagnostics.
|
||||
- Throws `NO_HEALTHY_TRANSPORT` when the candidate set is empty so callers do not silently swallow a starved capability.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
TransportPool,
|
||||
RoundRobinBalancer,
|
||||
} from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const pool = new TransportPool();
|
||||
pool.register({ id: "ws-a", capability: "websocket", connected: true });
|
||||
pool.register({ id: "ws-b", capability: "websocket", connected: true });
|
||||
|
||||
const balancer = new RoundRobinBalancer(pool);
|
||||
balancer.next(["websocket"]).id; // "ws-a"
|
||||
balancer.next(["websocket"]).id; // "ws-b"
|
||||
balancer.next(["websocket"]).id; // "ws-a" — wraps
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
TransportPool,
|
||||
RoundRobinBalancer,
|
||||
} from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const pool = new TransportPool();
|
||||
pool.register({ id: "wrtc", capability: "webrtc-datachannel", connected: true });
|
||||
pool.register({ id: "ws", capability: "websocket", connected: true });
|
||||
|
||||
const balancer = new RoundRobinBalancer(pool);
|
||||
|
||||
// Distinct capability subsets keep separate cursors so they cannot
|
||||
// starve each other.
|
||||
balancer.preview(["webrtc-datachannel", "websocket"], 4);
|
||||
// → ["wrtc", "ws", "wrtc", "ws"]
|
||||
|
||||
balancer.preview(["websocket"], 3);
|
||||
// → ["ws", "ws", "ws"] — only one matches
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class RoundRobinBalancer(pool: TransportPool)`
|
||||
- `next(commonCapabilities): TransportLike` — advances the cursor by one and returns the next eligible transport. Throws `NO_HEALTHY_TRANSPORT` when no transport matches.
|
||||
- `preview(commonCapabilities, n): ReadonlyArray<string>` — repeated `next` calls returning ids; mutates cursor state.
|
||||
- `candidates(commonCapabilities): ReadonlyArray<TransportLike>` — the eligible set (without rotating).
|
||||
- `reset(): void` — clears every cursor.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The cursor is per (sorted) capability subset because two subsets with the same set of transports should still rotate independently when callers ask for them as different shapes — distinct call sites typically hit the balancer with different subsets, and sharing cursors across them would couple unrelated traffic.
|
||||
- The defensive `next === undefined` branch is unreachable when `candidates.length > 0`, but it is kept so a future change to the candidate strategy (e.g. inserting a filter that mutates the array between length-check and indexing) cannot silently return `undefined`. The test suite exercises both branches via a stub pool that simulates the race.
|
||||
- Unhealthy/disconnected transports are filtered out at every `next` call rather than cached so a transport that flips paused mid-stream is dropped on the next rotation without an explicit notification.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Error codes — `@suaveplan/network-plugin-transport-manager`
|
||||
|
||||
## Purpose
|
||||
|
||||
Frozen registry of every error code raised by the transport-manager package. Codes are namespaced under `NET_TM` and registered into the global error registry on first import via the side-effect import in `src/index.ts`. Each code maps to a single failure class so that callers can branch on `error.code` without parsing message text.
|
||||
|
||||
## Features
|
||||
|
||||
- Eight codes covering registration, selection, recovery, and buffer-state failures.
|
||||
- Frozen registry — runtime mutation throws, ensuring downstream code cannot inject codes.
|
||||
- `NetworkTmError` extends `SuaveplanError<NetworkTmErrorCode>` so cause-chain traversal, redaction, and OTel adapters work without per-package wiring.
|
||||
- `NetworkTmError` preserves `cause` and supports the standard `SuaveplanErrorOptions` bag.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
NETWORK_TM_CODES,
|
||||
NetworkTmError,
|
||||
} from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
throw new NetworkTmError(
|
||||
NETWORK_TM_CODES.TRANSPORT_ALREADY_REGISTERED,
|
||||
"pool.duplicate",
|
||||
{ context: { transportId: "webrtc-1" } }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
try {
|
||||
pool.register(transport);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof NetworkTmError &&
|
||||
err.code === NETWORK_TM_CODES.TRANSPORT_ALREADY_REGISTERED
|
||||
) {
|
||||
// idempotent retry: skip the duplicate registration
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `NETWORK_TM_CODES` — frozen object mapping each code key (e.g. `TRANSPORT_ALREADY_REGISTERED`) to its fully-qualified value (`NET_TM.TRANSPORT_ALREADY_REGISTERED`). Throws on mutation attempts.
|
||||
- `NetworkTmErrorCode` — TypeScript union of every value in `NETWORK_TM_CODES`.
|
||||
- `NetworkTmError` — `SuaveplanError<NetworkTmErrorCode>` subclass. Constructor takes `(code, messageKey, options?)`. `options.context` is the canonical place to attach structured details.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The registry is constructed via `defineCodes("NET_TM", […] as const)` so that the type system can enumerate the value union. Adding a code requires bumping the `as const` tuple — TypeScript will then propagate the new key everywhere `NetworkTmErrorCode` is referenced.
|
||||
- The `src/index.ts` barrel performs a side-effect import of `./error-codes.js` so that the registration runs even when callers tree-shake the named exports.
|
||||
- Codes are deliberately coarse (one per failure class) rather than per-call-site so consumers can build stable retry/observability policies without churn when the implementation changes.
|
||||
@@ -0,0 +1,81 @@
|
||||
# `TransportHealthMonitor`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Aggregates per-transport health samples (RTT, packet loss, drop signal) into a smoothed score, persists the result on the pool, and toggles the `paused` flag with hysteresis so flapping transports do not bounce between in-service and out-of-service. The selector and balancer never need to track samples themselves — they only read the pool.
|
||||
|
||||
## Features
|
||||
|
||||
- EMA smoothing of `rttMs` and `lossRate` so a single bad sample does not pull the score off the cliff.
|
||||
- Stability tracker that decays on drops and recovers slowly with healthy samples.
|
||||
- Hysteresis: `pauseBelow` (default 30) trips a transport offline; `resumeAtOrAbove` (default 60) brings it back. Tuning these prevents oscillation.
|
||||
- Optional `BusLike` so callers can subscribe to `transport:health-change` for observability without polling the pool.
|
||||
- `pause(id)` / `resume(id)` for forced overrides on disconnect/reconnect events.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
TransportPool,
|
||||
TransportHealthMonitor,
|
||||
} from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const pool = new TransportPool();
|
||||
pool.register({ id: "ws", capability: "websocket", connected: true });
|
||||
const monitor = new TransportHealthMonitor(pool);
|
||||
|
||||
monitor.recordSample("ws", { rttMs: 60, lossRate: 0.01 });
|
||||
pool.getHealth("ws").score; // ~95
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
TransportPool,
|
||||
TransportHealthMonitor,
|
||||
} from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const events: unknown[] = [];
|
||||
const bus = {
|
||||
emit: (event: string, payload: unknown) => events.push({ event, payload }),
|
||||
};
|
||||
|
||||
const pool = new TransportPool();
|
||||
pool.register({ id: "wrtc", capability: "webrtc-datachannel", connected: true });
|
||||
const monitor = new TransportHealthMonitor(pool, bus, {
|
||||
pauseBelow: 40,
|
||||
resumeAtOrAbove: 70,
|
||||
emaAlpha: 0.5,
|
||||
});
|
||||
|
||||
// Burst of bad samples — the score collapses past the pause threshold.
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
monitor.recordSample("wrtc", { rttMs: 600, lossRate: 0.4, dropped: true });
|
||||
}
|
||||
pool.getHealth("wrtc").paused; // true
|
||||
|
||||
// Recovery: only un-pauses when the score crosses the higher threshold.
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
monitor.recordSample("wrtc", { rttMs: 40, lossRate: 0, dropped: false });
|
||||
}
|
||||
pool.getHealth("wrtc").paused; // false
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class TransportHealthMonitor(pool, bus?, config?)`
|
||||
- `recordSample(transportId, sample): TransportHealth` — apply EMA, recompute score, toggle pause state, emit `transport:health-change` on transition.
|
||||
- `pause(transportId): void` — force pause without a sample (idempotent).
|
||||
- `resume(transportId): void` — force resume without a sample (idempotent).
|
||||
- `interface TransportHealthMonitorConfig` — `{ pauseBelow?, resumeAtOrAbove?, emaAlpha?, stabilityDropStep?, stabilityRecoverStep? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `pauseBelow` and `resumeAtOrAbove` are intentionally separated. With identical thresholds, a transport hovering at the boundary would pause/resume on every sample, generating bus storms and confusing the selector.
|
||||
- Stability decays in larger steps (default 0.1) than it recovers (default 0.05) so a transport that drops samples is treated as suspect for at least twice as many healthy samples as it took to land in suspect — matches operational intuition that "fixed once, then stayed fixed" is more credible than "one good sample".
|
||||
- The bus is optional and defaults to a no-op object so unit tests do not need to instantiate a real bus to exercise the monitor.
|
||||
- All math is in plain JavaScript numbers because the manager runs in browsers and Node — `BigInt` would be overkill and would not reflect the underlying noise.
|
||||
@@ -0,0 +1,74 @@
|
||||
# `CapabilityNegotiator`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Records the transport-capability bitmap each peer advertised during handshake and produces the intersection on demand. The selector consults this intersection so the manager only attempts transports both peers can actually speak — avoiding wasted dials over WebRTC when the remote is a Node.js worker without WebRTC support, or over Bluetooth when the remote is a browser tab.
|
||||
|
||||
## Features
|
||||
|
||||
- Stores local capabilities once at construction; remote bitmaps refresh on every `recordRemote` call.
|
||||
- Intersection respects local preference order so the selector receives a ranked list, not an unordered set.
|
||||
- Optional throw-on-empty mode (`throwOnEmpty: true`) lets callers treat zero-overlap as fatal at the boundary.
|
||||
- Clock-injectable so tests can pin `updatedAt` deterministically.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { CapabilityNegotiator } from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const negotiator = new CapabilityNegotiator({
|
||||
local: ["webrtc-datachannel", "websocket", "http-fetch"],
|
||||
});
|
||||
negotiator.recordRemote("peer-A", ["websocket", "http-fetch"]);
|
||||
const common = negotiator.commonWith("peer-A");
|
||||
// → ["websocket", "http-fetch"] in local preference order
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { CapabilityNegotiator } from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const clock = () => 1_700_000_000_000;
|
||||
const negotiator = new CapabilityNegotiator({
|
||||
local: ["webrtc-datachannel", "websocket"],
|
||||
clock,
|
||||
throwOnEmpty: true,
|
||||
});
|
||||
|
||||
// First handshake
|
||||
negotiator.recordRemote("peer-B", ["http-fetch", "bluetooth-le"]);
|
||||
try {
|
||||
negotiator.commonWith("peer-B");
|
||||
} catch (err) {
|
||||
// throws NO_COMMON_CAPABILITY — the peer has nothing in common
|
||||
}
|
||||
|
||||
// Peer reconnects via a WebRTC-capable browser tab
|
||||
negotiator.recordRemote("peer-B", ["webrtc-datachannel", "websocket"]);
|
||||
negotiator.commonWith("peer-B");
|
||||
// → ["webrtc-datachannel", "websocket"]
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class CapabilityNegotiator`
|
||||
- `constructor(config: CapabilityNegotiatorConfig)` — `local` is the local capability list (frozen on store), `clock` is optional, `throwOnEmpty` defaults to `false`.
|
||||
- `localCapabilities(): ReadonlyArray<TransportCapability>` — frozen copy of the configured local list.
|
||||
- `recordRemote(peerId, capabilities): NegotiatedPeer` — replaces any prior bitmap and stamps `updatedAt`.
|
||||
- `hasRemote(peerId): boolean`.
|
||||
- `forgetRemote(peerId): void`.
|
||||
- `clear(): void`.
|
||||
- `commonWith(peerId): ReadonlyArray<TransportCapability>` — returns the intersection in local preference order; throws `NO_COMMON_CAPABILITY` when empty if `throwOnEmpty: true`.
|
||||
- `interface NegotiatedPeer` — `{ peerId, capabilities, updatedAt }`.
|
||||
- `interface CapabilityNegotiatorConfig` — `{ local, clock?, throwOnEmpty? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The intersection is computed with a `Set` lookup (O(n + m)) where `n` = local list and `m` = remote list. Both are short, so this is effectively constant.
|
||||
- `recordRemote` freezes the stored capability array to prevent the caller from mutating the stored entry post-hoc — the negotiator is the boundary that owns the snapshot from then on.
|
||||
- `throwOnEmpty` is a constructor flag rather than a per-call argument so peers from the same negotiator share a single boundary policy. Mixed semantics within one peer would be a code smell.
|
||||
- Peer GC (e.g. forgetting peers older than X minutes) is intentionally not built in — the higher-level peer manager controls peer lifecycle and should call `forgetRemote` from its own `peer:disconnected` handler.
|
||||
@@ -0,0 +1,70 @@
|
||||
# `SessionPersistenceBuffer`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Holds outbound messages while a session is in a temporary-drop state so the recovery manager can replay them in priority order once a new transport is established. Without this buffer, messages produced during the brief window between disconnect and reconnect would be lost — including CRITICAL host-election traffic that the mesh cannot tolerate losing.
|
||||
|
||||
## Features
|
||||
|
||||
- Priority-aware enqueue: messages remember the tier they were sent at, so drains can replay CRITICAL/HIGH ahead of NORMAL/LOW/BULK.
|
||||
- Hard capacity ceiling: when the buffer is full, the oldest lowest-priority entry is evicted to make room. CRITICAL messages never silently drop — `BUFFER_OVERFLOW` is raised when even non-CRITICAL eviction fails.
|
||||
- `drain()` returns the buffered messages in priority-then-FIFO order and clears the buffer atomically.
|
||||
- Injectable clock so deterministic tests can assert priority/order without timer flakiness.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { SessionPersistenceBuffer } from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const buf = new SessionPersistenceBuffer<string>();
|
||||
buf.enqueue({ id: "m1", priority: "NORMAL", payload: "hello" });
|
||||
buf.enqueue({ id: "m2", priority: "CRITICAL", payload: "election:vote" });
|
||||
|
||||
const ordered = buf.drain();
|
||||
// ordered[0].id === "m2" — CRITICAL ahead of NORMAL
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { SessionPersistenceBuffer } from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
let now = 1_700_000_000_000;
|
||||
const buf = new SessionPersistenceBuffer<{ kind: string }>({
|
||||
capacity: 3,
|
||||
clock: () => now,
|
||||
});
|
||||
|
||||
buf.enqueue({ id: "a", priority: "BULK", payload: { kind: "chunk" } });
|
||||
now += 10;
|
||||
buf.enqueue({ id: "b", priority: "LOW", payload: { kind: "metric" } });
|
||||
now += 10;
|
||||
buf.enqueue({ id: "c", priority: "NORMAL", payload: { kind: "state" } });
|
||||
|
||||
// Buffer is full — incoming CRITICAL evicts the lowest-priority oldest entry.
|
||||
buf.enqueue({ id: "d", priority: "CRITICAL", payload: { kind: "vote" } });
|
||||
buf.size(); // 3 — BULK ('a') was evicted, CRITICAL accepted
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class SessionPersistenceBuffer<TPayload = unknown>(config?)`
|
||||
- `size(): number`.
|
||||
- `peek(): ReadonlyArray<BufferedMessage<TPayload>>` — non-destructive snapshot.
|
||||
- `enqueue({ id, priority, payload }): BufferedMessage<TPayload>`.
|
||||
- `drain(): ReadonlyArray<BufferedMessage<TPayload>>` — priority-then-FIFO; clears the buffer.
|
||||
- `find(id): BufferedMessage<TPayload> | undefined`.
|
||||
- `remove(id): BufferedMessage<TPayload>` — throws `RECOVERY_NOT_BUFFERED` when the id is missing.
|
||||
- `clear(): void`.
|
||||
- `interface BufferedMessage<TPayload>` — `{ id, priority, payload, enqueuedAt }`.
|
||||
- `interface SessionPersistenceBufferConfig` — `{ capacity?, clock? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The eviction policy walks priorities from BULK toward CRITICAL, so the _lowest-priority_ oldest entry leaves first regardless of arrival order. This matches operator intuition: filling up on telemetry should not push out election votes.
|
||||
- `drain` returns a sorted copy of the entries because callers MAY want to filter or re-enqueue some messages without losing the original ordering. Mutating the returned array does not affect future enqueues.
|
||||
- `BUFFER_OVERFLOW` is only raised when the buffer is at capacity AND every existing entry is CRITICAL AND the incoming is also CRITICAL. In every other shape we make room. Tests cover both branches.
|
||||
- Insertion order matters: `find` and `remove` walk the underlying array; for the typical buffer size (≤ 1024) this is well below the threshold where a `Map`-by-id index would pay for itself.
|
||||
@@ -0,0 +1,81 @@
|
||||
# `TransportManagerPlugin`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Single `NetworkPlugin` that wires every transport-manager subsystem into a `NetworkNode` lifecycle. Owns the `TransportPool` (truth), `CapabilityNegotiator` (peer agreement), `PriorityRouter` + `TransportSelector` + `RoundRobinBalancer` (selection), `TransportHealthMonitor` (observability), `QualityTrendAnalyzer` + `PreemptiveSelector` (early-warning swap), `SessionPersistenceBuffer` + `ConnectionRecoveryManager` + `SessionContinuityGuard` (drop survival), and `BatteryAwareScheduler` + `BandwidthConservator` (resource conservation). Each is exposed as a public field so consumers can inspect or extend without subclassing.
|
||||
|
||||
## Features
|
||||
|
||||
- Single registration — drop into `node.use(plugin)` and every subsystem is wired and reachable.
|
||||
- Public fields per subsystem so consumers can subscribe, override, or instrument selectively.
|
||||
- Idempotent `start` / `stop` / `dispose` lifecycle.
|
||||
- Health monitor receives the node's bus on `install(node)` so `transport:health-change` events surface to subscribers without manual wiring.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { NetworkNode } from "@suaveplan/network";
|
||||
import { TransportManagerPlugin } from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const node = new NetworkNode({ id: "node-1", networkBus: bus });
|
||||
const tm = new TransportManagerPlugin({
|
||||
local: ["webrtc-datachannel", "websocket"],
|
||||
});
|
||||
node.use(tm);
|
||||
await node.start();
|
||||
|
||||
tm.pool.register({ id: "ws-1", capability: "websocket", connected: true });
|
||||
const result = tm.selector.select({
|
||||
priority: "HIGH",
|
||||
commonCapabilities: ["websocket"],
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { NetworkNode } from "@suaveplan/network";
|
||||
import { TransportManagerPlugin } from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const tm = new TransportManagerPlugin({
|
||||
local: ["webrtc-datachannel", "websocket", "http-fetch"],
|
||||
recovery: { baseMs: 200, maxMs: 10_000, factor: 1.7, jitter: 0.4 },
|
||||
health: { pauseBelow: 25, resumeAtOrAbove: 65 },
|
||||
preemptive: { overlapMs: 1_500, minScoreGap: 20 },
|
||||
battery: { lowBatteryAt: 0.15 },
|
||||
bandwidth: { batchIntervalMs: 150 },
|
||||
});
|
||||
node.use(tm);
|
||||
await node.start();
|
||||
|
||||
// Mobile peer: tighten power policy when battery drops
|
||||
tm.battery.update({ onBattery: true, level: 0.10, charging: false });
|
||||
const keepalive = tm.battery.keepaliveMs(); // aggressive backoff
|
||||
|
||||
// On disconnect: enter buffering, then replay on reconnect
|
||||
tm.continuity.enterBuffering();
|
||||
tm.continuity.bufferOutbound({ id: "x", priority: "CRITICAL", payload: "vote" });
|
||||
|
||||
const replay = tm.continuity.enterRecovering();
|
||||
tm.continuity.markConnected();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class TransportManagerPlugin implements NetworkPlugin`
|
||||
- Public fields: `pool`, `negotiator`, `priority`, `selector`, `balancer`, `analyzer`, `preemptive`, `buffer`, `recovery`, `continuity`, `battery`, `bandwidth`, `health` (set on `install`).
|
||||
- `install(node: NetworkNode): void` — wires the health monitor to `node.networkBus`.
|
||||
- `start(): void` / `stop(): void` — idempotent lifecycle.
|
||||
- `dispose(): void` — clears every subsystem (pool, negotiator, priority, balancer, analyzer, buffer, recovery) and detaches the health monitor.
|
||||
- `isStarted(): boolean`.
|
||||
- `interface TransportManagerPluginConfig` — `{ local, recovery?, health?, buffer?, priority?, preemptive?, battery?, bandwidth? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The plugin holds _references_ to subsystems rather than re-exporting their methods so the public surface stays explicit. Callers that want a "facade" can write a thin wrapper around the fields — keeping the plugin minimal makes refactors cheaper.
|
||||
- `health` is the only field that depends on the node's bus, so it is initialised in `install(node)` rather than the constructor. The other subsystems are constructed eagerly so unit tests can build the plugin without a node.
|
||||
- `dispose` does NOT discard the immutable subsystems (those are still referenced via the public fields) but it DOES reset their internal state. Callers that want a clean re-install should construct a new plugin instance — the same trade-off the failover plugin uses.
|
||||
- The lifecycle flag `started` is purely informational. Every subsystem is fully usable without the plugin being started — `start` exists to satisfy the `NetworkPlugin` contract and to give consumers a single hook for "OK, now you may begin processing".
|
||||
@@ -0,0 +1,73 @@
|
||||
# `TransportPool`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Single source of truth for every transport the manager knows about. Each registered transport gets a health snapshot tracked alongside its structural identity, so downstream selectors, balancers, and the health monitor can ask the pool for both the live `TransportLike` reference and the most-recent score in a single lookup.
|
||||
|
||||
## Features
|
||||
|
||||
- Idempotent-friendly registration: duplicate ids raise `TRANSPORT_ALREADY_REGISTERED` so callers decide between update vs recreate.
|
||||
- Health-aware listing: `listHealthy()` returns only the entries that the monitor has not paused.
|
||||
- Direct lookup APIs (`get`, `getHealth`, `has`) so consumers do not need to defensively re-check `Map` semantics.
|
||||
- Pure score function `scoreFromSample({ rttMs, lossRate, stability })` returning a clamped `[0, 100]` value used by the health monitor.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { TransportPool } from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const pool = new TransportPool();
|
||||
pool.register({ id: "ws-1", capability: "websocket", connected: true });
|
||||
const score = pool.getHealth("ws-1").score; // 100 by default
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
TransportPool,
|
||||
scoreFromSample,
|
||||
} from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const pool = new TransportPool();
|
||||
pool.register({ id: "wrtc-1", capability: "webrtc-datachannel", connected: true });
|
||||
|
||||
// After a quality probe…
|
||||
const sample = { rttMs: 220, lossRate: 0.04, stability: 0.92 };
|
||||
const score = scoreFromSample(sample);
|
||||
pool.setHealth("wrtc-1", {
|
||||
rttMs: sample.rttMs,
|
||||
lossRate: sample.lossRate,
|
||||
stability: sample.stability,
|
||||
score,
|
||||
paused: score < 30,
|
||||
});
|
||||
|
||||
for (const entry of pool.listHealthy()) {
|
||||
console.log(entry.transport.id, entry.health.score);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class TransportPool` — in-memory map of `id → TransportEntry`.
|
||||
- `register(transport: TransportLike): TransportEntry` — throws `TRANSPORT_ALREADY_REGISTERED` if the id is taken.
|
||||
- `unregister(transportId: string): void` — throws `TRANSPORT_NOT_REGISTERED` if the id is not present.
|
||||
- `has(transportId: string): boolean`.
|
||||
- `size(): number`.
|
||||
- `get(transportId: string): TransportEntry` — throws `TRANSPORT_NOT_REGISTERED` if missing.
|
||||
- `list(): ReadonlyArray<TransportEntry>` and `listHealthy(): ReadonlyArray<TransportEntry>`.
|
||||
- `getHealth(transportId: string): TransportHealth`.
|
||||
- `setHealth(transportId: string, health: TransportHealth): TransportEntry`.
|
||||
- `clear(): void`.
|
||||
- `DEFAULT_HEALTH` — frozen neutral baseline applied at registration.
|
||||
- `scoreFromSample(input): number` — pure score function; clamps to `[0, 100]`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The pool stores `TransportEntry` as immutable snapshots; `setHealth` replaces the entry rather than mutating it, so consumers can hold a reference safely while the monitor updates the score.
|
||||
- `scoreFromSample` is intentionally simple — half-point per RTT-ms above a 50 ms floor, 50 points for full loss, 25 points for full instability — because real-world transport selection is dominated by the relative ordering, not the absolute number. Tests pin the formula at boundary values.
|
||||
- Errors are thrown rather than returned as `Result<T>` because the pool sits inside a single trust boundary (the manager process) and registration mistakes indicate caller bugs that should fail fast.
|
||||
@@ -0,0 +1,63 @@
|
||||
# `PriorityRouter`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Classifies outbound traffic into the five tiers (`CRITICAL`, `HIGH`, `NORMAL`, `LOW`, `BULK`) and answers two operational questions per message: how many transports the selector should fan out to, and whether duplicate arrivals on the receiver side should be suppressed. The router holds the policy table — the selector and balancer only honour what it returns.
|
||||
|
||||
## Features
|
||||
|
||||
- Default config table that mirrors the transport-priority matrix in the proposal (CRITICAL → 2-of-3 redundant, all other tiers single-transport).
|
||||
- Per-tier overrides via the `config` constructor argument.
|
||||
- Bounded dedup tracker (`maxSeenPerTier`, default 4096) so the receiver does not leak memory under sustained CRITICAL traffic.
|
||||
- `isRedundant(priority)` predicate so callers can gate redundant-send code paths without reaching into the config struct.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { PriorityRouter } from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const router = new PriorityRouter();
|
||||
router.isRedundant("CRITICAL"); // true
|
||||
router.isRedundant("NORMAL"); // false
|
||||
const cfg = router.configFor("HIGH"); // { minTransports: 1, maxTransports: 1, dedup: false }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { PriorityRouter } from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const router = new PriorityRouter(
|
||||
{
|
||||
// Allow CRITICAL fan-out to 4 transports in this deployment
|
||||
CRITICAL: { minTransports: 2, maxTransports: 4, dedup: true },
|
||||
// Treat BULK as best-effort: skip dedup, single transport
|
||||
BULK: { minTransports: 1, maxTransports: 1, dedup: false },
|
||||
},
|
||||
{ maxSeenPerTier: 1024 }
|
||||
);
|
||||
|
||||
// Receiver-side dedup
|
||||
const id = "msg-7";
|
||||
router.shouldDeliver("CRITICAL", id); // true (first arrival)
|
||||
router.shouldDeliver("CRITICAL", id); // false (duplicate from redundant transport)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class PriorityRouter`
|
||||
- `constructor(config?: Partial<Record<MessagePriority, PriorityConfig>>, options?: { maxSeenPerTier?: number })`.
|
||||
- `configFor(priority): PriorityConfig` — throws `PRIORITY_UNKNOWN` for non-tier values (defends against caller bugs that pass arbitrary strings).
|
||||
- `shouldDeliver(priority, messageId): boolean` — returns `true` for non-dedup tiers; for dedup tiers returns `false` on duplicate ids and trims the oldest seen-id once the per-tier cap is reached.
|
||||
- `isRedundant(priority): boolean` — `true` when `minTransports > 1`.
|
||||
- `reset(): void` — drops every dedup bucket.
|
||||
- `DEFAULT_PRIORITY_CONFIG` — frozen baseline policy table.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The dedup tracker uses `Set` for O(1) lookup and relies on JavaScript's insertion-order iteration to evict the oldest id when the cap is hit. A min-heap / LRU would scale better past ~10k ids but is unnecessary for the per-tier defaults.
|
||||
- Dedup is per-tier rather than global because the same message id MAY appear on different tiers (e.g. retried as CRITICAL after originally sending as NORMAL) and the receiver should evaluate each independently.
|
||||
- `configFor` throws on unknown tiers rather than returning `undefined` because the rest of the manager treats `MessagePriority` as a closed union — receiving an arbitrary string here means a programming error elsewhere that should fail loudly.
|
||||
@@ -0,0 +1,60 @@
|
||||
# `ConnectionRecoveryManager`
|
||||
|
||||
## Purpose
|
||||
|
||||
Generates exponential-backoff delays with bounded full-jitter so the manager can pace reconnection attempts without thundering-herd or starving CRITICAL traffic forever. Encapsulates the state machine of "current attempt count" so callers do not have to track it themselves; resetting on a successful reconnect re-arms the curve from zero.
|
||||
|
||||
## Features
|
||||
|
||||
- Exponential growth (`base * factor ^ (attempt - 1)`) with a hard upper bound (`maxMs`).
|
||||
- Bounded jitter centred on the exponential ceiling so two peers reconnecting after the same outage do not collide on the second.
|
||||
- Deterministic via injectable `rng` so tests can pin sequences exactly.
|
||||
- `attempts()` getter and `reset()` exposed for higher-level orchestration (e.g. record a counter or roll back state on success).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ConnectionRecoveryManager } from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const recovery = new ConnectionRecoveryManager();
|
||||
const first = recovery.nextDelay(); // ~250 ms ± jitter
|
||||
const second = recovery.nextDelay(); // ~500 ms ± jitter
|
||||
const third = recovery.nextDelay(); // ~1000 ms ± jitter
|
||||
recovery.reset();
|
||||
recovery.nextDelay().attempt; // 1 — sequence restarts
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { ConnectionRecoveryManager } from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const sequence: number[] = [];
|
||||
const recovery = new ConnectionRecoveryManager({
|
||||
baseMs: 100,
|
||||
maxMs: 5_000,
|
||||
factor: 1.8,
|
||||
jitter: 0.3,
|
||||
rng: () => 0.5, // deterministic — half-jitter centre
|
||||
});
|
||||
|
||||
while (recovery.attempts() < 8) {
|
||||
sequence.push(recovery.nextDelay().delayMs);
|
||||
}
|
||||
// sequence climbs 100 → 180 → 324 → ... and caps at 5000
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class ConnectionRecoveryManager(config?)`
|
||||
- `nextDelay(): RecoverySchedule` — `{ attempt, delayMs, capped }`. Always advances the counter.
|
||||
- `attempts(): number` — current attempt count.
|
||||
- `reset(): void` — restart the sequence from attempt 0.
|
||||
- `interface ConnectionRecoveryManagerConfig` — `{ baseMs?, maxMs?, factor?, jitter?, rng? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Jitter is "full" relative to the exponential ceiling: the actual delay lands in `[ceiling * (1 - jitter), ceiling * (1 + jitter)]`, then clamped to `[0, maxMs]`. With the default `jitter = 0.5`, two peers reconnecting will land in disjoint half-second windows on average.
|
||||
- The schedule advances on every `nextDelay()` call regardless of whether the caller actually used the result. This is intentional — callers that abandon a schedule should `reset()` so the next `nextDelay()` does not start from a high attempt number.
|
||||
- `clamp01(jitter)` defends against misconfiguration: a `jitter` of 1.5 would otherwise produce negative delays, which would be silently treated as zero by `setTimeout`.
|
||||
- The class is intentionally state-only — actually scheduling the timer lives in the recovery flow that owns the `setTimeout`/`AbortSignal` lifetime.
|
||||
@@ -0,0 +1,80 @@
|
||||
# `SessionContinuityGuard`
|
||||
|
||||
## Purpose
|
||||
|
||||
Owns the session-state machine around the persistence buffer so callers cannot bypass the documented sequence: a session must enter `buffering` before it can `recover`, and only the recovering state drains the buffer for replay. Centralising this logic keeps the manager free of ad-hoc state checks scattered across modules.
|
||||
|
||||
## Features
|
||||
|
||||
- Three explicit states (`connected`, `buffering`, `recovering`) with strict transition guards backed by `INVALID_TRANSITION`.
|
||||
- `enterRecovering()` returns the priority-ordered drain in a single atomic step so callers cannot replay against a half-cleared buffer.
|
||||
- Optional `BusLike` for `session:state-change` observability events; injectable clock for deterministic tests.
|
||||
- `bufferOutbound(...)` gateway refuses to buffer while the session is still connected — that would silently swallow messages.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
SessionPersistenceBuffer,
|
||||
SessionContinuityGuard,
|
||||
} from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const buffer = new SessionPersistenceBuffer<string>();
|
||||
const guard = new SessionContinuityGuard(buffer);
|
||||
|
||||
guard.enterBuffering();
|
||||
guard.bufferOutbound({ id: "m1", priority: "CRITICAL", payload: "vote" });
|
||||
|
||||
// Reconnect: drain in priority order, then mark connected.
|
||||
const replay = guard.enterRecovering();
|
||||
replay[0].id; // "m1"
|
||||
guard.markConnected();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
SessionPersistenceBuffer,
|
||||
SessionContinuityGuard,
|
||||
} from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const events: unknown[] = [];
|
||||
const bus = {
|
||||
emit: (event: string, payload: unknown) => events.push({ event, payload }),
|
||||
};
|
||||
|
||||
let now = 1_700_000_000_000;
|
||||
const buffer = new SessionPersistenceBuffer<string>();
|
||||
const guard = new SessionContinuityGuard(buffer, bus, { clock: () => now });
|
||||
|
||||
try {
|
||||
guard.bufferOutbound({ id: "x", priority: "NORMAL", payload: "" });
|
||||
} catch (err) {
|
||||
// INVALID_TRANSITION — must enter buffering first
|
||||
}
|
||||
|
||||
guard.enterBuffering();
|
||||
now += 5;
|
||||
guard.bufferOutbound({ id: "y", priority: "NORMAL", payload: "" });
|
||||
const drained = guard.enterRecovering();
|
||||
guard.markConnected();
|
||||
events.length; // 3 — buffering, recovering, connected
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class SessionContinuityGuard<TPayload = unknown>(buffer, bus?, config?)`
|
||||
- `currentState(): SessionState`.
|
||||
- `enterBuffering(): void` — throws `INVALID_TRANSITION` from non-connected states.
|
||||
- `enterRecovering(): ReadonlyArray<BufferedMessage<TPayload>>` — throws `INVALID_TRANSITION` from non-buffering states; returns the drain in priority-then-FIFO order.
|
||||
- `markConnected(): void` — idempotent no-op when already connected.
|
||||
- `bufferOutbound({ id, priority, payload }): BufferedMessage<TPayload>` — throws `INVALID_TRANSITION` when called from `connected`.
|
||||
- `type SessionState` — `"connected" | "buffering" | "recovering"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Transitions are deliberately one-directional except for the `recovering → connected → buffering` re-entry path, which mirrors the wire-level reality: a session that recovers fully can later enter buffering again on a new drop.
|
||||
- The guard does not own a timer of its own — `ConnectionRecoveryManager` handles delays. Splitting the concerns keeps each class trivially unit-testable.
|
||||
- `markConnected` is idempotent so handlers wired to multiple sources (e.g. a peer-event listener AND a transport-event listener) cannot trip the guard by both calling it.
|
||||
- `bufferOutbound` could quietly send-through when `state === "connected"`, but failing loudly catches caller bugs much earlier than a silent fallback would.
|
||||
@@ -0,0 +1,63 @@
|
||||
# `BandwidthConservator`
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements `NET-124`: when the manager is told it has a constrained bandwidth budget, this module reports the recommended message-coalescing interval, decides whether the redundant copy of a CRITICAL message should be suppressed, and gates per-priority coalescing. Like `BatteryAwareScheduler`, it is advisory — callers honour the recommendations rather than having timers fire from inside the conservator.
|
||||
|
||||
## Features
|
||||
|
||||
- Two-tier coalescing: normal conservation uses the configured batch interval; bytes/sec below the "extreme" threshold quadruples the interval.
|
||||
- Always flushes CRITICAL immediately even when coalescing is on so heartbeats and election votes do not sit in batches.
|
||||
- Optional suppression of redundant CRITICAL fan-out when the budget is extreme — better to send once than to drop the message because the budget burned out on duplicates.
|
||||
- Inert when `conserveActive: false` (the production fast-path) so the conservator costs nothing in normal operation.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { BandwidthConservator } from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const conservator = new BandwidthConservator();
|
||||
conservator.update({ conserveActive: true, bytesPerSec: 32 * 1024 });
|
||||
conservator.batchInterval(); // 200 ms
|
||||
conservator.shouldCoalesce("NORMAL"); // true
|
||||
conservator.shouldCoalesce("CRITICAL"); // false
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { BandwidthConservator } from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const conservator = new BandwidthConservator({
|
||||
batchIntervalMs: 250,
|
||||
extremeBatchMultiplier: 6,
|
||||
extremeBytesPerSec: 2_048,
|
||||
});
|
||||
|
||||
// Tethered to a slow link
|
||||
conservator.update({ conserveActive: true, bytesPerSec: 1_024 });
|
||||
conservator.batchInterval(); // 1500 ms — extreme
|
||||
conservator.suppressRedundantCritical(); // true — drop CRITICAL duplicates
|
||||
|
||||
// Back on Wi-Fi
|
||||
conservator.update({ conserveActive: false, bytesPerSec: 10_000_000 });
|
||||
conservator.batchInterval(); // 0 — no batching
|
||||
conservator.suppressRedundantCritical(); // false
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class BandwidthConservator(config?)`
|
||||
- `update(signal: BandwidthSignal): void`.
|
||||
- `currentSignal(): BandwidthSignal`.
|
||||
- `batchInterval(): number` — `0` when inactive, `batchIntervalMs` otherwise, multiplied at extreme budgets.
|
||||
- `suppressRedundantCritical(): boolean` — only `true` when active AND extreme.
|
||||
- `shouldCoalesce(priority: MessagePriority): boolean` — `false` for CRITICAL always; `false` when inactive; `true` for the other tiers under conservation.
|
||||
- `interface BandwidthSignal` — `{ conserveActive, bytesPerSec }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `bytesPerSec` is a single advisory number; the conservator does not enforce it. Real enforcement (token-bucket, write-side throttling) is done elsewhere in the dispatcher pipeline. Splitting policy from enforcement keeps each module trivially testable.
|
||||
- The "extreme" threshold defaults to 4 KiB/s — roughly the floor where modern protocols (HTTPS handshakes, ICE keepalive) become uncomfortable. Tune via `extremeBytesPerSec` for deployment-specific links (e.g. satellite).
|
||||
- CRITICAL is the one priority that opts out of coalescing because batching a CRITICAL message into a delayed flush window negates the entire point of the priority tier.
|
||||
- `suppressRedundantCritical` is intentionally *not* the inverse of "`isRedundant("CRITICAL")` in the priority router". The router decides what the system *would* like to do; the conservator decides what the system can *afford* to do. Both decisions feed the dispatcher.
|
||||
@@ -0,0 +1,68 @@
|
||||
# `BatteryAwareScheduler`
|
||||
|
||||
## Purpose
|
||||
|
||||
Reduces keepalive frequency and prefers low-power transports when the host device is running on battery. Implements `NET-123` — a mobile peer should not burn 30 % of its remaining charge on WebRTC ICE keepalives when WebSocket would carry the same traffic for half the energy budget.
|
||||
|
||||
## Features
|
||||
|
||||
- Two backoff multipliers: a moderate one above the low-battery threshold and an aggressive one at/below it.
|
||||
- Charging state respected: even on battery, plugged-in laptops use the AC keepalive cadence.
|
||||
- `preferLowPower()` returns transports in `highPower: false` first order so the manager rotates through cheap paths before expensive ones.
|
||||
- Pure advisory API — no timers scheduled internally. Callers retain control over scheduling primitives so the policy stays test-friendly.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
TransportPool,
|
||||
BatteryAwareScheduler,
|
||||
} from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const pool = new TransportPool();
|
||||
const scheduler = new BatteryAwareScheduler(pool);
|
||||
|
||||
scheduler.update({ onBattery: true, level: 0.65, charging: false });
|
||||
scheduler.keepaliveMs(); // 10_000 — 2× the AC cadence
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
TransportPool,
|
||||
BatteryAwareScheduler,
|
||||
} from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const pool = new TransportPool();
|
||||
pool.register({ id: "wrtc", capability: "webrtc-datachannel", connected: true, highPower: true });
|
||||
pool.register({ id: "ws", capability: "websocket", connected: true });
|
||||
|
||||
const scheduler = new BatteryAwareScheduler(pool, {
|
||||
baseKeepaliveMs: 4_000,
|
||||
batteryMultiplier: 3,
|
||||
lowBatteryMultiplier: 6,
|
||||
lowBatteryAt: 0.15,
|
||||
});
|
||||
|
||||
scheduler.update({ onBattery: true, level: 0.10, charging: false });
|
||||
scheduler.keepaliveMs(); // 24_000 — aggressive backoff
|
||||
scheduler.preferLowPower()[0].id; // "ws" — low-power first
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class BatteryAwareScheduler(pool, config?)`
|
||||
- `update(signal: BatterySignal): void`.
|
||||
- `currentSignal(): BatterySignal`.
|
||||
- `keepaliveMs(): number` — current recommended keepalive interval.
|
||||
- `preferLowPower(): ReadonlyArray<TransportLike>` — healthy transports reordered low-power first when on battery.
|
||||
- `interface BatterySignal` — `{ onBattery, level, charging }`.
|
||||
- `interface BatteryAwareSchedulerConfig` — `{ baseKeepaliveMs?, batteryMultiplier?, lowBatteryMultiplier?, lowBatteryAt? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The scheduler is *advisory*: it does not subscribe to OS-level battery events, schedule timers, or pause keepalive probes itself. Callers integrate it with their own probe scheduler so the production code path matches the test harness exactly.
|
||||
- `charging: true` short-circuits to the AC cadence even when `onBattery: true` — a laptop topped up at the desk is effectively on AC for the duration of the session.
|
||||
- The reorder is stable: ties (two non-high-power transports) keep the pool insertion order so callers get deterministic rotation across calls when the battery signal does not change.
|
||||
- The `highPower` hint is opt-in (`undefined` means "not specified"). Transports without the hint are treated as low-power, which is the safer default — the worst case is using a slightly hungrier transport than necessary, not skipping a critical path.
|
||||
@@ -0,0 +1,70 @@
|
||||
# `PreemptiveSelector`
|
||||
|
||||
## Purpose
|
||||
|
||||
Decides whether to swap a still-functioning transport for a healthier alternative *before* the current one drops to below the pause threshold. Combines the `QualityTrendAnalyzer` (is the path getting worse?) with the `TransportPool` (do we have a better path?) and returns a `PreemptiveHandoff` describing how to transition with overlap so in-flight messages do not hit a black hole.
|
||||
|
||||
## Features
|
||||
|
||||
- Two-input gating: only proposes a swap when the current path is degrading **and** an alternative exists with at least `minScoreGap` more health.
|
||||
- Returns `null` for the safe default — leave the transport in place — so callers can use the result directly without re-implementing boolean logic.
|
||||
- Smooth-handoff plan: establish-before-teardown with a configurable overlap window (default 750 ms) for messages already in flight.
|
||||
- `requireBestAlternative` throws `NO_HEALTHY_TRANSPORT` for callers (e.g. emergency manual swap) that cannot tolerate a `null` return.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
TransportPool,
|
||||
QualityTrendAnalyzer,
|
||||
PreemptiveSelector,
|
||||
} from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const pool = new TransportPool();
|
||||
pool.register({ id: "wrtc", capability: "webrtc-datachannel", connected: true });
|
||||
pool.register({ id: "ws", capability: "websocket", connected: true });
|
||||
|
||||
const analyzer = new QualityTrendAnalyzer();
|
||||
const selector = new PreemptiveSelector(pool, analyzer);
|
||||
|
||||
// Feed a clean WebSocket vs degrading WebRTC profile
|
||||
analyzer.record("wrtc", { latency: 60, loss: 0, bandwidthBps: 1_000_000 });
|
||||
analyzer.record("wrtc", { latency: 220, loss: 0.05, bandwidthBps: 600_000 });
|
||||
pool.setHealth("wrtc", { rttMs: 220, lossRate: 0.05, stability: 0.7, score: 35, paused: false });
|
||||
pool.setHealth("ws", { rttMs: 70, lossRate: 0, stability: 1, score: 95, paused: false });
|
||||
|
||||
const plan = selector.evaluate("wrtc");
|
||||
// plan.fromTransportId === "wrtc"
|
||||
// plan.toTransport.id === "ws"
|
||||
// plan.overlapMs === 750
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
const selector = new PreemptiveSelector(pool, analyzer, {
|
||||
overlapMs: 1500, // longer overlap for satellite paths
|
||||
minScoreGap: 25, // only swap when the alternative is materially better
|
||||
});
|
||||
|
||||
const plan = selector.evaluate(currentId);
|
||||
if (plan !== null) {
|
||||
await dial(plan.toTransport.id);
|
||||
setTimeout(() => teardown(plan.fromTransportId), plan.overlapMs);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class PreemptiveSelector(pool, analyzer, config?)`
|
||||
- `evaluate(currentTransportId): PreemptiveHandoff | null` — `null` means "leave current path in place".
|
||||
- `requireBestAlternative(currentTransportId): { transport, score }` — throws `NO_HEALTHY_TRANSPORT` when no alternative exists.
|
||||
- `interface PreemptiveHandoff` — `{ fromTransportId, toTransport, overlapMs }`.
|
||||
- `interface PreemptiveSelectorConfig` — `{ overlapMs?, minScoreGap? }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `evaluate` re-derives the degradation flag from the analyzer's most recent two samples instead of trusting a cached `degrading` boolean. This keeps the selector and the analyzer decoupled — callers may have recorded samples since the last `record` return value was inspected.
|
||||
- `minScoreGap` is critical to prevent thrash: without it, two transports with scores 71 and 70 would swap on every sample. Default `10` matches the hysteresis used by the `TransportHealthMonitor`.
|
||||
- The overlap window is the manager's responsibility to honour — the selector only reports the recommended duration. This separation lets the dispatcher own timer management (which it already does for retries) instead of forking timers across two modules.
|
||||
- The first call to `evaluate` for a fresh transport returns `null` because the analyzer has < 2 samples and cannot compute a delta. Tests pin this boundary explicitly.
|
||||
@@ -0,0 +1,87 @@
|
||||
# `TransportSelector`
|
||||
|
||||
## Purpose
|
||||
|
||||
Translates a `(priority, commonCapabilities)` ask into a concrete, ordered list of transports drawn from the live pool. The selector is the single boundary that combines three independent inputs — the pool's per-transport health, the router's fan-out policy, and the negotiator's per-peer capability intersection — into a deterministic selection. Downstream, the dispatcher just sends to whatever the selector returns.
|
||||
|
||||
## Features
|
||||
|
||||
- Health-sorted (`score` desc) shortlist filtered to candidates that are connected, in the negotiated capability set, and not on the caller's exclusion list.
|
||||
- Honours the priority router's `minTransports`/`maxTransports` per tier and lets callers force redundant fan-out via `redundant: true`.
|
||||
- `NO_HEALTHY_TRANSPORT` is raised when the candidate set is empty so callers do not silently swallow a partition.
|
||||
- `candidates(...)` exposed for tests and for the round-robin balancer that wraps the selector.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
TransportPool,
|
||||
PriorityRouter,
|
||||
TransportSelector,
|
||||
} from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const pool = new TransportPool();
|
||||
const router = new PriorityRouter();
|
||||
pool.register({ id: "ws", capability: "websocket", connected: true });
|
||||
pool.register({ id: "wrtc", capability: "webrtc-datachannel", connected: true });
|
||||
|
||||
const selector = new TransportSelector(pool, router);
|
||||
const result = selector.select({
|
||||
priority: "HIGH",
|
||||
commonCapabilities: ["webrtc-datachannel", "websocket"],
|
||||
});
|
||||
result.chosen[0].id; // highest-scoring of the two
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
TransportPool,
|
||||
PriorityRouter,
|
||||
TransportSelector,
|
||||
} from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const pool = new TransportPool();
|
||||
const router = new PriorityRouter();
|
||||
pool.register({ id: "ws-a", capability: "websocket", connected: true });
|
||||
pool.register({ id: "ws-b", capability: "websocket", connected: true });
|
||||
pool.register({ id: "wrtc", capability: "webrtc-datachannel", connected: true });
|
||||
pool.setHealth("ws-a", { rttMs: 30, lossRate: 0, stability: 1, score: 100, paused: false });
|
||||
pool.setHealth("ws-b", { rttMs: 80, lossRate: 0.01, stability: 0.9, score: 70, paused: false });
|
||||
pool.setHealth("wrtc", { rttMs: 60, lossRate: 0, stability: 1, score: 90, paused: false });
|
||||
|
||||
const selector = new TransportSelector(pool, router);
|
||||
|
||||
// CRITICAL fans out across the top two transports for redundancy
|
||||
const critical = selector.select({
|
||||
priority: "CRITICAL",
|
||||
commonCapabilities: ["webrtc-datachannel", "websocket"],
|
||||
});
|
||||
critical.chosen.length; // 2
|
||||
critical.redundant; // true
|
||||
|
||||
// Force redundant fan-out on a normally single-transport tier
|
||||
const forced = selector.select({
|
||||
priority: "NORMAL",
|
||||
commonCapabilities: ["websocket"],
|
||||
redundant: true,
|
||||
});
|
||||
forced.chosen.length; // 2
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class TransportSelector(pool, router)`
|
||||
- `select(config: SelectorConfig): SelectionResult` — throws `NO_HEALTHY_TRANSPORT` when no candidate matches.
|
||||
- `candidates(priority, commonCapabilities, excludeTransportIds): ReadonlyArray<TransportEntry>` — health-sorted list before fan-out cap is applied.
|
||||
- `selectIds(config): ReadonlyArray<string>` — convenience that returns just the chosen ids.
|
||||
- `interface SelectorConfig` — `{ priority, commonCapabilities, excludeTransportIds?, redundant? }`.
|
||||
- `interface SelectionResult` — `{ priority, chosen, redundant }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The candidate filter uses `Set` for both the capability allowlist and the exclusion list (`O(n)` filter where `n` = pool size). Both sets are short for typical mesh sizes (≤ 20 transports per node).
|
||||
- Sorting is stable in V8/SpiderMonkey for arrays smaller than ~10 items, so transports with identical scores keep their pool insertion order — this means the manager picks the same redundant pair across calls for a given health snapshot, which is what tests rely on.
|
||||
- The `priority` parameter to `candidates` is currently informational. It stays in the signature so deployment-specific overrides (e.g. excluding `relay-via-host` from CRITICAL) can subclass the selector without API churn.
|
||||
- `redundant: true` clamps `minTransports` to `>= 2` rather than overriding `maxTransports` because callers asking for redundancy still want to respect the configured ceiling.
|
||||
@@ -0,0 +1,44 @@
|
||||
# Priority types — `@suaveplan/network-plugin-transport-manager`
|
||||
|
||||
## Purpose
|
||||
|
||||
Shared types for the five-tier priority router (`NET-25`). The router uses a closed `MessagePriority` union so consumers cannot accidentally pass arbitrary strings, and `PriorityConfig` carries the per-tier knobs (`minTransports`, `maxTransports`, `dedup`) the router exposes for overrides.
|
||||
|
||||
## Features
|
||||
|
||||
- Closed union of five tiers in spec order: `CRITICAL`, `HIGH`, `NORMAL`, `LOW`, `BULK`.
|
||||
- `PriorityConfig` describes everything a tier needs to operationalise: how many transports to fan out to, how many at most, and whether duplicate arrivals should be deduplicated on the receiver side.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { MessagePriority } from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const tier: MessagePriority = "CRITICAL";
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
MessagePriority,
|
||||
PriorityConfig,
|
||||
} from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const overrides: Partial<Record<MessagePriority, PriorityConfig>> = {
|
||||
CRITICAL: { minTransports: 2, maxTransports: 4, dedup: true },
|
||||
BULK: { minTransports: 1, maxTransports: 1, dedup: false },
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type MessagePriority` — `"CRITICAL" | "HIGH" | "NORMAL" | "LOW" | "BULK"`.
|
||||
- `interface PriorityConfig` — `{ minTransports: number; maxTransports: number; dedup: boolean }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The union order matches the priority order used by `PriorityRouter` and `SessionPersistenceBuffer`: CRITICAL is "highest", BULK is "lowest". Several modules use this ordering to walk priorities, so reordering the union would be a breaking change at runtime even though the type system would accept it.
|
||||
- `dedup: true` is intentionally opt-in per tier — most tiers do not need it because they fan out to a single transport, so duplicate arrivals are impossible by construction.
|
||||
- Adding a tier (e.g. `REALTIME`) would require coordinated changes to the router default config table, the persistence buffer's eviction policy, the bandwidth conservator's `shouldCoalesce` switch, and every spec-conformance test that pins the closed set. It is not a drop-in extension.
|
||||
- The type lives in a dedicated file so cross-module type-only imports (`import type { MessagePriority } from "..."`) do not pull in any runtime code.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Selector types — `@suaveplan/network-plugin-transport-manager`
|
||||
|
||||
## Purpose
|
||||
|
||||
Input/output shapes for `TransportSelector.select(...)`. The selector translates a `(priority, capabilities)` ask into a concrete transport list, so the contract belongs in a dedicated types file that both the selector and its callers (priority router, plugin facade, custom dispatchers) can import without depending on the selector implementation.
|
||||
|
||||
## Features
|
||||
|
||||
- `SelectorConfig` — every input the selector needs in a single struct: priority, the capability intersection from the negotiator, an optional exclude list (for "the path I just dialed failed, give me another"), and an optional `redundant: true` override that forces fan-out on tiers that would otherwise pick a single transport.
|
||||
- `SelectionResult` — the chosen transports in send order plus a `redundant` boolean callers can switch on to enable receiver-side dedup.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
SelectionResult,
|
||||
SelectorConfig,
|
||||
} from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const ask: SelectorConfig = {
|
||||
priority: "HIGH",
|
||||
commonCapabilities: ["webrtc-datachannel", "websocket"],
|
||||
};
|
||||
|
||||
declare const result: SelectionResult;
|
||||
result.chosen.forEach((t) => console.log(t.id));
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
SelectorConfig,
|
||||
} from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
// Force fan-out even on a tier that the router treats as single-transport
|
||||
const forced: SelectorConfig = {
|
||||
priority: "NORMAL",
|
||||
commonCapabilities: ["websocket", "http-fetch"],
|
||||
excludeTransportIds: ["ws-flapping-1"],
|
||||
redundant: true,
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface SelectorConfig` — `{ priority, commonCapabilities, excludeTransportIds?, redundant? }`.
|
||||
- `interface SelectionResult` — `{ priority, chosen, redundant }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `commonCapabilities` is `ReadonlyArray<TransportCapability>` rather than `Set<TransportCapability>` so callers can preserve a deterministic preference order and pass the result of `CapabilityNegotiator.commonWith(peerId)` directly.
|
||||
- `excludeTransportIds` is the boundary the dispatcher uses to communicate "this path just failed, do not pick it again on retry". Including it in the config struct rather than the router state keeps retries observable and stateless.
|
||||
- `SelectionResult.priority` is echoed back so callers logging or instrumenting the result do not need to remember which priority they asked for — important when the selector is wrapped in a generic dispatcher pipeline.
|
||||
- These types live separately from the runtime selector module so cross-file type imports stay zero-cost at runtime.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Transport types — `@suaveplan/network-plugin-transport-manager`
|
||||
|
||||
## Purpose
|
||||
|
||||
Shared structural types for transport-manager subsystems. These types encode the contract between the manager and the underlying transports (`network-plugin-webrtc`, `-websocket`, `-http`, …) without forcing the manager to depend on any concrete implementation. Concrete transports satisfy `TransportLike` structurally, so the manager can be tested with synthetic transports and the production code path matches the test path.
|
||||
|
||||
## Features
|
||||
|
||||
- `TransportCapability` — closed union of the seven capability bitmap values exchanged at peer handshake.
|
||||
- `TransportHealth` / `TransportHealthSample` — input/output shapes for the health monitor pipeline.
|
||||
- `TransportLike` — minimal structural contract a registered transport must satisfy. Includes the optional `bandwidthHintBps` and `highPower` fields the bandwidth conservator and battery scheduler consume when deciding whether to coalesce or rotate.
|
||||
- `TransportEntry` — pool-stored tuple of `{ transport, health }` so consumers get both in a single lookup.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
TransportCapability,
|
||||
TransportLike,
|
||||
} from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const cap: TransportCapability = "websocket";
|
||||
const t: TransportLike = {
|
||||
id: "ws-1",
|
||||
capability: cap,
|
||||
connected: true,
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
TransportEntry,
|
||||
TransportHealth,
|
||||
TransportLike,
|
||||
} from "@suaveplan/network-plugin-transport-manager";
|
||||
|
||||
const transport: TransportLike = {
|
||||
id: "wrtc",
|
||||
capability: "webrtc-datachannel",
|
||||
connected: true,
|
||||
bandwidthHintBps: 5_000_000,
|
||||
highPower: true,
|
||||
};
|
||||
|
||||
const health: TransportHealth = {
|
||||
rttMs: 60,
|
||||
lossRate: 0.01,
|
||||
stability: 0.95,
|
||||
score: 92,
|
||||
paused: false,
|
||||
};
|
||||
|
||||
const entry: TransportEntry = { transport, health };
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type TransportCapability` — `"webrtc-datachannel" | "websocket" | "http-fetch" | "http-sse" | "bluetooth-le" | "local-network-mdns" | "relay-via-host"`.
|
||||
- `interface TransportHealth` — `{ rttMs, lossRate, stability, score, paused }` — every field is `readonly` because the pool stores frozen snapshots.
|
||||
- `interface TransportHealthSample` — `{ rttMs, lossRate, dropped? }`.
|
||||
- `interface TransportLike` — `{ id, capability, connected, bandwidthHintBps?, highPower? }`.
|
||||
- `interface TransportEntry` — `{ transport, health }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The capability list is intentionally closed: extending it requires bumping the wire protocol because peers advertise capability bitmaps to each other. New capabilities should land via OpenSpec change so that the negotiator and selector can validate them across versions.
|
||||
- `TransportHealth.score` is documented as `0..100` and `lossRate` / `stability` as `[0, 1]`. The pool clamps both at the boundary; consumers that build a `TransportHealth` directly are expected to honour the same ranges.
|
||||
- `highPower` is opt-in (`undefined` means "not specified"). Battery-aware scheduling treats unspecified as low-power — a safer default than over-pessimistic rotation.
|
||||
- `TransportEntry` is treated as immutable: `TransportPool.setHealth` replaces the entry rather than mutating it so consumers can hold a reference to the prior snapshot safely.
|
||||
Reference in New Issue
Block a user