first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View 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();
```

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.