first commit
This commit is contained in:
71
docs/networking/network/network-transport-standby/README.md
Normal file
71
docs/networking/network/network-transport-standby/README.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# @suaveplan/network-transport-standby - Pre-emptive WebRTC standby transport: warm-slot connection pool with health scoring and auto-reconnect for sub-100ms peer attach
|
||||
|
||||
## Summary
|
||||
|
||||
Pre-emptive WebRTC standby transport for `@suaveplan/network`. Maintains
|
||||
a pool of warm `RTCPeerConnection`-like slots so peer attach completes
|
||||
in ~10 ms instead of the typical 1-3 s ICE/DTLS handshake.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [health](./src/health/monitor.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [monitor](./src/health/monitor.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [pool](./src/pool/pool.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [pool](./src/pool/pool.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [reconnect](./src/reconnect/reconnect.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [reconnect](./src/reconnect/reconnect.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs --> - [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [health](./src/health/monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [monitor](./src/health/monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [pool](./src/pool/pool.md) — <!-- begin:auto-docs --> _(no submodules)_- [pool](./src/pool/pool.md) — <!-- begin:auto-docs --> _(no submodules)_- [reconnect](./src/reconnect/reconnect.md) — <!-- begin:auto-docs --> _(no submodules)_- [reconnect](./src/reconnect/reconnect.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [health](./src/health/monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [monitor](./src/health/monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [pool](./src/pool/pool.md) — <!-- begin:auto-docs --> _(no submodules)_- [pool](./src/pool/pool.md) — <!-- begin:auto-docs --> _(no submodules)_- [reconnect](./src/reconnect/reconnect.md) — <!-- begin:auto-docs --> _(no submodules)_- [reconnect](./src/reconnect/reconnect.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [health](./src/health/monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [monitor](./src/health/monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [pool](./src/pool/pool.md) — <!-- begin:auto-docs --> _(no submodules)_- [pool](./src/pool/pool.md) — <!-- begin:auto-docs --> _(no submodules)_- [reconnect](./src/reconnect/reconnect.md) — <!-- begin:auto-docs --> _(no submodules)_- [reconnect](./src/reconnect/reconnect.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [health](./src/health/monitor.md) — <!-- begin:auto-docs --> _(no submodules)_ - [monitor](./src/health/monitor.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [otel](./src/otel/otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ - [plugin](./src/plugin/plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [pool](./src/pool/pool.md) — <!-- begin:auto-docs --> _(no submodules)_ - [pool](./src/pool/pool.md) — <!-- begin:auto-docs --> _(no submodules)_- [reconnect](./src/reconnect/reconnect.md) — <!-- begin:auto-docs --> _(no submodules)_ - [reconnect](./src/reconnect/reconnect.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_ - [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ - [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_
|
||||
## Module map
|
||||
|
||||
- [`src/types/types.md`](./src/types/types.md) — shared types.
|
||||
- [`src/errors/errors.md`](./src/errors/errors.md) — 6-code error
|
||||
registry + `NetStandbyError`.
|
||||
- [`src/health/monitor.md`](./src/health/monitor.md) — EMA-smoothed
|
||||
composite health scorer.
|
||||
- [`src/otel/otel.md`](./src/otel/otel.md) — gauge, counters,
|
||||
histogram + span helper.
|
||||
- [`src/reconnect/reconnect.md`](./src/reconnect/reconnect.md) —
|
||||
exponential-backoff retry loop over `@suaveplan/retry`.
|
||||
- [`src/pool/pool.md`](./src/pool/pool.md) — connection-pool engine.
|
||||
- [`src/transport/transport.md`](./src/transport/transport.md) —
|
||||
`Transport` adapter implementing `@suaveplan/network`'s contract.
|
||||
- [`src/plugin/plugin.md`](./src/plugin/plugin.md) — `NetworkPlugin`
|
||||
wrapper.
|
||||
|
||||
## At a glance
|
||||
|
||||
```ts
|
||||
import { NetworkNode } from "@suaveplan/network";
|
||||
import { StandbyPlugin } from "@suaveplan/network-transport-standby";
|
||||
|
||||
const node = new NetworkNode();
|
||||
node.use(
|
||||
new StandbyPlugin({
|
||||
pool: {
|
||||
config: {
|
||||
minSlots: 4,
|
||||
maxSlots: 8,
|
||||
warmupTimeoutMs: 5_000,
|
||||
healthCheckIntervalMs: 30_000,
|
||||
qualityThreshold: 50,
|
||||
maxReconnectAttempts: 3,
|
||||
reconnectBaseDelayMs: 1_000,
|
||||
reconnectMaxDelayMs: 30_000,
|
||||
},
|
||||
rtcFactory: async (slotId) => makeRtcConnection(slotId),
|
||||
},
|
||||
})
|
||||
);
|
||||
await node.start();
|
||||
```
|
||||
@@ -0,0 +1,79 @@
|
||||
# errors
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
This module defines the structured error vocabulary for
|
||||
`@suaveplan/network-transport-standby`. Every public boundary that can
|
||||
fail surfaces an instance of `NetStandbyError` carrying one of six
|
||||
fixed codes, so consumers branch on `error.code` rather than parsing
|
||||
free-form messages. The registry is created via `defineCodes` from
|
||||
`@suaveplan/error` so the codes are typed at the call site and the
|
||||
literal strings (`"NET_STANDBY.POOL_EXHAUSTED"` and friends) are
|
||||
guaranteed unique across the project.
|
||||
|
||||
## Features
|
||||
|
||||
- Six frozen, type-safe codes covering pool, slot, configuration and
|
||||
lifecycle failure modes.
|
||||
- `NetStandbyError` extends `SuaveplanError` so cause chains, message
|
||||
keys, context dictionaries and OTel span-event adapters work out of
|
||||
the box.
|
||||
- Class `name` is set explicitly so logs and crash reports can tell
|
||||
this error class apart from sibling network errors.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { NET_STANDBY_CODES, NetStandbyError } from "@suaveplan/network-transport-standby";
|
||||
|
||||
throw new NetStandbyError(
|
||||
NET_STANDBY_CODES.POOL_EXHAUSTED,
|
||||
"standby.pool-exhausted",
|
||||
{ context: { maxSlots: 8 } }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
try {
|
||||
await pool.promote("peer-7");
|
||||
} catch (err) {
|
||||
if (err instanceof NetStandbyError) {
|
||||
switch (err.code) {
|
||||
case NET_STANDBY_CODES.POOL_EXHAUSTED:
|
||||
metrics.exhausted.inc();
|
||||
break;
|
||||
case NET_STANDBY_CODES.PROMOTION_FAILED:
|
||||
logger.warn({ cause: err.cause }, "promotion failed");
|
||||
break;
|
||||
default:
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `NET_STANDBY_CODES` — `Readonly<Record<Code, string>>` registry of
|
||||
the six codes.
|
||||
- `type NetStandbyErrorCode` — string-literal union of the registry
|
||||
values.
|
||||
- `class NetStandbyError extends SuaveplanError<NetStandbyErrorCode>`
|
||||
with `constructor(code, messageKey, options?)` where `options`
|
||||
matches `SuaveplanErrorOptions` (`cause`, `context`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The codes are deliberately small and orthogonal. `POOL_EXHAUSTED`
|
||||
fires when no slot can be allocated; `SLOT_UNHEALTHY` covers per-slot
|
||||
health degradation; `PROMOTION_FAILED` is reserved for the moment
|
||||
between "slot exists" and "slot is bound to a peer";
|
||||
`INVALID_OPTIONS` wraps Zod parse failures; `DISPOSED` and
|
||||
`NOT_RUNNING` model lifecycle violations. Add new codes sparingly —
|
||||
each new code is a public contract the entire ecosystem branches on,
|
||||
and removing one is a breaking change.
|
||||
@@ -0,0 +1,72 @@
|
||||
# health/monitor
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
This module is the health-scoring kernel for `StandbyPool`. It owns
|
||||
an exponentially-weighted moving-average (EMA) state tied to every
|
||||
slot and translates raw round-trip-time / jitter / loss samples into a
|
||||
single 0–100 quality score the pool can threshold against. Keeping the
|
||||
scorer side-effect-free means the pool can call it from synchronous
|
||||
code paths (`recordSample`, scheduled sweeps) and the same arithmetic
|
||||
can be exercised under `fast-check` property tests without dragging
|
||||
the transport, scheduler or any I/O into the loop.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure-function EMA combinator with a frozen `α = 0.3` smoothing
|
||||
factor — responsive enough to catch sustained degradation, stable
|
||||
enough to ignore single-sample spikes.
|
||||
- Input clamping so a malformed sample (negative RTT, loss > 1)
|
||||
cannot corrupt the state.
|
||||
- Composite scorer that weights RTT 60%, jitter 20%, loss 20% and
|
||||
caps every penalty so the score stays in `[0, 100]`.
|
||||
- Deep-frozen result objects — callers may safely retain references
|
||||
across update cycles.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { applyHealthSample, initialHealthState, isHealthy } from "@suaveplan/network-transport-standby";
|
||||
|
||||
let state = initialHealthState();
|
||||
state = applyHealthSample(state, { rttMs: 35, jitterMs: 3, lossRate: 0 });
|
||||
console.log(isHealthy(state, 50)); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
function scoreStream(samples: HealthSample[], threshold: number): boolean {
|
||||
let state = initialHealthState();
|
||||
for (const sample of samples) {
|
||||
state = applyHealthSample(state, sample);
|
||||
if (!isHealthy(state, threshold)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `initialHealthState(): HealthState` — frozen seed (`quality = 100`).
|
||||
- `applyHealthSample(prev, sample): HealthState` — apply one sample
|
||||
and return a fresh frozen snapshot.
|
||||
- `isHealthy(state, threshold): boolean` — compare `state.quality`
|
||||
against the integer threshold.
|
||||
- `interface HealthSample` and `interface HealthState` — both
|
||||
readonly.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The EMA is initialised at zero — not at the first sample — so a
|
||||
freshly-warmed slot's quality is 100 right out of the gate and a
|
||||
single high-RTT sample cannot evict it. The first three samples
|
||||
typically suffice to push the EMA into a representative steady state.
|
||||
The composite scorer's weights are tuned for typical WebRTC data
|
||||
channels; consumers with different transport characteristics should
|
||||
fork the scorer rather than override the weights at runtime.
|
||||
@@ -0,0 +1,79 @@
|
||||
# otel
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
This module hosts every OpenTelemetry instrument the standby package
|
||||
emits. Centralising the tracer, meter and instrument handles in a
|
||||
single module gives the package one definitive list of observable
|
||||
signals and lets the pool, transport and reconnect modules add to the
|
||||
metrics surface without each rebuilding its own meter. The gauge,
|
||||
counters and histogram align with the metric names declared in the
|
||||
parent change proposal so dashboards and alerts can be defined once
|
||||
across every transport in the network family.
|
||||
|
||||
## Features
|
||||
|
||||
- Up-down counter `standby.warm_slots` tracking the live count of
|
||||
warm-but-not-active slots.
|
||||
- Counter `standby.promotions_total` labelled by `outcome ∈
|
||||
{"ok","error"}` for the success rate of promotions.
|
||||
- Counter `standby.evictions_total` labelled by free-form `reason` so
|
||||
consumers can split by `"unhealthy"`, `"shutdown"`, `"manual"`,
|
||||
`"idle"`.
|
||||
- Histogram `standby.warmup_duration_ms` capturing the wall-clock
|
||||
cost of every warm-up handshake.
|
||||
- `withStandbySpan` helper that starts an active span, runs an async
|
||||
body, and ends the span on both success and failure.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { recordPromotion, withStandbySpan } from "@suaveplan/network-transport-standby";
|
||||
|
||||
await withStandbySpan("standby.promote", async (span) => {
|
||||
span.setAttribute("peer.id", "peer-7");
|
||||
recordPromotion("ok");
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
adjustWarmSlots,
|
||||
recordEviction,
|
||||
recordWarmupDuration,
|
||||
} from "@suaveplan/network-transport-standby";
|
||||
|
||||
function onSlotWarmed(durationMs: number): void {
|
||||
recordWarmupDuration(durationMs);
|
||||
adjustWarmSlots(+1);
|
||||
}
|
||||
|
||||
function onSlotDropped(reason: "unhealthy" | "shutdown" | "manual"): void {
|
||||
adjustWarmSlots(-1);
|
||||
recordEviction(reason);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `withStandbySpan(name, fn)` — wraps an async body in an active
|
||||
span; rethrows any error after ending the span.
|
||||
- `adjustWarmSlots(delta)` — adjust the warm-slots gauge.
|
||||
- `recordPromotion(outcome)` — increment the promotions counter.
|
||||
- `recordEviction(reason)` — increment the evictions counter.
|
||||
- `recordWarmupDuration(ms)` — record a non-negative sample on the
|
||||
warmup histogram (negative input is clamped to zero).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Span attributes are set by callers, not here, so each call site can
|
||||
record the most relevant context (`peer.id`, `slot.id`). Counter
|
||||
labels are kept low-cardinality on purpose: `outcome` is binary and
|
||||
`reason` is drawn from a small fixed enum. Adding wide-cardinality
|
||||
labels (peer ids, slot ids) would explode the metrics index and
|
||||
should be done with a span attribute instead.
|
||||
@@ -0,0 +1,85 @@
|
||||
# plugin
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`StandbyPlugin` is the `NetworkPlugin` wrapper for `StandbyTransport`.
|
||||
It mirrors the `WebRTCPlugin` and `WebSocketPlugin` shapes from the
|
||||
sibling transport packages exactly so that the
|
||||
`network-plugin-transport-manager` and any other plugin host treats
|
||||
the standby transport uniformly with every other transport in the
|
||||
network family. The wrapper is intentionally thin — it owns no state
|
||||
beyond the node reference and a `disposed` flag.
|
||||
|
||||
## Features
|
||||
|
||||
- Stable `name = "standby"` — the value the host node keys plugins by.
|
||||
- `install(node)` registers the underlying `StandbyTransport` with
|
||||
the node's router so the unicast path can find it by id.
|
||||
- `start()` warms the pool to `minSlots` lazily; consumers may also
|
||||
start the transport directly.
|
||||
- `stop()` closes the transport and unregisters it from the node so
|
||||
the next `install` is idempotent.
|
||||
- `dispose()` releases the node reference; safe to call multiple
|
||||
times.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { NetworkNode } from "@suaveplan/network";
|
||||
import { StandbyPlugin } from "@suaveplan/network-transport-standby";
|
||||
|
||||
const node = new NetworkNode();
|
||||
const plugin = new StandbyPlugin({
|
||||
pool: { config, rtcFactory },
|
||||
});
|
||||
node.use(plugin);
|
||||
await node.start();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
const standby = new StandbyPlugin({
|
||||
pool: {
|
||||
config,
|
||||
rtcFactory: makeRtcConnection,
|
||||
onWarm: (slot) => bus.emit("transport:standby:warm", slot.id),
|
||||
},
|
||||
});
|
||||
|
||||
node.use(standby);
|
||||
await node.start();
|
||||
|
||||
// Later — graceful shutdown propagates through node -> plugin -> transport.
|
||||
await node.stop();
|
||||
standby.dispose();
|
||||
await node.dispose();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new StandbyPlugin(config: StandbyTransportConfig)` — same config
|
||||
shape as `StandbyTransport`. The constructor builds the transport
|
||||
immediately so `plugin.transport` is observable before `install`.
|
||||
- `name: "standby"` — read-only.
|
||||
- `transport: StandbyTransport` — public so consumers can wire
|
||||
bus events or inspect the pool.
|
||||
- `install(node): void` — registers the transport with the node's
|
||||
router.
|
||||
- `start(): Promise<void>` — warms the pool.
|
||||
- `stop(): Promise<void>` — closes the transport and unregisters.
|
||||
- `dispose(): void` — releases the node reference (idempotent).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`install` stores the node reference and registers the transport in a
|
||||
single synchronous call so `start()` can run as part of the host's
|
||||
plugin-lifecycle batch without retrying. `stop()` deliberately does
|
||||
NOT call `dispose()` — the lifecycle separation matches the
|
||||
`NetworkPlugin` contract: `stop` halts active work, `dispose`
|
||||
relinquishes resources. The wrapper holds no extra state beyond the
|
||||
node reference, so the pool / transport remain the canonical source
|
||||
of truth for slot data.
|
||||
@@ -0,0 +1,103 @@
|
||||
# pool
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`StandbyPool` is the connection-pool engine at the heart of this
|
||||
package. It maintains a configurable number of warm WebRTC-like
|
||||
slots so a downstream consumer can promote one to an active peer
|
||||
in well under 100 ms, sidestepping the typical 1–3 s ICE/DTLS
|
||||
handshake. The pool is transport-agnostic — it accepts an injectable
|
||||
`RtcConnectionFactory` so the same code drives production
|
||||
`RTCPeerConnection` factories and deterministic test fakes built on
|
||||
plain JavaScript objects.
|
||||
|
||||
## Features
|
||||
|
||||
- Configurable `minSlots` / `maxSlots` with cross-field invariant
|
||||
validation via Zod.
|
||||
- Promotion-friendly fast path: `pool.promote(peerId)` first looks for
|
||||
a warm slot, then warms a cold one if capacity remains.
|
||||
- EMA-smoothed per-slot health scoring via the `health/monitor`
|
||||
module; samples below `qualityThreshold` evict inline.
|
||||
- Mutex-serialised top-up so two concurrent eviction events cannot
|
||||
over-warm past `maxSlots`.
|
||||
- Auto-eviction observer wired into every connection's
|
||||
`onstatechange` so slots whose channel goes to `failed` / `closed`
|
||||
/ `disconnected` are removed without polling.
|
||||
- Periodic idle sweep over the injected `Scheduler` from
|
||||
`@suaveplan/timing` (interval configurable; `0` disables).
|
||||
- Optional `onWarm`, `onPromoted`, `onEvicted` hooks for callers that
|
||||
want to wire bus events or extra telemetry.
|
||||
- Full OTel surface — gauge, counters, histogram, span helper.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { StandbyPool } from "@suaveplan/network-transport-standby";
|
||||
|
||||
const pool = new StandbyPool({
|
||||
config: {
|
||||
minSlots: 4,
|
||||
maxSlots: 8,
|
||||
warmupTimeoutMs: 5_000,
|
||||
healthCheckIntervalMs: 30_000,
|
||||
qualityThreshold: 50,
|
||||
maxReconnectAttempts: 3,
|
||||
reconnectBaseDelayMs: 1_000,
|
||||
reconnectMaxDelayMs: 30_000,
|
||||
},
|
||||
rtcFactory: async (slotId) => makeRealPeerConnection(slotId),
|
||||
});
|
||||
|
||||
await pool.start();
|
||||
const slot = await pool.promote("peer-42");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
const pool = new StandbyPool({
|
||||
config,
|
||||
rtcFactory,
|
||||
clock: () => testClock.nowMs(),
|
||||
scheduler: testScheduler,
|
||||
onWarm: (slot) => bus.emit("transport:standby:warm", slot.id),
|
||||
onPromoted: (slot) => bus.emit("transport:standby:promoted", slot),
|
||||
onEvicted: (id, reason) => bus.emit("transport:standby:evicted", id, reason),
|
||||
});
|
||||
|
||||
await pool.start(ac.signal);
|
||||
pool.recordSample(slot.id, { rttMs: 35, jitterMs: 3, lossRate: 0 });
|
||||
pool.runIdleSweepNow();
|
||||
await pool.dispose();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new StandbyPool(options)` — validates config via Zod; throws
|
||||
`INVALID_OPTIONS` on parse failure.
|
||||
- `start(signal?): Promise<void>`, `stop(): Promise<void>`,
|
||||
`dispose(): Promise<void>` — lifecycle, all idempotent.
|
||||
- `promote(peerId): Promise<StandbySlot>` — returns active slot;
|
||||
throws `POOL_EXHAUSTED` or `PROMOTION_FAILED`.
|
||||
- `release(slotId): void` — return active slot to warm; graceful
|
||||
no-op when pool stopped or slot unknown.
|
||||
- `recordSample(slotId, sample): StandbySlot | null` — apply health
|
||||
sample; returns `null` if the slot was evicted inline.
|
||||
- `runIdleSweepNow(): void` — synchronous sweep over all slots.
|
||||
- `evict(slotId, reason?): void`, `getStats(): PoolStats`,
|
||||
`warmSlots()`, `activeSlots()`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Eviction is the trickiest path. Re-entrancy via the connection's
|
||||
`onstatechange` observer is neutralised by deleting from the slot
|
||||
map AND detaching the observer before invoking `connection.close()`.
|
||||
The mutex on `#topUpInflight` is critical: without it, two
|
||||
back-to-back evictions would each launch a parallel ensureMinimum,
|
||||
both observing the same stale warm count and over-allocating. The
|
||||
pool exposes both warm and active sets so consumers can compute
|
||||
load-balancing decisions without recomputing state.
|
||||
@@ -0,0 +1,86 @@
|
||||
# reconnect
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
This module wraps `@suaveplan/retry`'s exponential-backoff policy in
|
||||
an `AbortSignal`-aware retry loop tuned for the standby transport's
|
||||
reconnect requirements. Rather than re-implement the canonical
|
||||
backoff + jitter loop, it delegates the delay calculation to the
|
||||
shared `createExponentialBackoff` helper and focuses on the cancel
|
||||
semantics: any abort during execution, inside a sleep, or just after
|
||||
a thrown attempt must short-circuit the loop without spending another
|
||||
attempt budget.
|
||||
|
||||
## Features
|
||||
|
||||
- Equal-jitter exponential backoff via the canonical retry package —
|
||||
no ad-hoc `Math.min(base * 2^n, cap)` reimplementation.
|
||||
- AbortSignal honoured at every checkpoint (entry, after a failed
|
||||
attempt, during the sleep between attempts).
|
||||
- On exhaustion, throws a typed `NetStandbyError` with code
|
||||
`PROMOTION_FAILED` and the original cause preserved.
|
||||
- Pure function — no internal timers retained after return, so the
|
||||
loop is safe to invoke from any caller stack.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { runReconnectLoop } from "@suaveplan/network-transport-standby";
|
||||
|
||||
const ac = new AbortController();
|
||||
await runReconnectLoop(
|
||||
async (attempt) => {
|
||||
await dialPeer(`peer-1`);
|
||||
},
|
||||
{ maxAttempts: 5, baseDelayMs: 200, maxDelayMs: 5_000 },
|
||||
ac.signal
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
async function reconnectWithReseed(
|
||||
seed: () => Promise<void>,
|
||||
signal: AbortSignal
|
||||
): Promise<void> {
|
||||
let attempts = 0;
|
||||
try {
|
||||
await runReconnectLoop(
|
||||
async (n) => {
|
||||
attempts = n;
|
||||
await seed();
|
||||
},
|
||||
{ maxAttempts: 8, baseDelayMs: 500, maxDelayMs: 30_000 },
|
||||
signal
|
||||
);
|
||||
} catch (err) {
|
||||
log.warn({ attempts, err }, "reconnect exhausted");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `interface ReconnectOptions` — `{ maxAttempts, baseDelayMs,
|
||||
maxDelayMs }`. All non-negative integers; `baseDelayMs <=
|
||||
maxDelayMs` for monotonic growth.
|
||||
- `type ReconnectWork` — `(attempt, signal) => Promise<void>`.
|
||||
- `runReconnectLoop(work, options, signal): Promise<void>` — drives
|
||||
`work` with exponential-backoff between failures.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The internal `sleep` helper is intentionally narrow: it adds a single
|
||||
`abort` listener with `{ once: true }` so the listener self-cleans
|
||||
when the timer fires first. Because the outer loop pre-checks
|
||||
`signal.aborted` we never enter `sleep` with an already-aborted
|
||||
signal — the helper trusts that invariant rather than re-checking and
|
||||
opening a branch the test suite cannot reach. The cause chain on
|
||||
`PROMOTION_FAILED` preserves the most-recent thrown error so
|
||||
observers can distinguish a network-level failure from a
|
||||
configuration-level one.
|
||||
@@ -0,0 +1,96 @@
|
||||
# transport
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`StandbyTransport` is the `Transport` adapter from `@suaveplan/network`
|
||||
backed by a `StandbyPool`. Every `connect(peerId, address)` either
|
||||
promotes a warm slot to active (the hot path, sub-100 ms) or warms a
|
||||
cold one through the pool if no warm slot exists and capacity remains.
|
||||
The transport layers message encoding, peer-event fan-out and
|
||||
graceful eviction handling on top of the pool's lifecycle so the
|
||||
upstream `NetworkNode` router can treat the standby transport
|
||||
identically to `WebRTCTransport` or `WebSocketTransport`.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements every method of `Transport` (`connect`, `disconnect`,
|
||||
`send`, `onMessage`, `onPeerEvent`, `getConnectedPeers`, `close`).
|
||||
- Pluggable `encode` / `decode` hooks — defaults are `JSON.stringify`
|
||||
/ `JSON.parse`, drop-in replacements (msgpack, protobuf) wire in
|
||||
via constructor options.
|
||||
- Forwards `onPromoted` / `onEvicted` from the pool, mirroring them
|
||||
as `"connected"` / `"disconnected"` peer events so router-level
|
||||
topology updates fire as soon as the slot transitions.
|
||||
- Idempotent `connect(peerId)` — repeated calls for the same peer
|
||||
return without warming an additional slot.
|
||||
- `close()` disposes the underlying pool, clears every handler and
|
||||
emits a final `disconnected` for each active peer.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { StandbyTransport } from "@suaveplan/network-transport-standby";
|
||||
|
||||
const transport = new StandbyTransport({
|
||||
pool: {
|
||||
config: { /* StandbyPoolConfig */ },
|
||||
rtcFactory: makeRtcConnection,
|
||||
},
|
||||
});
|
||||
await transport.start();
|
||||
await transport.connect("peer-7", "stun:example.com");
|
||||
await transport.send("peer-7", envelope);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
const transport = new StandbyTransport({
|
||||
pool: {
|
||||
config,
|
||||
rtcFactory,
|
||||
onPromoted: (slot) => audit.log({ event: "promoted", slot }),
|
||||
},
|
||||
encode: (envelope) => msgpack.encode(envelope),
|
||||
decode: (raw) =>
|
||||
raw instanceof Uint8Array ? (msgpack.decode(raw) as MessageEnvelope) : null,
|
||||
});
|
||||
|
||||
const offMsg = transport.onMessage((peerId, env) => router.dispatch(peerId, env));
|
||||
const offPeer = transport.onPeerEvent((peerId, ev) => topology.update(peerId, ev));
|
||||
await transport.start();
|
||||
// ... later
|
||||
offMsg();
|
||||
offPeer();
|
||||
await transport.close();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new StandbyTransport(config: StandbyTransportConfig)` — `config.pool`
|
||||
is forwarded verbatim to `StandbyPool`; `config.encode` / `decode`
|
||||
are optional.
|
||||
- `start(signal?): Promise<void>` — warms the pool.
|
||||
- `connect(peerId, address): Promise<void>` — promotes a warm slot.
|
||||
- `disconnect(peerId): Promise<void>` — releases the slot and emits
|
||||
the disconnect event.
|
||||
- `send(peerId, envelope): Promise<void>` — encodes and forwards
|
||||
over the slot's connection.
|
||||
- `onMessage(handler)` / `onPeerEvent(handler)` — return unsubscribe
|
||||
functions.
|
||||
- `getConnectedPeers(): readonly PeerId[]`.
|
||||
- `close(): Promise<void>` — terminal teardown.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The transport never holds a stale `onmessage` closure on the
|
||||
underlying connection: each `connect` saves the previous handler and
|
||||
restores it on `disconnect`. Eviction events from the pool are
|
||||
routed through `#handleEviction`, which removes the matching peer
|
||||
from the registry and emits the disconnect event so the router does
|
||||
not need to poll. The error envelope is intentionally narrow — every
|
||||
boundary failure raises `NetStandbyError`, never a raw `Error` — so
|
||||
the consumer never has to introspect free-form messages.
|
||||
@@ -0,0 +1,99 @@
|
||||
# types
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
This module is the single home of every shared type the standby
|
||||
transport publishes. Keeping the type surface in a leaf module lets
|
||||
the implementation files import only the names they need, keeps the
|
||||
public API discoverable at a glance, and gives downstream consumers a
|
||||
single import target for ambient declarations and discriminated
|
||||
unions.
|
||||
|
||||
## Features
|
||||
|
||||
- `SlotState` discriminated union — `"warming" | "warm" | "active" |
|
||||
"unhealthy" | "closed"` — drives every state-machine branch in the
|
||||
pool.
|
||||
- `RtcConnectionLike` / `RtcConnectionFactory` — minimal mockable
|
||||
WebRTC peer-connection shape so production wires to
|
||||
`RTCPeerConnection` and tests wire to deterministic fakes built on
|
||||
plain objects.
|
||||
- `StandbySlot` — frozen record carrying every per-slot field the
|
||||
pool tracks (state, connection handle, EMA health, peer binding).
|
||||
- `StandbyPoolConfig` — exhaustive configuration shape paired with a
|
||||
Zod schema (see `types/standby.schemas.ts`).
|
||||
- `ConnectionRequest`, `PoolStats`, `StandbyBusEvent`, `StandbyDeps`
|
||||
and the four handler-typedef aliases (`PromotionHandler`,
|
||||
`EvictionHandler`, `MessageHandler`, `PeerEventHandler`).
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
StandbySlot,
|
||||
StandbyPoolConfig,
|
||||
} from "@suaveplan/network-transport-standby";
|
||||
|
||||
const config: StandbyPoolConfig = {
|
||||
minSlots: 4,
|
||||
maxSlots: 8,
|
||||
warmupTimeoutMs: 5_000,
|
||||
healthCheckIntervalMs: 30_000,
|
||||
qualityThreshold: 50,
|
||||
maxReconnectAttempts: 3,
|
||||
reconnectBaseDelayMs: 1_000,
|
||||
reconnectMaxDelayMs: 30_000,
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
RtcConnectionFactory,
|
||||
PromotionHandler,
|
||||
EvictionHandler,
|
||||
} from "@suaveplan/network-transport-standby";
|
||||
|
||||
const factory: RtcConnectionFactory = async (slotId, signal) => {
|
||||
signal?.throwIfAborted();
|
||||
return makePeerConnection(slotId);
|
||||
};
|
||||
|
||||
const onPromoted: PromotionHandler = (slot) => audit.log(slot.peerId);
|
||||
const onEvicted: EvictionHandler = (id, reason) =>
|
||||
audit.log({ slot: id, reason });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `type SlotState` — discriminated union of slot lifecycle states.
|
||||
- `interface RtcConnectionLike` — `close` / `send` / `connectionState`
|
||||
/ `onmessage` / `onstatechange`.
|
||||
- `type RtcConnectionFactory` — async constructor accepting `slotId`
|
||||
and optional `AbortSignal`.
|
||||
- `interface StandbySlot` — read-only public projection of every
|
||||
slot in the pool.
|
||||
- `interface StandbyPoolConfig` — eight required numeric fields.
|
||||
- `interface ConnectionRequest` — `peerId` plus optional `priority`
|
||||
and `metadata`.
|
||||
- `interface PoolStats` — snapshot stats from `pool.getStats()`.
|
||||
- `type StandbyBusEvent` — discriminated union of optional bus
|
||||
payloads.
|
||||
- `interface StandbyDeps` — injectable collaborators (`clock`,
|
||||
`rtcFactory`).
|
||||
- `type PromotionHandler`, `EvictionHandler`, `MessageHandler`,
|
||||
`PeerEventHandler` — callback signatures.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All record shapes are declared `readonly` so consumers cannot mutate
|
||||
slots they hold a reference to. `RtcConnectionLike` is the narrowest
|
||||
subset of `RTCPeerConnection` that the pool needs — keeping the shim
|
||||
small means tests stay tiny and the production adapter is a thin
|
||||
wrapper, not a translation layer. The `StandbyDeps` bundle is the
|
||||
canonical seam for dependency injection; new collaborators should be
|
||||
added here so every internal module gains them in one place.
|
||||
Reference in New Issue
Block a user