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,56 @@
# `network`
Mesh networking, WebSocket transports, bootstrap, and discovery protocols.
<!-- begin:auto-docs -->
## Packages in `network`
44 packages.
| Package | Description |
|---|---|
| [`@suaveplan/network`](./network/README.md) | Core network interfaces and runtime for the mesh system. |
| [`@suaveplan/network-bootstrap`](./network-bootstrap/README.md) | Pluggable peer-discovery chain, persistent peer cache, and `NetworkPlugin` wrapper for the `@suaveplan/network` mesh. Provides the canonical way for a freshly… |
| [`@suaveplan/network-discovery-crdt`](./network-discovery-crdt/README.md) | Browser-tier `DiscoveryStrategy` for `@suaveplan/network-bootstrap`. Wraps an awareness-document CRDT (Yjs *or* Automerge — chosen at construction) and produce… |
| [`@suaveplan/network-discovery-gundb`](./network-discovery-gundb/README.md) | Browser-tier `DiscoveryStrategy` for `@suaveplan/network-bootstrap` chains. Wraps a GunDB instance — gossip-over- WebSocket decentralised graph database — and… |
| [`@suaveplan/network-discovery-hybrid`](./network-discovery-hybrid/README.md) | Browser-tier multi-strategy DiscoveryStrategy orchestrator: composes any subset of the five Wave-2 sibling strategies (peerjs / crdt / trystero / webtorrent / gundb) into a single HybridDiscoveryManager with priority-based peer selection, per-strategy health tracking, automatic fallback, de-duplicated peer set with PeerSource provenance, and unified lifecycle. Implements the network-bootstrap DiscoveryStrategy contract so it can itself be chained. |
| [`@suaveplan/network-discovery-peerjs`](./network-discovery-peerjs/README.md) | Browser-tier `DiscoveryStrategy` for `@suaveplan/network-bootstrap` that wraps a PeerJS signalling-server client + WebRTC `DataConnection`s into a deduped peer… |
| [`@suaveplan/network-discovery-trystero`](./network-discovery-trystero/README.md) | Browser-tier `DiscoveryStrategy` for `@suaveplan/network-bootstrap`. Serverless WebRTC peer discovery via Trystero, with four interchangeable transports — BitT… |
| [`@suaveplan/network-discovery-webtorrent`](./network-discovery-webtorrent/README.md) | Browser-tier `DiscoveryStrategy` for `@suaveplan/network-bootstrap` chains. Wraps a `p2pt` instance — WebTorrent-tracker-driven WebRTC peer discovery — and exp… |
| [`@suaveplan/network-gundb-server`](./network-gundb-server/README.md) | Server-tier GunDB relay daemon. Hosts a Gun relay peer plus standard `/health` / `/ready` / `/live` probes on a single Node `http.Server`. Browser-tier clients… |
| [`@suaveplan/network-libp2p-server`](./network-libp2p-server/README.md) | Server-tier libp2p relay daemon paired with `@suaveplan/network-transport-libp2p`. Hosts a libp2p node configured as a bootstrap + circuit-relay-v2 server alon… |
| [`@suaveplan/network-plugin-assignment`](./network-plugin-assignment/README.md) | Mesh auto-assignment and load balancing: live `MeshCapacityTracker`, weighted-multi-criteria `AssignmentEngine`, imbalance-detecting `MeshBalancer` planner, an… |
| [`@suaveplan/network-plugin-audit-mesh`](./network-plugin-audit-mesh/README.md) | Tamper-evident, cryptographically signed, append-only audit log chain for the Suaveplan mesh. `SignedLog` produces hash-chained `LogEntry` records keyed by mon… |
| [`@suaveplan/network-plugin-auth`](./network-plugin-auth/README.md) | Mesh membership authentication and cross-mesh relay authorization. Bridges `@suaveplan/auth` into the mesh: peer join verification, capability-tier-bound token… |
| [`@suaveplan/network-plugin-circuit-breaker`](./network-plugin-circuit-breaker/README.md) | Mesh-level circuit breakers: per-path state machine (`CLOSED → OPEN → HALF_OPEN`), registry, half-open probe loop, and relay middleware integration. Fast-fails… |
| [`@suaveplan/network-plugin-content-delivery`](./network-plugin-content-delivery/README.md) | Mesh-as-CDN core for the Suaveplan network. `ChunkIndex` maintains a distributed holder map keyed by chunk hash with gossip-style merge over `ChunkAdvertisemen… |
| [`@suaveplan/network-plugin-crypto-messages`](./network-plugin-crypto-messages/README.md) | Cryptographic integrity layer for the Suaveplan mesh. Ed25519-backed `MessageSigner` (sign on send, verify against the registry on receive); `SessionTokenAutho… |
| [`@suaveplan/network-plugin-devtools`](./network-plugin-devtools/README.md) | Developer-tools plugin for the Suaveplan mesh. Bundles four subsystems behind a single `NetworkPlugin` lifecycle so engineers can inspect, visualise, and profi… |
| [`@suaveplan/network-plugin-failover`](./network-plugin-failover/README.md) | Mesh failover plugin: per-peer heartbeat manager, alive→suspect→dead→recovering health monitor, debounced promoter that triggers emergency elections, and a coo… |
| [`@suaveplan/network-plugin-governance`](./network-plugin-governance/README.md) | Election engine for the Suaveplan mesh: candidate scoring, reputation tracking, voting, and host transitions. |
| [`@suaveplan/network-plugin-intra-relay`](./network-plugin-intra-relay/README.md) | Intra-mesh peer-to-peer relay for the Suaveplan network. When direct WebRTC fails (symmetric NAT, restrictive firewall), `IntraRelayPlugin` routes the message… |
| [`@suaveplan/network-plugin-middleware`](./network-plugin-middleware/README.md) | Priority-ordered middleware chain for the Suaveplan mesh. Inbound and outbound message flow each pass through the same five-band pipeline (CRITICAL → HIGH → NO… |
| [`@suaveplan/network-plugin-migration`](./network-plugin-migration/README.md) | Peer state migration between meshes: 8-step `MigrationCoordinator`, snapshot serialize/deserialize via `StateTransferProtocol`, graceful-handoff `ForwardingTun… |
| [`@suaveplan/network-plugin-monitor`](./network-plugin-monitor/README.md) | Production monitoring + distributed tracing for the Suaveplan mesh. Collects metrics across four categories (node / connection / message / hierarchy), supports… |
| [`@suaveplan/network-plugin-partition`](./network-plugin-partition/README.md) | Split-brain detection and resolution for the mesh: epoch-based `PartitionDetector` (deterministic partition-id from sorted reachable set), `QuorumResolver` wit… |
| [`@suaveplan/network-plugin-protocol`](./network-plugin-protocol/README.md) | Network protocol plugin for the mesh system. |
| [`@suaveplan/network-plugin-qos`](./network-plugin-qos/README.md) | Bandwidth management and quality-of-service for the mesh: bandwidth-quota enforcer, message-priority classifier, QoS router (bypass / drop / queue / defer), co… |
| [`@suaveplan/network-plugin-registry`](./network-plugin-registry/README.md) | Fixed-capacity peer registry with bitpacker-based binary encoding. |
| [`@suaveplan/network-plugin-relay`](./network-plugin-relay/README.md) | Cross-mesh relay — the federation layer that lets Tier 0 hosts of different meshes route messages to each other. Composes `RelayRouter`, `HostBackboneTransport… |
| [`@suaveplan/network-plugin-server-bridge`](./network-plugin-server-bridge/README.md) | Tier-0 host ↔ central-server bridge for the Suaveplan mesh. |
| [`@suaveplan/network-plugin-session`](./network-plugin-session/README.md) | Hierarchical session management for the Suaveplan mesh. `SessionTree` maintains a parent-child session graph rooted at a single auto-created root; `SessionMana… |
| [`@suaveplan/network-plugin-simulation`](./network-plugin-simulation/README.md) | Deterministic virtual-network simulation framework for the Suaveplan mesh. `NetworkSimulator` runs scenarios against a seeded RNG with simulated time (accelera… |
| [`@suaveplan/network-plugin-state`](./network-plugin-state/README.md) | Mesh-wide synchronized state for the Suaveplan network. Path-based `StateManager` (versioned history with sequence numbers + per-path subscriptions); `DeltaCom… |
| [`@suaveplan/network-plugin-topology`](./network-plugin-topology/README.md) | Tiered mesh topology plugin: 3-tier `HierarchicalTopology` (Host / SubHost / Leaf) with promote/demote, latency-bucket-based `LatencyAssigner`, `GroupManager`,… |
| [`@suaveplan/network-plugin-transport-manager`](./network-plugin-transport-manager/README.md) | Multi-transport orchestration for the Suaveplan mesh. Maintains a pool of registered transports per peer, negotiates capability bitmaps during handshake, route… |
| [`@suaveplan/network-testing`](./network-testing/README.md) | <!-- begin:auto-docs --> |
| [`@suaveplan/network-transport-hyperswarm`](./network-transport-hyperswarm/README.md) | Server-tier Hyperswarm DHT transport for `@suaveplan/network`. Provides hole-punched peer-to-peer streams discovered via Kademlia DHT topics. |
| [`@suaveplan/network-transport-libp2p`](./network-transport-libp2p/README.md) | Universal libp2p `Transport` for `@suaveplan/network`. Composes WebSockets or WebRTC sub-transports with Noise + Yamux, identify + ping + bootstrap + circuit-r… |
| [`@suaveplan/network-transport-standby`](./network-transport-standby/README.md) | Pre-emptive WebRTC standby transport for `@suaveplan/network`. Maintains a pool of warm `RTCPeerConnection`-like slots so peer attach completes in ~10 ms inste… |
| [`@suaveplan/network-transport-webrtc`](./network-transport-webrtc/README.md) | WebRTC transport plugin for mesh networking. |
| [`@suaveplan/network-transport-websockets`](./network-transport-websockets/README.md) | WebSocket client utilities with typed message framing, connection lifecycle management, heartbeat, backpressure queue, and injectable transport adapters. |
| [`@suaveplan/network-trystero-server`](./network-trystero-server/README.md) | Server-tier Trystero signaling daemon. Hosts an MQTT-style or Nostr-style WebSocket broker shim plus standard `/health` / `/ready` / `/live` probes on a single… |
| [`@suaveplan/react-network-libp2p-demo`](./react-network-libp2p-demo/README.md) | React-tier demo UI for `@suaveplan/network-transport-libp2p`. Splits the upstream 749-LOC monolithic demo into per-concern subcomponents (`<NodeStatusPanel>`,… |
| [`@suaveplan/service-discovery`](./service-discovery/README.md) | Universal service-registry primitives. Provides the shared `ServiceDescriptor`, `ServiceQuery`, `RegistryEvent`, and `ServiceRegistry` contract consumed by eve… |
| [`@suaveplan/websocket-gateway`](./websocket-gateway/README.md) | Server-tier WebSocket gateway daemon with rooms, presence tracking, and channel-multiplexed pub/sub. Pairs with `@suaveplan/network-transport-websockets` on th… |
<!-- end:auto-docs -->

View File

@@ -0,0 +1 @@
Mesh networking, WebSocket transports, bootstrap, and discovery protocols.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,103 @@
# DiscoveryChain
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`DiscoveryChain` orchestrates an ordered list of `DiscoveryStrategy`
implementations, running them in concurrency-bounded batches with retry,
jitter, and per-strategy timeout, then merging the results into a deduped
peer set. It is the engine the `BootstrapPlugin` delegates to when a freshly
started node needs to find its first peers.
## Features
- Pluggable, ordered `DiscoveryStrategy` chain — `addStrategy` /
`removeStrategy` / `getStrategies` for runtime mutation.
- `isAvailable()` gating: unavailable strategies are skipped without
consuming a timeout slot.
- Concurrency-bounded execution (`maxConcurrent`, default 3) — strategies
run in parallel batches, not all-at-once.
- Per-strategy retry via `@suaveplan/retry` exponential backoff + full
jitter; configurable via `BootstrapRetryConfig`.
- Per-attempt timeout that aborts an in-flight strategy via an inner
`AbortController` chained to the caller's `signal`.
- Deduplication by `peerId` across all strategy results.
- Early stop once `maxCollectedPeers` has been collected (saves work when
the head of the chain produces enough peers).
- Emits `bootstrap:started`, `bootstrap:peer-found`, `bootstrap:exhausted`
on an optional `NetworkBus`.
- OTel: `bootstrap.chain.execute` span wraps `discover()`;
`bootstrap.peers_found_total`, `bootstrap.failures_total`,
`bootstrap.chain_duration_ms` instruments are recorded.
## Basic Example
```ts
import { DiscoveryChain } from "@suaveplan/network-bootstrap";
const chain = new DiscoveryChain({
strategies: [seedStrategy, mdnsStrategy],
maxConcurrent: 2,
});
const peers = await chain.discover(2000);
```
## Advanced Example
```ts
import { NetworkBus } from "@suaveplan/network";
import { DiscoveryChain } from "@suaveplan/network-bootstrap";
import type { BootstrapEventMap } from "@suaveplan/network-bootstrap";
const bus = new NetworkBus<BootstrapEventMap>();
bus.on("bootstrap:peer-found", (ev) => {
console.log(`found ${ev.peer.peerId} via ${ev.strategy}`);
});
const chain = new DiscoveryChain({
strategies: [dhtStrategy, mdnsStrategy, seedStrategy],
maxConcurrent: 3,
maxCollectedPeers: 20,
retry: {
maxAttempts: 4,
initialDelayMs: 250,
maxDelayMs: 4000,
factor: 2,
},
bus,
});
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);
const peers = await chain.discover(2000, controller.signal);
```
## API Reference
- `new DiscoveryChain(options?: DiscoveryChainOptions)`.
- `discover(timeoutMs: number, signal?: AbortSignal): Promise<PeerInfo[]>`
— runs the chain; always resolves with the collected peer list (failure
is reported via OTel counters and bus events, not thrown).
- `addStrategy(strategy: DiscoveryStrategy): void`.
- `removeStrategy(name: string): boolean`.
- `getStrategies(): string[]`.
`DiscoveryChainOptions`: `strategies`, `maxConcurrent`,
`maxCollectedPeers`, `retry`, `clock`, `bus`.
## Implementation Notes
`Promise.race` between the strategy and a timeout promise is the cleanest
way to enforce a per-attempt budget without forking another microtask. The
inner `AbortController` is unconditionally aborted on timeout so
strategies that wire it correctly can free resources. Retry uses
`createExponentialBackoff` + `applyJitter("full")` from `@suaveplan/retry`
to keep the package consistent with every other backoff site in the
project. The chain swallows the last error and returns an empty list per
strategy — failure surfaces through `bootstrap.failures_total{strategy,
code}` and `bootstrap:exhausted` rather than via `throw`, so callers do not
need a try/catch around the bootstrap fast path. Early-stop on
`maxCollectedPeers` is checked before each batch, not mid-batch, to keep
the strategies-running-in-parallel invariant honest.

View File

@@ -0,0 +1,97 @@
# PeerCache
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`PeerCache` stores discovered peer records keyed by `peerId`, with TTL-based
expiration and capacity-based eviction. It is the persistent memory of the
bootstrap subsystem — strategies populate it on discovery, and downstream
transports query it to find candidate peers without re-running discovery.
Persistence is opt-in: pass a `storage` adapter (anything implementing the
narrow `PeerCacheStorageLike` surface — `AbstractStorage` from
`@suaveplan/storage` qualifies) plus a `persistKey` and the cache can flush
itself to durable storage and reload on next start.
## Features
- In-memory `Map<peerId, PeerInfo>` with read-time TTL pruning.
- Capacity-bounded with oldest-first eviction (smallest `lastSeen`).
- Injectable `Clock` for deterministic tests.
- `flush(signal?)` / `load(signal?)` for opt-in JSON persistence through any
`PeerCacheStorageLike` backend.
- `CACHE_CORRUPTED` thrown when persistent state is unparseable, with the
underlying `cause` preserved.
- Zod-validated options at construction (`INVALID_OPTIONS` on bad input).
- `AbortSignal` threaded through both persistence methods.
## Basic Example
```ts
import { PeerCache } from "@suaveplan/network-bootstrap";
const cache = new PeerCache({ maxPeers: 50, ttlMs: 60_000 });
cache.add({
peerId: "p1",
addresses: ["/ip4/127.0.0.1/tcp/9001"],
lastSeen: Date.now(),
source: "seed",
});
const all = cache.getAll();
```
## Advanced Example
```ts
import { MemoryStorage } from "@suaveplan/storage";
import { PeerCache } from "@suaveplan/network-bootstrap";
const storage = new MemoryStorage();
const clock = () => 1_700_000_000_000;
const cache = new PeerCache({
maxPeers: 200,
ttlMs: 300_000,
persistKey: "net:peers",
storage,
clock,
});
await cache.load();
cache.add({
peerId: "p1",
addresses: ["/ip4/10.0.0.1/tcp/9001"],
lastSeen: clock(),
source: "mdns",
});
await cache.flush();
```
## API Reference
- `new PeerCache(config?: Partial<PeerCacheConfig>)` — validates options via
`PeerCacheConfigSchema`; throws `NetBootstrapError(INVALID_OPTIONS)` on
malformed input.
- `add(peer: PeerInfo): void` — insert-or-update; evicts oldest when at
`maxPeers`.
- `get(peerId: string): PeerInfo | undefined` — returns a defensive copy or
`undefined` if absent/expired.
- `getAll(): PeerInfo[]` — returns defensive copies sorted by `lastSeen`
descending; prunes expired entries as a side-effect.
- `remove(peerId: string): boolean` — true if removed.
- `clear(): void`.
- `flush(signal?: AbortSignal): Promise<void>` — no-op without persistence.
- `load(signal?: AbortSignal): Promise<void>` — throws
`NetBootstrapError(CACHE_CORRUPTED)` on undecodable payloads.
- `readonly size: number`.
## Implementation Notes
Eviction is O(n) on insert at capacity — the cache is sized for hundreds of
peers, not millions, so a min-heap is overkill. Defensive copies on `add` /
`get` / `getAll` ensure callers cannot mutate cache internals. Persistence
serialises only the non-expired snapshot — expired entries are pruned in
`flush()` before encoding. The cache emits no events directly; bus events
are owned by the `BootstrapPlugin` so they correlate with discovery rounds.

View File

@@ -0,0 +1,79 @@
# Errors
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the frozen error-code registry and `NetBootstrapError` class used by
every public boundary in `@suaveplan/network-bootstrap`. The codes are
declared via `defineCodes("NET_BOOTSTRAP", [...])` so they participate in the
repo-wide error-registry contract and are guaranteed to be stable across
versions.
## Features
- Frozen `NET_BOOTSTRAP_CODES` registry with four canonical codes:
`CHAIN_EXHAUSTED`, `CACHE_CORRUPTED`, `STRATEGY_TIMEOUT`, `INVALID_OPTIONS`.
- `NetBootstrapErrorCode` discriminated-union type for exhaustive switches.
- `NetBootstrapError` extending `SuaveplanError<NetBootstrapErrorCode>` —
carries the code, a message key for i18n resolvers, and an optional
`cause` chain.
- Compatible with the cause-traversal helpers in `@suaveplan/error`.
## Basic Example
```ts
import {
NET_BOOTSTRAP_CODES,
NetBootstrapError,
} from "@suaveplan/network-bootstrap";
throw new NetBootstrapError(
NET_BOOTSTRAP_CODES.CHAIN_EXHAUSTED,
"bootstrap.chain.exhausted"
);
```
## Advanced Example
```ts
import {
NET_BOOTSTRAP_CODES,
NetBootstrapError,
} from "@suaveplan/network-bootstrap";
try {
await plugin.discover();
} catch (err) {
if (
err instanceof NetBootstrapError &&
err.code === NET_BOOTSTRAP_CODES.STRATEGY_TIMEOUT
) {
// Surface a user-facing toast or retry with a longer budget.
retryWithLongerBudget();
} else {
throw err;
}
}
```
## API Reference
- `const NET_BOOTSTRAP_CODES` — frozen registry with keys
`CHAIN_EXHAUSTED` / `CACHE_CORRUPTED` / `STRATEGY_TIMEOUT` /
`INVALID_OPTIONS`.
- `type NetBootstrapErrorCode` — discriminated union of the registry values.
- `class NetBootstrapError extends SuaveplanError<NetBootstrapErrorCode>` —
constructor `(code, messageKey, options?)`. Inherits `cause`, `chain()`,
and `toJSON()` from `SuaveplanError`.
## Implementation Notes
The registry is declared via `defineCodes` so a duplicate code at module
import time is a hard error — codes cannot accidentally clash across the
project. The `messageKey` argument is not the human-readable message; it is
the i18n key that callers can resolve with `@suaveplan/i18n`. The four codes
cover every failure path documented in the proposal — there is no
`UNKNOWN` catch-all because every throw site in the package maps to exactly
one of these four conditions.

View File

@@ -0,0 +1,82 @@
# OTel surface
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Centralises every OpenTelemetry instrument used by
`@suaveplan/network-bootstrap`. Routing all spans, counters, and histograms
through one module keeps cardinality bounded and makes dashboards trivial to
wire up: subscribers only need to discover one tracer and one meter name.
## Features
- `withChainSpan(fn)` — opens `bootstrap.chain.execute`, passes the live span
to the body so the caller can add attributes, and ends the span in a
`finally` block (no leaked spans on throw).
- `recordPeersFound(strategy, count)` — increments
`bootstrap.peers_found_total{strategy}` by `count` (no-op when zero).
- `recordFailure(strategy, code)` — increments
`bootstrap.failures_total{strategy, code}` by 1.
- `recordChainDuration(ms)` — records into
`bootstrap.chain_duration_ms` histogram, clamping negative inputs to 0.
## Basic Example
```ts
import {
recordChainDuration,
recordPeersFound,
withChainSpan,
} from "@suaveplan/network-bootstrap";
await withChainSpan(async (span) => {
span.setAttribute("strategy.count", 3);
const start = Date.now();
// ...run chain...
recordPeersFound("mdns", 5);
recordChainDuration(Date.now() - start);
});
```
## Advanced Example
```ts
import {
NET_BOOTSTRAP_CODES,
recordFailure,
withChainSpan,
} from "@suaveplan/network-bootstrap";
await withChainSpan(async (span) => {
try {
await runStrategy("dht");
} catch (err) {
recordFailure("dht", NET_BOOTSTRAP_CODES.STRATEGY_TIMEOUT);
span.recordException(err as Error);
throw err;
}
});
```
## API Reference
- `withChainSpan<T>(fn: (span: Span) => Promise<T>): Promise<T>` — runs `fn`
inside `bootstrap.chain.execute`. Always ends the span. Re-throws on
failure after ending.
- `recordPeersFound(strategy: string, count: number): void` — no-op when
`count <= 0`.
- `recordFailure(strategy: string, code: string): void` — `code` SHOULD be
one of the `NET_BOOTSTRAP_CODES` values.
- `recordChainDuration(ms: number): void` — negative inputs are clamped to 0.
## Implementation Notes
All instruments are lazily acquired at module load via
`metrics.getMeter()` / `trace.getTracer()` so test environments without an
SDK installed still work — the no-op tracer/meter is returned. Labels are
restricted to `{strategy}` and `{strategy, code}` to keep cardinality
predictable in production dashboards: strategy names are caller-controlled
and bounded, error codes are bounded to the four-entry registry. The
histogram unit is declared as `ms` per OTel semantic-convention guidance.

View File

@@ -0,0 +1,81 @@
# Bootstrap middleware
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createBootstrapMiddleware` builds a `MessageMiddleware` that intercepts
incoming peer-discovery requests on a message pipeline and replies with the
current cached peer set. It is how a node serves its neighbours' bootstrap
queries without needing a separate request/response loop — the same
pipeline that handles ordinary messages also handles bootstrap requests.
## Features
- Zod-validated configuration at construction.
- Default intercept type: `BOOTSTRAP_REQUEST` — overridable to any string
list (e.g. multi-version protocols).
- On match: runs `plugin.discover()` and returns a `BOOTSTRAP_RESPONSE`
message with `payload: { peers }` and metadata enrichment
(`bootstrapPeerCount`, `bootstrapTimestamp`).
- Non-matching incoming messages pass through unchanged.
- Outgoing messages always pass through unchanged.
- Injectable `clock` for deterministic timestamp tests.
## Basic Example
```ts
import {
BootstrapPlugin,
createBootstrapMiddleware,
} from "@suaveplan/network-bootstrap";
const plugin = new BootstrapPlugin({ strategies: [seedStrategy] });
const middleware = createBootstrapMiddleware(plugin);
pipeline.use(middleware);
```
## Advanced Example
```ts
import {
BootstrapPlugin,
createBootstrapMiddleware,
} from "@suaveplan/network-bootstrap";
const plugin = new BootstrapPlugin({
strategies: [seedStrategy],
discoveryTimeoutMs: 1500,
});
const middleware = createBootstrapMiddleware(plugin, {
interceptTypes: ["BOOTSTRAP_REQUEST", "PEER_DISCOVERY_REQUEST"],
clock: () => fixedTimestamp,
});
pipeline.use(middleware);
```
## API Reference
- `createBootstrapMiddleware(plugin: BootstrapPlugin, config?:
Partial<BootstrapMiddlewareConfig>): MessageMiddleware` — throws
`NetBootstrapError(INVALID_OPTIONS)` on Zod parse failure. The returned
middleware has `name === "bootstrap-middleware"` and `priority === 2`.
`MessageMiddleware`:
- `onIncoming(message, context): Promise<MiddlewareMessage | null>`.
- `onOutgoing(message, context): Promise<MiddlewareMessage | null>`.
## Implementation Notes
The middleware is a thin lambda over `plugin.discover()`; it deliberately
does not maintain its own state so two consumers can mount the same plugin
behind two separate pipelines without coordination. Priority `2` slots it
after authentication / decoding middleware (which typically run at
priorities 0 and 1) but before application-level handlers — bootstrap
should never reach domain logic. The Zod schema rejects empty
`interceptTypes` strings so callers cannot accidentally disable the
middleware via configuration silently.

View File

@@ -0,0 +1,109 @@
# BootstrapPlugin
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`BootstrapPlugin` implements the `@suaveplan/network` `NetworkPlugin`
contract and wires the bootstrap subsystem (peer cache + discovery chain)
into a `NetworkNode`. It is the canonical entry point consumers register
with `node.use(new BootstrapPlugin(...))` to give a freshly started node a
way to find its first peers.
## Features
- `NetworkPlugin` lifecycle: `install` / `start` / `stop` / `dispose`,
every method idempotent.
- Zod-validated configuration at construction (throws
`NetBootstrapError(INVALID_OPTIONS)` on malformed input).
- Builds an internal `PeerCache` and `DiscoveryChain` from config; both are
retrievable via accessors for inspection.
- Auto-emits `bootstrap:*` events onto the node's `NetworkBus`.
- `discover(signal?)` runs a manual discovery round and merges new peers
into the cache.
- `dispose()` clears the cache and severs all references — re-install on a
disposed plugin is rejected with a structured error.
## Basic Example
```ts
import { NetworkNode } from "@suaveplan/network";
import { BootstrapPlugin } from "@suaveplan/network-bootstrap";
const plugin = new BootstrapPlugin({
strategies: [seedStrategy],
discoveryTimeoutMs: 2000,
});
const node = new NetworkNode().use(plugin);
await node.start();
const peers = plugin.peerCache?.getAll() ?? [];
```
## Advanced Example
```ts
import { NetworkNode } from "@suaveplan/network";
import { MemoryStorage } from "@suaveplan/storage";
import { BootstrapPlugin } from "@suaveplan/network-bootstrap";
const storage = new MemoryStorage();
const plugin = new BootstrapPlugin({
strategies: [dhtStrategy, mdnsStrategy, seedStrategy],
cache: {
maxPeers: 500,
ttlMs: 600_000,
persistKey: "net:peers",
storage,
},
discoveryTimeoutMs: 2500,
maxConcurrentDiscoveries: 4,
retry: {
maxAttempts: 4,
initialDelayMs: 200,
maxDelayMs: 5000,
factor: 2,
},
maxCollectedPeers: 20,
});
const node = new NetworkNode().use(plugin);
node.networkBus.on("bootstrap:exhausted", (ev) => {
console.log(`bootstrap done — ${ev.collected} peers`);
});
await node.start();
// Manual re-discovery later:
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);
await plugin.discover(controller.signal);
await node.stop();
await node.dispose();
```
## API Reference
- `new BootstrapPlugin(config?: Partial<BootstrapPluginConfig>)` — throws
`NetBootstrapError(INVALID_OPTIONS)` on Zod parse failure.
- `install(node: NetworkNode): void` — idempotent.
- `start(): Promise<void>` — throws `INVALID_OPTIONS` if called before
`install` or after `dispose`.
- `stop(): void` — idempotent.
- `dispose(): void` — idempotent; releases internal references.
- `discover(signal?: AbortSignal): Promise<PeerInfo[]>` — manual discovery
round; merges results into the cache.
- `get peerCache(): PeerCache | undefined`.
- `get discoveryChain(): DiscoveryChain | undefined`.
- `get isStarted(): boolean`.
## Implementation Notes
The plugin keeps `install`, `start`, `stop`, and `dispose` strictly
idempotent so it composes with the `PluginManager`'s reverse-order
teardown without surprises. Disposed plugins reject `install` and `start`
with `INVALID_OPTIONS` rather than silently no-op — silent failure on a
disposed plugin is a footgun. The `NetworkBus` cast at install time is
narrowed to `NetworkBus<BootstrapEventMap>` because the node's bus is
generic — the cast is safe because the bus accepts any subset of its key
union via the structural `EventBus` shape.

View File

@@ -0,0 +1,119 @@
# network-bootstrap types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the shared type vocabulary for `@suaveplan/network-bootstrap`.
Every public option struct, event payload, and pluggable contract lives here so
the rest of the package can stay focused on behaviour. Types are
implementation-agnostic — they describe shapes, not strategies, so consumers
can supply their own discovery strategies (mDNS, DHT, hardcoded seeds, cached
peers, ...) and still satisfy the `BootstrapPlugin` contract.
## Features
- `PeerInfo` — discovered-peer record (id, addresses, lastSeen, source).
- `DiscoveryStrategy` — pluggable discovery contract with `AbortSignal` on
every async method.
- `PeerCacheStorageLike` — minimal storage subset the cache needs (subset of
`@suaveplan/storage` `AbstractStorage` so the cache does not couple to every
storage capability).
- `PeerCacheConfig`, `BootstrapPluginConfig`, `BootstrapRetryConfig` — public
option structs (each has a matching Zod schema in `types.schemas.ts`).
- `BootstrapMiddlewareConfig`, `MessageMiddleware`, `MiddlewareMessage`,
`MiddlewareContext` — bootstrap middleware contract.
- `BootstrapEventMap` — typed event payloads emitted on the `NetworkBus`:
`bootstrap:started`, `bootstrap:peer-found`, `bootstrap:exhausted`.
## Basic Example
```ts
import type {
BootstrapPluginConfig,
DiscoveryStrategy,
} from "@suaveplan/network-bootstrap/types";
const seedStrategy: DiscoveryStrategy = {
name: "seed",
async discover() {
return [
{
peerId: "p1",
addresses: ["/ip4/127.0.0.1/tcp/9001"],
lastSeen: Date.now(),
source: "seed",
},
];
},
async isAvailable() {
return true;
},
};
const config: Partial<BootstrapPluginConfig> = {
strategies: [seedStrategy],
discoveryTimeoutMs: 2000,
};
```
## Advanced Example
```ts
import type {
BootstrapPluginConfig,
PeerCacheConfig,
} from "@suaveplan/network-bootstrap/types";
const cache: Partial<PeerCacheConfig> = {
maxPeers: 500,
ttlMs: 60_000,
persistKey: "net:bootstrap:peers",
clock: () => Date.now(),
};
const config: BootstrapPluginConfig = {
strategies: [],
cache,
discoveryTimeoutMs: 3000,
maxConcurrentDiscoveries: 4,
retry: {
maxAttempts: 3,
initialDelayMs: 200,
maxDelayMs: 5000,
factor: 2,
},
maxCollectedPeers: 20,
};
```
## API Reference
- `interface PeerInfo` — `{ peerId, addresses, lastSeen, source }`.
- `interface DiscoveryStrategy` — `discover(timeoutMs, signal?)` +
`isAvailable(signal?)`.
- `interface PeerCacheStorageLike` — `get` / `set` / `delete`.
- `interface PeerCacheConfig` — `maxPeers`, `ttlMs`, optional `persistKey`,
`storage`, `clock`.
- `interface BootstrapRetryConfig` — `maxAttempts`, `initialDelayMs`,
`maxDelayMs`, `factor`.
- `interface BootstrapPluginConfig` — `strategies`, `cache`,
`discoveryTimeoutMs`, `maxConcurrentDiscoveries`, `retry`,
`maxCollectedPeers`, optional `clock`.
- `interface BootstrapMiddlewareConfig` — `interceptTypes`, optional `clock`.
- `interface MessageMiddleware` — `name`, `priority`, `onIncoming?`,
`onOutgoing?`.
- `interface BootstrapEventMap` — `bootstrap:started` /
`bootstrap:peer-found` / `bootstrap:exhausted`.
## Implementation Notes
Every async method on these contracts accepts an `AbortSignal` — bootstrap
must be cancellable end-to-end. `PeerCacheStorageLike` is intentionally narrow:
`AbstractStorage` from `@suaveplan/storage` is a structural superset and slots
in directly without an adapter. `BootstrapEventMap` keys match the bus topics
defined in the proposal so consumers can subscribe with the same string
literals. Types here are pure — no runtime code — so the file is excluded from
docs-check via the standard `*.types.ts` rule by being placed under
`src/types/` flat layout (which the docs-check rule explicitly allows).

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,81 @@
# automerge-provider
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Automerge-backed implementation of the `CRDTProviderAdapter` contract. Wraps an `AutomergeRepoLike` (built by the caller-supplied `AutomergeRepoFactory`) that hides the layered network-adapter setup (`BroadcastChannelNetworkAdapter`, `BrowserWebSocketClientAdapter`) plus the optional `IndexedDBStorageAdapter` from the orchestrator. The orchestrator only ever talks to the structural surface; production wires the factory to a real `@automerge/automerge-repo` `Repo`, and tests wire a deterministic in-memory `FakeAutomergeRepo`.
## Features
- Pure adapter — never reaches into `@automerge/automerge-repo` directly.
- Repo `peer` event fans out into `onPeer` callback for every advertised peer the repo currently knows about (the structural `listPeers()` call lets the orchestrator stay synchronous with no awareness payloads of its own).
- Repo `peer-disconnected` event mirrors into `onPeerLost`.
- Initial `onSynced()` + a snapshot flush of `listPeers()` so a late-joining peer immediately sees the full set.
- Idempotent `destroy()` calls `repo.shutdown()` and swallows individual library errors.
- Factory wrapped in try / catch — failures map to `AUTOMERGE_SYNC_FAILED`.
## Usage: Basic Example
```typescript
import { AutomergeProviderAdapter } from "@suaveplan/network-discovery-crdt";
const adapter = new AutomergeProviderAdapter(
"demo",
{ enableBroadcastChannel: true },
(room, opts) => makeFakeRepo(room, opts)
);
const myId = await adapter.initialize({
onPeer: (m) => console.log("peer", m.id),
onPeerLost: (id) => console.log("lost", id),
onSynced: () => console.log("synced"),
});
```
## Usage: Advanced Example
```typescript
import { AutomergeProviderAdapter } from "@suaveplan/network-discovery-crdt";
import { Repo } from "@automerge/automerge-repo";
import { BroadcastChannelNetworkAdapter } from "@automerge/automerge-repo-network-broadcastchannel";
import { BrowserWebSocketClientAdapter } from "@automerge/automerge-repo-network-websocket";
import { IndexedDBStorageAdapter } from "@automerge/automerge-repo-storage-indexeddb";
const adapter = new AutomergeProviderAdapter(
"team-A",
{
websocketUrl: "wss://sync.internal.example",
enableBroadcastChannel: true,
enableIndexedDB: true,
storageKey: "team-a",
},
(room, opts) => {
const network = [];
if (opts.enableBroadcastChannel !== false)
network.push(new BroadcastChannelNetworkAdapter());
if (opts.websocketUrl)
network.push(new BrowserWebSocketClientAdapter(opts.websocketUrl));
const storage = opts.enableIndexedDB !== false
? new IndexedDBStorageAdapter(opts.storageKey ?? "automerge-discovery")
: undefined;
const repo = new Repo({ network, storage });
return wrapAsRepoLike(repo, room);
}
);
```
## API Reference
- **`new AutomergeProviderAdapter(room, opts, factory)`** — `opts: AutomergeConfig`, `factory: AutomergeRepoFactory`.
- **`initialize(callbacks, signal?)`** — returns the local peer id; throws `NetDiscCRDTError(AUTOMERGE_SYNC_FAILED)` on factory failure, `DISPOSED` if `destroy()` was already called.
- **`announce(metadata)`** — delegates to `repo.announce(metadata)`.
- **`leave()`** — no-op (Automerge has no first-class leave; remote peers drop us via the cleanup-loop TTL).
- **`listKnownPeers()`** — snapshot of `repo.listPeers()`.
- **`destroy()`** — `repo.shutdown()`; idempotent, swallows errors.
- **`isConnected: boolean`** — `true` while the repo is alive.
## Implementation Notes
Automerge's `Repo` does not expose a first-class peer-list at the time of this migration — the structural `listPeers()` on `AutomergeRepoLike` is the contract the production factory has to synthesise from `Repo` internals, and the test fake implements directly. The `leave()` no-op is deliberate: Automerge does not have a notion of "I am leaving the swarm" the way `y-webrtc` does, and crafting one synthetically (e.g. by writing a tombstone) would conflict with the `Repo`'s own GC semantics. Stale peers are eventually dropped by the cleanup loop in `CRDTIntervals`. The `factory` is the only point of contact with the real Automerge libraries, which keeps the adapter shippable as a universal package — no `import "@automerge/automerge-repo"` ever fires at module-load time.

View File

@@ -0,0 +1,81 @@
# crdt-discovery
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Orchestrator class for `@suaveplan/network-discovery-crdt`. Implements `DiscoveryStrategy` from `@suaveplan/network-bootstrap` so a `CRDTDiscovery` instance plugs into a bootstrap chain unchanged. Owns the lifecycle (`start` / `stop` / `dispose`), wires the configured provider adapter (Yjs or Automerge) into the shared `CRDTPeerManager` + `CRDTIntervals` infrastructure, and translates between the package-private `CRDTPeerMetadata` shape and the bootstrap-canonical `PeerInfo` shape on `discover()`.
## Features
- Strict construction-time provider selection (`provider: "yjs" | "automerge"`) — never branches on provider strings at runtime once constructed.
- Zod-validated `CRDTDiscoveryConfig` with per-provider gates (`yjs` block + `yjsDocFactory` are mandatory when `provider === "yjs"`; same for automerge).
- AbortSignal threaded through every async path (`start` / `stop` / `discover` / `isAvailable` / `announce`).
- Optional `EventBusLike<CRDTEventMap>` integration — emits `crdt:started`, `crdt:peer-discovered`, `crdt:peer-lost`, `crdt:sync-failed`.
- Optional `@suaveplan/service-discovery` `ServiceRegistry` integration — discovered peers auto-register / auto-deregister.
- Idempotent `start()` / `stop()` / `dispose()`; `dispose()` cannot be undone.
- OTel spans (`disc_crdt.start`, `disc_crdt.provider.<yjs|automerge>.sync`) + counters / histograms.
- Status snapshots (`getSyncStatus`, `getStatus`) for dashboards.
## Usage: Basic Example
```typescript
import { CRDTDiscovery } from "@suaveplan/network-discovery-crdt";
const discovery = new CRDTDiscovery({
provider: "yjs",
room: "demo",
yjs: { signaling: ["wss://signal.local"] },
yjsDocFactory: (room, opts) => ({
doc: makeDoc(room),
provider: makeProvider(opts),
}),
});
await discovery.start();
const peers = await discovery.discover(1000);
await discovery.stop();
```
## Usage: Advanced Example
```typescript
import { CRDTDiscovery } from "@suaveplan/network-discovery-crdt";
import { EventBus } from "@suaveplan/events";
import { ServiceRegistry } from "@suaveplan/service-discovery";
import type { CRDTEventMap } from "@suaveplan/network-discovery-crdt/types";
const bus = new EventBus<CRDTEventMap>();
const registry = new ServiceRegistry();
const discovery = new CRDTDiscovery({
provider: "automerge",
room: "team-A",
automerge: { websocketUrl: "wss://sync.example", enableIndexedDB: true },
automergeRepoFactory: (room, opts) => makeAutomergeRepo(room, opts),
interval: 5_000,
timeout: 30_000,
bus,
registry,
});
bus.on("crdt:peer-discovered", (e) => console.log("peer", e.peer.id));
const ctrl = new AbortController();
await discovery.start(ctrl.signal);
```
## API Reference
- **`new CRDTDiscovery(config, clock?)`** — `clock` defaults to `Date.now`. Throws `NetDiscCRDTError(INVALID_OPTIONS)` for any schema failure or missing per-provider factory.
- **`start(signal?)`** — initialise adapter + schedule loops; emits `crdt:started`. Throws `DISPOSED` after dispose.
- **`stop(signal?)`** — leave + tear down. Idempotent. Pre-aborted signal skips the leave broadcast.
- **`discover(timeoutMs, signal?)`** — returns `PeerInfo[]` (bootstrap shape). Throws `DISPOSED` after dispose.
- **`isAvailable(signal?)`** — `true` while running + not disposed.
- **`announce(peerInfo, signal?)`** — push an explicit announce into the shared CRDT. Throws `PROVIDER_FAILED` when not running.
- **`getMyPeerId()` / `getPeers()` / `getPeerCount()` / `getProvider()` / `getSyncStatus()` / `getStatus()`** — read accessors.
- **`dispose()`** — terminal; safe to call repeatedly.
## Implementation Notes
The construction branch on `provider` is the only place the orchestrator ever sees the provider string. From that point on every call goes through `this.adapter` — switching backends would mean instantiating a new `CRDTDiscovery`, which is intentional: Yjs and Automerge documents are not interchangeable, and a runtime swap would silently lose state. The `discover()` method always returns the snapshot the peer-manager currently holds; awareness updates flow asynchronously through the adapter callbacks, so a caller that needs deterministic ordering should subscribe to `crdt:peer-discovered` on the bus instead of polling `discover()`.
The optional `bus` and `registry` integrations live in the `CRDTPeerManager`; the orchestrator only owns the lifecycle-level `crdt:started` / `crdt:sync-failed` emissions. `crdt:sync-failed` is reused for two distinct conditions — provider init failure (fatal, surfaces the thrown error reason) and `PEER_REJECTED` from a peer-manager admit (non-fatal, reason `"room_full"`). Consumers branching on it should inspect the reason string.

View File

@@ -0,0 +1,73 @@
# crdt-intervals
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the three periodic loops every `CRDTDiscovery` instance needs: announce (re-broadcast local presence), heartbeat (refresh `lastSeen` so peers do not garbage-collect this peer), and cleanup (drop stale remote peers). Uses one `@suaveplan/timing` `Scheduler` so disposing the strategy cancels every pending timer atomically — there is no equivalent guarantee with raw `setInterval` handles, and the upstream version leaked timers on aborted shutdowns.
## Features
- One `Scheduler` per `CRDTIntervals` instance — `stop()` calls `scheduler.dispose()`, draining every pending tick.
- Self-rearming loops via `scheduler.schedule(...)`. Each tick re-queues itself; cancellation breaks the chain.
- Loops short-circuit when `isRunning()` returns `false` or `getMyPeerId()` is still `null`, so a slow `start()` cannot fire announces against an uninitialised adapter.
- Cleanup respects the configured `timeout` (ms) and routes drops through the caller's `handlePeerLost` so the peer-manager / bus / registry side-effects stay consistent.
- Records `disc_crdt.reconcile_duration_ms{provider}` on every announce.
## Usage: Basic Example
```typescript
import { CRDTIntervals } from "@suaveplan/network-discovery-crdt";
const intervals = new CRDTIntervals({
clock: () => Date.now(),
announceIntervalMs: 1_000,
heartbeatIntervalMs: 500,
cleanupIntervalMs: 1_000,
timeoutMs: 30_000,
provider: "yjs",
adapter,
peers: new Map(),
getMyPeerId: () => "p1",
isRunning: () => true,
selfMetadata: () => ({
id: "p1",
addresses: [],
metadata: {},
lastSeen: 0,
provider: "yjs",
}),
handlePeerLost: (id) => console.log("lost", id),
});
intervals.start();
// later:
intervals.stop();
```
## Usage: Advanced Example
```typescript
import { CRDTIntervals } from "@suaveplan/network-discovery-crdt";
import { createScheduler } from "@suaveplan/timing";
const intervals = new CRDTIntervals(
{
/* ...deps */
} as never,
(clock) => createScheduler({ clock, maxConcurrentTasks: 4 })
);
intervals.start();
```
## API Reference
- **`new CRDTIntervals(deps, schedulerFactory?)`** — deps shape documented above; `schedulerFactory` defaults to `(c) => createScheduler({ clock: c })`.
- **`start()`** — fire-and-forget queue of the three loops. Safe to call multiple times.
- **`stop()`** — dispose every pending timer. Idempotent.
- **`isStopped: boolean`** — true after `stop()` has fired.
## Implementation Notes
The heartbeat loop deliberately re-runs the same `selfMetadata()` builder as the announce loop, only at a shorter cadence. The underlying CRDT (Yjs Y.Map or Automerge document) de-duplicates the write via its own conflict-resolution — a heartbeat that lands on the same data is a no-op. The cleanup loop iterates the live `peers` map directly so we never snapshot megabytes of peer state per tick. A peer whose `lastSeen` is younger than `timeoutMs` is left untouched; entries past the threshold are dropped through the caller's `handlePeerLost` so the side-effects (event-bus emission, registry deregistration) stay in one place. The OTel reconciliation-duration histogram is recorded around the announce step because that is the operation operations teams need to chart — heartbeat is too noisy and cleanup is constant-time per peer.

View File

@@ -0,0 +1,80 @@
# crdt-peer-manager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
In-memory tracking layer for the deduped set of `CRDTPeerMetadata` records observed via the underlying CRDT awareness layer. Owns the lifecycle deltas — `admit` (insert / update), `touch` (heartbeat), `drop` (leave), `clear` (full reset) — and fans every delta out to the optional `EventBusLike<CRDTEventMap>` and the optional `@suaveplan/service-discovery` `ServiceRegistry`. Enforces the configured `maxPeers` cap on insert, throwing `NetDiscCRDTError` with code `PEER_REJECTED` rather than silently dropping records.
## Features
- O(1) lookup / insert / delete via `Map<string, CRDTPeerMetadata>`.
- Injectable `Clock` (every `lastSeen` write goes through the clock — deterministic in tests).
- Optional `EventBusLike` integration emits `crdt:peer-discovered` and `crdt:peer-lost` for every transition.
- Optional `ServiceRegistry` mirror — discovered peers register as `crdt:<room>:<peerId>` services, auto-deregister on drop.
- OTel `disc_crdt.peers_discovered_total{provider,outcome}` increments per outcome.
- Hard `maxPeers` cap with structured `PEER_REJECTED` failure.
## Usage: Basic Example
```typescript
import { CRDTPeerManager } from "@suaveplan/network-discovery-crdt";
const peers = new CRDTPeerManager({
clock: () => Date.now(),
provider: "yjs",
maxPeers: 100,
room: "demo",
});
peers.admit({
id: "p1",
addresses: ["wrtc-1"],
metadata: {},
lastSeen: 0,
provider: "yjs",
});
```
## Usage: Advanced Example
```typescript
import { CRDTPeerManager } from "@suaveplan/network-discovery-crdt";
import { EventBus } from "@suaveplan/events";
import { ServiceRegistry } from "@suaveplan/service-discovery";
import type { CRDTEventMap } from "@suaveplan/network-discovery-crdt/types";
const bus = new EventBus<CRDTEventMap>();
const registry = new ServiceRegistry();
const peers = new CRDTPeerManager({
clock: () => Date.now(),
bus,
registry,
provider: "automerge",
maxPeers: 50,
room: "team-A",
});
bus.on("crdt:peer-discovered", (e) => console.log("peer", e.peer.id));
peers.admit({
id: "p2",
addresses: ["wrtc-2"],
metadata: { app: "demo" },
lastSeen: 0,
provider: "automerge",
});
```
## API Reference
- **`new CRDTPeerManager(deps)`** — `deps: { clock, bus?, registry?, provider, maxPeers, room }`.
- **`admit(metadata)`** — returns `"added"` | `"updated"`; throws `NetDiscCRDTError(PEER_REJECTED)` when the cap is exceeded on insert.
- **`touch(peerId)`** — refresh `lastSeen` of an existing peer; no-op when unknown.
- **`drop(peerId)`** — remove peer; returns `boolean` indicating whether the peer existed.
- **`list()` / `get(peerId)` / `size()` / `getMap()`** — read accessors.
- **`clear()`** — drop every peer, firing `crdt:peer-lost` for each.
## Implementation Notes
The `getMap()` accessor returns the internal map directly so the cleanup-interval loop in `CRDTIntervals` can iterate without snapshotting on every tick. This is safe because `CRDTIntervals` only ever calls `drop()` on it (never raw `delete`), so the bus / registry side-effects always fire. The dedup behaviour relies on stable peer ids; if a CRDT backend rotates ids on reconnect, the new id appears as a new peer (intentional — we surface what the CRDT sees). `maxPeers` is enforced only on insert; an `admit` that updates an existing peer never throws, even if `size() > maxPeers` due to caller-initiated cap-shrinking.

View File

@@ -0,0 +1,79 @@
# yjs-provider
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Yjs-backed implementation of the `CRDTProviderAdapter` contract. Wraps a `Y.Doc` plus the `y-webrtc` `WebrtcProvider` built by the caller-supplied `YjsDocFactory`. Owns the `peers` `Y.Map` observe handler, the `synced` + `peers` provider events, and the destroy semantics. Designed so production wires `yjsDocFactory: (room, opts) => { const doc = new Doc(); const provider = new WebrtcProvider(room, doc, opts); return { doc, provider }; }`, while tests pass a deterministic in-memory `FakeYjs` that implements the same structural surface.
## Features
- Pure adapter — never reaches into `yjs` or `y-webrtc` directly. Every dependency on the upstream packages flows through `YDocLike` / `YMapLike` / `WebrtcProviderLike` structural types.
- Observe handler converts Y.Map deltas (add / update / delete) into `onPeer` / `onPeerLost` callbacks, ignoring the local peer.
- `provider.on("synced")` flushes the existing `peers` map into the callbacks so a late-joining peer immediately sees the full set.
- `provider.on("peers")` mirrors removed transport peers into `onPeerLost`.
- Idempotent `destroy()` swallows any teardown error from the upstream library.
- Caller-supplied factory is wrapped in a try/catch so factory throws map to `YJS_AWARENESS_FAILED` rather than escaping unstructured.
## Usage: Basic Example
```typescript
import { YjsProviderAdapter } from "@suaveplan/network-discovery-crdt";
const adapter = new YjsProviderAdapter(
"demo",
{ signaling: ["wss://signal.local"] },
(room, opts) => ({ doc: makeDoc(room), provider: makeProvider(opts) })
);
const myId = await adapter.initialize({
onPeer: (m) => console.log("peer", m.id),
onPeerLost: (id) => console.log("lost", id),
onSynced: () => console.log("synced"),
});
adapter.announce({
id: myId,
addresses: [],
metadata: {},
lastSeen: 0,
provider: "yjs",
});
```
## Usage: Advanced Example
```typescript
import { YjsProviderAdapter } from "@suaveplan/network-discovery-crdt";
import { Doc } from "yjs";
import { WebrtcProvider } from "y-webrtc";
const adapter = new YjsProviderAdapter(
"team-A",
{ signaling: ["wss://signal.internal.example"], password: "secret" },
(room, opts) => {
const doc = new Doc();
const provider = new WebrtcProvider(room, doc, {
signaling: [...opts.signaling],
password: opts.password,
maxConns: opts.maxConns,
peerExchange: opts.peerExchange ?? true,
});
return { doc: doc as never, provider: provider as never };
}
);
```
## API Reference
- **`new YjsProviderAdapter(room, opts, factory)`** — `opts: YjsSignalingConfig`, `factory: YjsDocFactory`.
- **`initialize(callbacks, signal?)`** — returns the local peer id; throws `NetDiscCRDTError(YJS_AWARENESS_FAILED)` on factory failure, `DISPOSED` if `destroy()` was already called.
- **`announce(metadata)`** — `set` on the underlying `Y.Map`.
- **`leave()`** — `delete` on the underlying `Y.Map`; idempotent.
- **`listKnownPeers()`** — snapshot of the `Y.Map` minus the local peer.
- **`destroy()`** — unobserve + destroy provider + destroy doc; idempotent.
- **`isConnected: boolean`** — mirrors the underlying provider's `connected` flag.
## Implementation Notes
The adapter never falls back to the public `wss://signaling.yjs.dev` URL — every caller MUST supply a non-empty `signaling` array. Removing the upstream default was the most important security uplift in the migration: hard-coding a public signalling server leaks discovery metadata to a third party. Teardown swallows individual library exceptions because the upstream `yjs` and `y-webrtc` packages have historically thrown from `destroy()` when called on a partially-initialised instance, and we never want a teardown to crash the orchestrator. The factory wrapper translates any thrown value into `YJS_AWARENESS_FAILED` so consumers do not need to know about the upstream error types.

View File

@@ -0,0 +1,62 @@
# errors — `NetDiscCRDTError` registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the frozen `NET_DISC_CRDT_CODES` registry and `NetDiscCRDTError` class every public boundary in `@suaveplan/network-discovery-crdt` throws on failure. Centralising the code list keeps consumers' branching exhaustive: a `switch (error.code)` either covers the whole set or fails type-checking.
## Features
- Seven stable codes covering every failure path in the package: provider lifecycle (`PROVIDER_FAILED`), state-machine invariants (`STATE_DESYNC`, `DISPOSED`), peer-admission (`PEER_REJECTED`), input validation (`INVALID_OPTIONS`), and per-provider sync failures (`YJS_AWARENESS_FAILED`, `AUTOMERGE_SYNC_FAILED`).
- Codes registered via `defineCodes("NET_DISC_CRDT", [...])` so they appear in the repo-wide error registry and never collide with other packages' codes.
- Single `NetDiscCRDTError` class extending `SuaveplanError` — opaque enough to be caught with `instanceof`, structured enough to branch on `error.code`.
## Usage: Basic Example
```typescript
import {
NET_DISC_CRDT_CODES,
NetDiscCRDTError,
} from "@suaveplan/network-discovery-crdt";
throw new NetDiscCRDTError(
NET_DISC_CRDT_CODES.PROVIDER_FAILED,
"errors.crdt.provider_failed"
);
```
## Usage: Advanced Example
```typescript
try {
await discovery.start();
} catch (err) {
if (err instanceof NetDiscCRDTError) {
switch (err.code) {
case NET_DISC_CRDT_CODES.INVALID_OPTIONS:
case NET_DISC_CRDT_CODES.DISPOSED:
// fatal — re-throw
throw err;
case NET_DISC_CRDT_CODES.PROVIDER_FAILED:
case NET_DISC_CRDT_CODES.YJS_AWARENESS_FAILED:
case NET_DISC_CRDT_CODES.AUTOMERGE_SYNC_FAILED:
// transient — retry with backoff
break;
default:
throw err;
}
}
}
```
## API Reference
- **`NET_DISC_CRDT_CODES`** — frozen string-literal registry; keys equal the value strings.
- **`NetDiscCRDTErrorCode`** — TypeScript union of every value in the registry.
- **`NetDiscCRDTError`** — `class NetDiscCRDTError extends SuaveplanError<NetDiscCRDTErrorCode>`. Constructor: `(code, messageKey, options?: SuaveplanErrorOptions)`. `options.cause` accepts any thrown value and is preserved on the resulting `Error` chain.
## Implementation Notes
The seven-code surface is deliberately small. Provider-level distinctions (`YJS_AWARENESS_FAILED` vs `AUTOMERGE_SYNC_FAILED`) exist because the two underlying CRDT libraries have very different failure modes and a single "sync failed" code would conflate them in consumer dashboards. `STATE_DESYNC` is reserved for cases the CRDT's own conflict-resolution cannot fix — e.g. an awareness payload that fails its Zod schema even after merging. The orchestrator never throws on simple peer churn (joins / leaves through normal CRDT operations); those flow through the `EventBusLike` instead.

View File

@@ -0,0 +1,56 @@
# otel — OpenTelemetry instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Centralises every OTel instrument the package emits. Concentrating instrument construction in a single module keeps tracer / meter names consistent and stops the rest of the code from importing `@opentelemetry/api` directly. The instruments cover the discovery-strategy lifecycle (start span, per-provider sync span) and the two dimensions the operations team needs to chart: peer-discovery throughput per outcome (found / duplicate / rejected) per provider, and reconciliation-loop duration per provider.
## Features
- Single tracer / meter under `@suaveplan/network-discovery-crdt`.
- `withStartSpan` and `withProviderSyncSpan` wrappers managing span lifecycle, so the orchestrator never has to call `span.end()` manually.
- `disc_crdt.peers_discovered_total{provider,outcome}` counter — three-label-cardinality outcome enum (`found` | `duplicate` | `rejected`) so dashboards stay bounded.
- `disc_crdt.reconcile_duration_ms{provider}` histogram tracking awareness-reconciliation wall-clock.
## Usage: Basic Example
```typescript
import {
recordPeersDiscovered,
withStartSpan,
} from "@suaveplan/network-discovery-crdt";
await withStartSpan(async (span) => {
span.setAttribute("room", "demo");
recordPeersDiscovered("yjs", "found", 1);
});
```
## Usage: Advanced Example
```typescript
import {
recordReconcileDuration,
withProviderSyncSpan,
} from "@suaveplan/network-discovery-crdt";
const t0 = Date.now();
await withProviderSyncSpan("automerge", async () => {
// adapter-level sync
});
recordReconcileDuration("automerge", Date.now() - t0);
```
## API Reference
- **`withStartSpan<T>(fn)`** — runs `fn(span)` inside a `disc_crdt.start` span, always closes the span.
- **`withProviderSyncSpan<T>(provider, fn)`** — runs `fn(span)` inside a `disc_crdt.provider.<provider>.sync` span; auto-attaches the `provider` attribute.
- **`recordPeersDiscovered(provider, outcome, count?)`** — increments `disc_crdt.peers_discovered_total` (no-op when `count <= 0`).
- **`recordReconcileDuration(provider, ms)`** — records on `disc_crdt.reconcile_duration_ms`; clamps `ms` at `0`.
- **`PeerDiscoveryOutcome`** — `"found" | "duplicate" | "rejected"`.
## Implementation Notes
The outcome label-set is intentionally three-valued. Adding finer-grained outcomes (e.g. "stale" / "expired") would explode counter cardinality without giving the dashboards information they cannot already derive from the room-state snapshot. Both span helpers always call `span.end()` in a `finally` block, so a thrown error inside the wrapped function still closes the span cleanly. Negative durations are clamped at zero because OTel's histogram contract rejects negative samples and a clock-skew event from an injected `Clock` should not crash discovery.

View File

@@ -0,0 +1,70 @@
# types — public surface of `@suaveplan/network-discovery-crdt`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module hosts every type the public API of `@suaveplan/network-discovery-crdt` exposes: the `CRDTProvider` discriminator, the `CRDTDiscoveryConfig` / `ResolvedCRDTDiscoveryConfig` pair, the shared `CRDTProviderAdapter` contract both Yjs and Automerge backends conform to, the wire-format message envelope, the structural `YDocLike` / `YMapLike` / `WebrtcProviderLike` / `AutomergeRepoLike` views the test suite implements without touching real CRDT libraries, and the typed `CRDTEventMap` emitted on an optional `EventBusLike`. Keeping every type in one module means a downstream consumer can `import type { CRDTDiscoveryConfig } from "@suaveplan/network-discovery-crdt/types"` without pulling in the orchestrator class.
## Features
- Single-source `CRDTProvider` discriminator (`"yjs" | "automerge"`) referenced everywhere.
- Strict `CRDTDiscoveryConfig` requiring per-provider config + factory at construction time.
- `CRDTProviderAdapter` interface — both providers conform; orchestrator never branches on `provider` strings.
- Structural `YDocLike` / `YMapLike` / `WebrtcProviderLike` / `AutomergeRepoLike` views allowing real-implementation test fakes (no `vi.mock`).
- Typed `CRDTEventMap` for the optional `@suaveplan/events` bus integration.
- Frozen `CRDTMessageType` registry for the wire format (kept for parity with the rest of the discovery family).
## Usage: Basic Example
```typescript
import type {
CRDTDiscoveryConfig,
CRDTProvider,
} from "@suaveplan/network-discovery-crdt/types";
const cfg: CRDTDiscoveryConfig = {
provider: "yjs",
room: "demo",
yjs: { signaling: ["wss://signal.local"] },
yjsDocFactory: (room, opts) => ({ doc: makeDoc(), provider: makeProvider() }),
};
```
## Usage: Advanced Example
```typescript
import type {
CRDTProviderAdapter,
CRDTProviderCallbacks,
} from "@suaveplan/network-discovery-crdt/types";
class MyProvider implements CRDTProviderAdapter {
public readonly kind = "yjs" as const;
public get isConnected() { return true; }
public async initialize(cb: CRDTProviderCallbacks): Promise<string> { return "id"; }
public announce(): void {}
public leave(): void {}
public listKnownPeers() { return []; }
public destroy(): void {}
}
```
## API Reference
- **`CRDTProvider`** — `"yjs" | "automerge"` discriminator.
- **`CRDTMessageType`** / **`CRDTMessageTypeValue`** — frozen wire-format message-type registry.
- **`CRDTPeerMetadata`** — awareness-document peer record.
- **`YjsSignalingConfig`** / **`AutomergeConfig`** — per-provider option blocks.
- **`YDocLike`** / **`YMapLike`** / **`YMapEventLike`** / **`WebrtcProviderLike`** — structural Yjs surfaces.
- **`YjsDocFactory`** — `(room, opts) => { doc, provider }` injection point.
- **`AutomergeRepoLike`** / **`AutomergeRepoFactory`** — structural Automerge surfaces + injection.
- **`CRDTProviderAdapter`** / **`CRDTProviderCallbacks`** — shared provider contract.
- **`CRDTDiscoveryConfig`** / **`ResolvedCRDTDiscoveryConfig`** — public + post-resolve config.
- **`CRDTSyncStatus`** / **`CRDTConnectionStatus`** — status snapshots.
- **`CRDTEventMap`** + the four event types — typed `EventBusLike` payloads.
## Implementation Notes
This module is type-only — every export is a `type` / `interface`, except `CRDTMessageType` which is a frozen string-literal record kept compatible with the upstream wire format. Treating the wire-format as a record (not a TS `enum`) lets it survive `isolatedModules` and avoids the implicit `const enum` foot-gun. The `CRDTProviderAdapter` interface is the package's most important abstraction: it lets the orchestrator call `adapter.initialize()`, `adapter.announce()`, `adapter.leave()`, `adapter.destroy()` without ever knowing whether it's driving Yjs awareness or an Automerge repo. The structural `*Like` views deliberately keep only the surface the discovery layer needs — they are smaller than the real Yjs / Automerge type surfaces, which keeps the test fakes implementable in ~100 lines each.

View File

@@ -0,0 +1,153 @@
# @suaveplan/network-discovery-gundb - Browser-tier GunDB DiscoveryStrategy: gossip-over-WebSocket peer discovery via Gun. Relay list REQUIRED — no public-relay fallback. Mandatory Ed25519-signed peer announces. Zod wire-payload validation. Constant-time peer-ID compare. AbortSignal + EventBusLike + ServiceRegistry + OTel + Zod boundaries.
## Summary
Browser-tier `DiscoveryStrategy` for `@suaveplan/network-bootstrap`
chains. Wraps a [GunDB](https://gun.eco) instance — gossip-over-
WebSocket decentralised graph database — and exposes a deduped, capped
peer set to the bootstrap pipeline.
GunDB is flagged security-fragile upstream; this release tightens
every relevant attack surface.
<!-- begin:auto-docs -->
## Documentation tree
- [gundb-discovery](./src/core/gundb-discovery/gundb-discovery.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [gundb-discovery](./src/core/gundb-discovery/gundb-discovery.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [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 -->
- [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 -->
- [signing](./src/signing/signing.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [signing](./src/signing/signing.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [envelope-signing](./src/transport/envelope-signing.md) — Provides the cryptographic helpers used by `GunDBTransport` to sign every outgoing `MessageEnvelope` and verify every i…
- [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 --> - [gundb-discovery](./src/core/gundb-discovery/gundb-discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.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)_- [signing](./src/signing/signing.md) — <!-- begin:auto-docs --> _(no submodules)_- [signing](./src/signing/signing.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [envelope-signing](./src/transport/envelope-signing.md) — Provides the cryptographic helpers used by `GunDBTransport` to sign every outgoing `MessageEnvelope` and verify every i…
- [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)_- [gundb-discovery](./src/core/gundb-discovery/gundb-discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.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)_- [signing](./src/signing/signing.md) — <!-- begin:auto-docs --> _(no submodules)_- [signing](./src/signing/signing.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [envelope-signing](./src/transport/envelope-signing.md) — Provides the cryptographic helpers used by `GunDBTransport` to sign every outgoing `MessageEnvelope` and verify every i…
- [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)_- [gundb-discovery](./src/core/gundb-discovery/gundb-discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.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)_- [signing](./src/signing/signing.md) — <!-- begin:auto-docs --> _(no submodules)_- [signing](./src/signing/signing.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_- [envelope-signing](./src/transport/envelope-signing.md) — Provides the cryptographic helpers used by `GunDBTransport` to sign every outgoing `MessageEnvelope` and verify every i…
- [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)_- [gundb-discovery](./src/core/gundb-discovery/gundb-discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ - [errors](./src/errors/errors.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)_- [signing](./src/signing/signing.md) — <!-- begin:auto-docs --> _(no submodules)_ - [signing](./src/signing/signing.md) — <!-- begin:auto-docs --> _(no submodules)_- [transport](./src/transport/transport.md) — <!-- begin:auto-docs --> _(no submodules)_ - [envelope-signing](./src/transport/envelope-signing.md) — Provides the cryptographic helpers used by `GunDBTransport` to sign every outgoing `MessageEnvelope` and verify every i…
- [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)_
## Why this package
- Plug-and-play `DiscoveryStrategy` slot for network bootstrap
chains. Works alongside `@suaveplan/network-discovery-peerjs`,
`@suaveplan/network-discovery-trystero`,
`@suaveplan/network-discovery-webtorrent`, and
`@suaveplan/network-discovery-crdt`.
- **Relay list REQUIRED.** Upstream's public-relay fallback
(`https://gun-manhattan.herokuapp.com/gun`) is removed. Callers must
supply their own relays.
- **Every outbound announce is Ed25519-signed.** The local peer's
32-byte secret is the contract input.
- **Every inbound announce is Zod-parsed AND Ed25519-verified before
admission.** Failures silently drop the payload and increment
`gundb.invalid_messages_total{reason}` /
`gundb.signature_rejected_total{reason}` — a malicious peer cannot
crash a node by gossiping garbage.
- **Constant-time peer-ID compare** via `constantTimeEqual` from
`@suaveplan/crypto-utils`.
- Injectable `gunFactory` — production wires the real `Gun(config)`;
tests bind a deterministic `GunNodeLike` fake (no `vi.mock` ever
used).
- Typed `EventBusLike<GunDBEventMap>` integration.
- Optional `@suaveplan/service-discovery` `ServiceRegistry` mirroring.
- OTel surface: `disc_gundb.start` span; counters
`peers_discovered_total{outcome}`,
`gundb.invalid_messages_total{reason}`,
`gundb.signature_rejected_total{reason}`,
`disc_gundb.relay_errors_total{relay}`; histogram
`gundb_announce_duration_ms`.
- `AbortSignal` honoured on every async public method.
- 100% test + branch + function + line coverage.
## Basic example
```ts
import { GunDBDiscovery, publicKeyHex } from "@suaveplan/network-discovery-gundb";
const signingKey = crypto.getRandomValues(new Uint8Array(32));
const localPeerId = publicKeyHex(signingKey);
const discovery = new GunDBDiscovery({
peers: ["wss://my-private-relay.example.com/gun"],
gunFactory: async (cfg) => {
const { default: Gun } = await import("gun");
return Gun({ peers: cfg.peers as string[] }) as unknown as GunNodeLike;
},
localPeerId,
signingKey,
});
await discovery.start();
const peers = await discovery.discover(5_000);
console.log(`found ${peers.length} peers`);
await discovery.stop();
```
## Advanced example
```ts
import { EventBus } from "@suaveplan/events";
import { ServiceRegistry } from "@suaveplan/service-discovery";
import { DiscoveryChain } from "@suaveplan/network-bootstrap";
import {
GunDBDiscovery,
publicKeyHex,
type GunDBEventMap,
} from "@suaveplan/network-discovery-gundb";
const bus = new EventBus<GunDBEventMap>();
const registry = new ServiceRegistry();
const signingKey = crypto.getRandomValues(new Uint8Array(32));
bus.on("gundb:peer-discovered", ({ peer }) => {
console.log("peer:", peer.id, peer.addresses);
});
bus.on("gundb:relay-error", ({ reason }) => {
console.warn("relay:", reason);
});
const discovery = new GunDBDiscovery({
peers: [
"wss://relay-a.example.com/gun",
"wss://relay-b.example.com/gun",
],
room: "my-app/v1",
maxPeers: 256,
announceIntervalMs: 15_000,
gunFactory: async (cfg) => {
const { default: Gun } = await import("gun");
return Gun({
peers: cfg.peers as string[],
localStorage: cfg.localStorage,
}) as unknown as GunNodeLike;
},
localPeerId: publicKeyHex(signingKey),
signingKey,
bus,
registry,
});
const chain = new DiscoveryChain([discovery]);
const peers = await chain.discover(5_000);
```
## API
See sibling co-located markdown:
- [`src/types/types.md`](./src/types/types.md)
- [`src/errors/errors.md`](./src/errors/errors.md)
- [`src/otel/otel.md`](./src/otel/otel.md)
- [`src/signing/signing.md`](./src/signing/signing.md)
- [`src/core/gundb-discovery/gundb-discovery.md`](./src/core/gundb-discovery/gundb-discovery.md)
## License
MIT

View File

@@ -0,0 +1,198 @@
# gundb-discovery
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`GunDBDiscovery` is the browser-tier `DiscoveryStrategy` for
`@suaveplan/network-bootstrap` chains. It wraps a single `Gun` instance
(constructed via the injected factory) and exposes a deduped, capped
peer set to the bootstrap pipeline. GunDB itself is a gossip-over-
WebSocket decentralised graph database; this strategy publishes the
local peer's announce into a room-scoped record, subscribes to the
room's child records, and lifts every fresh inbound announce into a
typed peer-record.
The strategy is the **single security-hardening layer** in the upstream
GunDB migration. Upstream's hardcoded public-relay defaults
(`https://gun-manhattan.herokuapp.com/gun` and friends) are gone — the
`peers` list is mandatory and non-empty, enforced at construction time
by Zod. Every outbound announce is Ed25519-signed by the local peer's
secret; every inbound announce is Zod-parsed AND Ed25519-verified
before it can enter the local peer set. Failures silently drop the
payload and increment the `gundb.invalid_messages_total{reason}` and
`gundb.signature_rejected_total{reason}` counters — a malicious peer
cannot crash a node by gossiping garbage, and operators alert on the
counters instead.
## Features
- Implements `DiscoveryStrategy` (`name`, `discover`, `isAvailable`)
so it slots directly into `@suaveplan/network-bootstrap`
`DiscoveryChain` instances.
- Lifecycle: idempotent `start()` / `stop()` / `dispose()`.
- Injectable `gunFactory` — production wires the real `Gun(config)`;
tests pass a deterministic in-memory `GunNodeLike` fake (no
`vi.mock` ever used).
- Zod-validated configuration. Relay list is non-optional and
`.min(1)`-enforced. The factory is enforced at runtime in addition
to schema-level acceptance. `signingKey` validated as exactly
32-byte `Uint8Array`.
- **Every outbound announce is Ed25519-signed** by `signingKey`.
- **Every inbound announce is Zod-parsed**
(`GunDBPeerAnnounceSchema`) AND signature-verified
(`verifyAnnounceSignature` over the canonical body).
- **Constant-time peer-ID compare** via `constantTimeEqual` from
`@suaveplan/crypto-utils` — short-circuiting byte-level compares
would leak a side channel.
- Typed `EventBusLike<GunDBEventMap>` integration: `started`,
`peer-discovered`, `peer-lost`, `relay-error`, `stopped`.
- Optional `@suaveplan/service-discovery` `ServiceRegistry`
integration — peers auto-register on join, auto-deregister on
drop and on `stop()`/`dispose()`.
- OTel instrumentation: `disc_gundb.start` span; counters
`disc_gundb.peers_discovered_total{outcome}`,
`gundb.invalid_messages_total{reason}`,
`gundb.signature_rejected_total{reason}`,
`disc_gundb.relay_errors_total{relay}`; histogram
`gundb_announce_duration_ms`.
- `AbortSignal` honoured on every async public method.
- Peer cap (`maxPeers`, default `100`) enforced at admission.
- Spoofing guard: an inbound announce whose `peerId` does not match
the GunDB record key it was published under is rejected as
`wrong-kind`.
## Usage: Basic Example
```ts
import { GunDBDiscovery } from "@suaveplan/network-discovery-gundb";
const signingKey = crypto.getRandomValues(new Uint8Array(32));
const discovery = new GunDBDiscovery({
peers: ["wss://my-private-relay.example.com/gun"],
gunFactory: async (cfg) => {
const { default: Gun } = await import("gun");
return Gun({ peers: cfg.peers as string[] }) as unknown as GunNodeLike;
},
localPeerId: "peer-local",
signingKey,
});
await discovery.start();
const peers = await discovery.discover(5_000);
console.log(`found ${peers.length} peers`);
await discovery.stop();
```
## Usage: Advanced Example
```ts
import { EventBus } from "@suaveplan/events";
import { ServiceRegistry } from "@suaveplan/service-discovery";
import { DiscoveryChain } from "@suaveplan/network-bootstrap";
import {
GunDBDiscovery,
publicKeyHex,
type GunDBEventMap,
} from "@suaveplan/network-discovery-gundb";
const bus = new EventBus<GunDBEventMap>();
const registry = new ServiceRegistry();
const signingKey = crypto.getRandomValues(new Uint8Array(32));
const localPeerId = publicKeyHex(signingKey);
bus.on("gundb:peer-discovered", ({ peer }) => {
console.log("peer joined:", peer.id, peer.addresses);
});
bus.on("gundb:relay-error", ({ reason }) => {
console.warn("relay:", reason);
});
const discovery = new GunDBDiscovery({
peers: [
"wss://relay-a.example.com/gun",
"wss://relay-b.example.com/gun",
],
room: "my-app/v1",
maxPeers: 256,
announceIntervalMs: 15_000,
gunFactory: async (cfg) => {
const { default: Gun } = await import("gun");
return Gun({
peers: cfg.peers as string[],
localStorage: cfg.localStorage,
}) as unknown as GunNodeLike;
},
localPeerId,
signingKey,
bus,
registry,
});
const chain = new DiscoveryChain([discovery]);
const peers = await chain.discover(5_000);
```
## API Reference
- `new GunDBDiscovery(config, clock?)` — Zod-validates the config,
enforces a callable `gunFactory`, stores the resolved
configuration. Throws `NET_DISC_GUNDB.INVALID_OPTIONS` on
validation / missing factory / wrong-length signing key.
- `start(signal?)` — instantiates the Gun root via the factory,
scopes the peers node by room, subscribes to the room's children,
emits `started`. Throws `RELAY_FAILED` (with the underlying error
as `cause`) on factory failure; throws `DISPOSED` if disposed;
rethrows abort-error if signalled.
- `stop(signal?)` — idempotent. Detaches every subscription,
deregisters every peer from the registry, clears local state.
Emits `stopped` when the call wasn't aborted.
- `discover(timeoutMs, signal?)` — returns the current peer
snapshot mapped into `network-bootstrap` `PeerInfo[]`. Honours
signal up-front.
- `isAvailable(signal?)` — `true` only while running and not
disposed.
- `announce(peerInfo, signal?)` — signs a fresh
`GunDBPeerAnnounce` over `localPeerId` and publishes it into the
room. Also mirrors the local peer into the optional
`ServiceRegistry`. Throws `DISPOSED` / `NOT_RUNNING` when
appropriate; throws `RELAY_FAILED` if `put` rejects.
- `getPeers()` / `getPeerCount()` / `isRunning()` — synchronous
introspection.
- `dispose()` — permanent teardown; subsequent lifecycle calls
throw `DISPOSED`.
## Implementation Notes
- Inbound dispatch is a four-step pipeline: (1) skip-if-local
(constant-time compare); (2) Zod parse; (3) signature verify;
(4) admit / update / cap. Each step's failure mode has a
dedicated counter label so dashboards can separate protocol
malformations from cryptographic ones from spoofing attempts.
- `peerIdEquals` is the constant-time comparator. When the two
inputs differ in length, it pads to equal length and still calls
`constantTimeEqual` so the wall-clock cost is independent of the
shorter input.
- `classifyParseFailure` directly inspects the raw payload to tell
apart "unsigned" (missing `signature` / `signaturePublicKey`)
from "wrong-kind" (explicit non-`peer-announce` discriminator)
from "malformed" (anything else). This ensures the
signature_rejected counter increments correctly even when Zod's
failure path collapses both into a single rejection.
- The strategy never throws out of the inbound handler — a
malicious gossip neighbour must not be able to crash a node by
publishing garbage. Failures surface only via counters and the
optional `gundb:relay-error` bus event.
- The factory is `async`-tolerant so production code can
dynamically `import("gun")` (a Node-flavoured package with
`window` side-effects) without forcing this package itself to
bundle it.
- `dispose()` is idempotent; subsequent lifecycle calls throw
`DISPOSED`. `stop()` plus `dispose()` is the canonical shutdown
sequence, but `dispose()` alone is safe (it implies `stop()`).
- The `ServiceRegistry` registration id form is
`gundb:<room>:<peerId>` mirroring the WebTorrent / Trystero
family so a single `ServiceRegistry` watcher can see peers from
every discovery family without colliding.

View File

@@ -0,0 +1,108 @@
# errors
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Structured error registry for `@suaveplan/network-discovery-gundb`.
Registers eight typed codes via `defineCodes("NET_DISC_GUNDB", ...)` from
`@suaveplan/error`. The `NetDiscGunDBError` class extends
`SuaveplanError<NetDiscGunDBErrorCode>` so every error thrown out of the
package carries a stable `code` consumers can branch on, plus a
canonical `cause` chain and the i18n message-key contract used across
Suaveplan.
The wire-payload rejection codes (`UNSIGNED_ANNOUNCE`, `BAD_SIGNATURE`,
`INVALID_WIRE_PAYLOAD`) are deliberately **not** thrown out of the
inbound-message handler — a malicious peer must not be able to crash a
node by gossiping garbage. They are surfaced via the OTel
`gundb.invalid_messages_total{reason}` /
`gundb.signature_rejected_total{reason}` counters and the optional
`gundb:relay-error` bus event instead. The codes are still defined here
so callers can construct them in tests and so the reason labels stay
synchronized between the counters and the error registry.
## Features
- Eleven stable codes covering every public-API failure path plus the
three wire-payload drop reasons.
- `NetDiscGunDBError` extends `SuaveplanError<NetDiscGunDBErrorCode>` —
inherits `code`, `cause`, `messageKey`, and `.name`.
- Frozen const-tuple input to `defineCodes` so consumer code branching
on `error.code` gets full IntelliSense.
- `NetDiscGunDBErrorCode` value type derived from the const-tuple.
## Usage: Basic Example
```ts
import {
NET_DISC_GUNDB_CODES,
NetDiscGunDBError,
} from "@suaveplan/network-discovery-gundb";
try {
await discovery.start();
} catch (err) {
if (
err instanceof NetDiscGunDBError &&
err.code === NET_DISC_GUNDB_CODES.RELAY_FAILED
) {
// every relay was unreachable — fall through to the next
// strategy in the chain
}
}
```
## Usage: Advanced Example
```ts
import {
NET_DISC_GUNDB_CODES,
NetDiscGunDBError,
} from "@suaveplan/network-discovery-gundb";
function classify(err: unknown): string {
if (!(err instanceof NetDiscGunDBError)) return "unknown";
switch (err.code) {
case NET_DISC_GUNDB_CODES.RELAY_FAILED:
return "relay-unreachable";
case NET_DISC_GUNDB_CODES.DISPOSED:
return "post-dispose-call";
case NET_DISC_GUNDB_CODES.NOT_RUNNING:
return "before-start";
case NET_DISC_GUNDB_CODES.INVALID_OPTIONS:
return "bad-config";
case NET_DISC_GUNDB_CODES.PEER_CONNECT_FAILED:
return "peer-rejected";
default:
return "other";
}
}
```
## API Reference
- `NET_DISC_GUNDB_CODES` — frozen object exposing each code as both a
property key and a string value. Generated via `defineCodes`.
- `NetDiscGunDBErrorCode` — string-literal union of every code value.
- `NetDiscGunDBError` — concrete error class.
- `code: NetDiscGunDBErrorCode`
- `messageKey: string` — i18n key (e.g. `"errors.gundb.relay_failed"`).
- `cause?: unknown` — original error / structured info preserved.
- `name: "NetDiscGunDBError"` — for stack traces and `instanceof`-light
branch logic.
## Implementation Notes
- All public-API entry points use the same `NetDiscGunDBError` class —
consumers do not have to discriminate by `name`.
- `UNSIGNED_ANNOUNCE` / `BAD_SIGNATURE` / `INVALID_WIRE_PAYLOAD` are
listed in the registry so the counter `reason` labels and the error
codes use one canonical set of strings, which keeps the dashboards
aligned with the error taxonomy. They are not thrown out of the
message handler — see file-level comment.
- `defineCodes` enforces the `NET_DISC_GUNDB.` prefix at the registry
level so two packages can't collide on a bare code value.
- The class explicitly sets `this.name = "NetDiscGunDBError"` so
uglifier rename-passes don't corrupt the runtime brand.

View File

@@ -0,0 +1,115 @@
# otel
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
OpenTelemetry surface for `@suaveplan/network-discovery-gundb`. Exposes
one span wrapper (`withStartSpan`), four counters
(`disc_gundb.peers_discovered_total`,
`gundb.invalid_messages_total`, `gundb.signature_rejected_total`,
`disc_gundb.relay_errors_total`), and one histogram
(`gundb_announce_duration_ms`). All instruments resolve lazily via the
global OTel API so the package works with or without an SDK
registered.
The `gundb.invalid_messages_total{reason}` counter and the
`gundb.signature_rejected_total{reason}` counter are the
**security-observability lever** for the strategy — they are the only
external evidence of a misbehaving peer because the orchestrator
deliberately swallows inbound-message exceptions to keep nodes alive
under adversarial conditions. Operators MUST alert on non-zero rates
here.
## Features
- `withStartSpan(fn)` — wraps `start()` in `disc_gundb.start` with
guaranteed `span.end()` even on rejection. Attributes such as
`room` and `relay_count` are set by the caller inside the body.
- `recordPeersDiscovered(outcome, count?)` — increments
`disc_gundb.peers_discovered_total{outcome}`. Guards against
zero / negative counts.
- `recordInvalidMessage(reason)` — increments
`gundb.invalid_messages_total{reason}` where reason is one of the
literal labels `"malformed"`, `"wrong-kind"`, `"unsigned"`,
`"bad-signature"`.
- `recordSignatureRejection(reason)` — increments
`gundb.signature_rejected_total{reason}` where reason is
`"unsigned"` or `"bad-signature"`.
- `recordRelayError(url?)` — increments
`disc_gundb.relay_errors_total{relay}` with `"unknown"` fallback.
- `recordAnnounceDuration(ms)` — records a value into
`gundb_announce_duration_ms`. Negative inputs clamp to zero.
## Usage: Basic Example
```ts
import {
recordPeersDiscovered,
recordInvalidMessage,
} from "@suaveplan/network-discovery-gundb";
recordPeersDiscovered("found");
recordInvalidMessage("bad-signature");
```
## Usage: Advanced Example
```ts
import {
withStartSpan,
recordPeersDiscovered,
recordInvalidMessage,
recordSignatureRejection,
recordRelayError,
recordAnnounceDuration,
} from "@suaveplan/network-discovery-gundb";
await withStartSpan(async (span) => {
span.setAttribute("room", "my-app/v1");
span.setAttribute("relay_count", 2);
const start = performance.now();
try {
// ... publish announce ...
recordAnnounceDuration(performance.now() - start);
recordPeersDiscovered("found", 3);
} catch (err) {
recordRelayError("wss://relay.example.com/gun");
throw err;
}
});
// On the inbound side: caller dispatches a single rejection per
// dropped message.
recordInvalidMessage("wrong-kind");
recordSignatureRejection("unsigned");
recordSignatureRejection("bad-signature");
```
## API Reference
- `withStartSpan<T>(fn: (span) => Promise<T>): Promise<T>` — runs
`fn` inside a `disc_gundb.start` span; ends the span on both
resolution and rejection.
- `recordPeersDiscovered(outcome: "found" | "duplicate" | "rejected", count?: number): void`.
- `recordInvalidMessage(reason: "malformed" | "wrong-kind" | "unsigned" | "bad-signature"): void`.
- `recordSignatureRejection(reason: "unsigned" | "bad-signature"): void`.
- `recordRelayError(relay?: string): void` — `"unknown"` when omitted.
- `recordAnnounceDuration(ms: number): void` — negative clamped to 0.
## Implementation Notes
- Uses `@opentelemetry/api` only — no SDK lock-in. With no SDK
registered, every call is a no-op via the OTel noop
meter/tracer.
- The `reason` labels on both counters are deliberately bounded — the
package will never emit a free-form `reason` value, which prevents
cardinality blow-up on the metrics backend.
- `signature_rejected_total` and `invalid_messages_total` are kept
separate so dashboards can tell apart "garbage payload" from "valid
shape but bad crypto". An attacker probing the signature path
shows up only on the second counter.
- `gundb_announce_duration_ms` is a duration histogram; the bucket
layout is whatever the exporter / view configures.

View File

@@ -0,0 +1,84 @@
# GunDBPlugin
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`GunDBPlugin` adapts `GunDBTransport` to the `NetworkPlugin` lifecycle that `@suaveplan/network` consumes. It is a thin three-method facade — `install` registers the transport with the node's router, `stop` closes the transport and unregisters it, `dispose` releases the node reference. There is no independent security surface; every wire-level operation is delegated to `GunDBTransport`, which signs every outbound envelope and verifies every inbound one. Treat the plugin as a registration shim, not a place to add new behaviour.
The plugin ships in the same package as the discovery strategy and the transport itself so a single signing key + Gun factory + relay set can power both peer discovery and message transport. Splitting the plugin into a separate package would have doubled the security surface for no design gain.
## Features
- Implements `NetworkPlugin` from `@suaveplan/network` (`install` / `start` / `stop` / `dispose`)
- Canonical plugin name `"gundb"`
- Constructs the transport eagerly so config errors surface at the plugin boundary
- `start()` is a no-op — Gun is opened lazily on first connect or send
- `stop()` calls `transport.close()` and unregisters from the node
- `dispose()` is idempotent
## Usage
### Basic Example
```ts
import { ed25519 } from "@suaveplan/crypto-curve";
import { NetworkNode } from "@suaveplan/network";
import {
GunDBPlugin,
publicKeyHex,
} from "@suaveplan/network-discovery-gundb";
const sk = ed25519.randomPrivateKey();
const node = new NetworkNode();
node.use(
new GunDBPlugin({
peers: ["https://relay.example.com/gun"],
gunFactory: myGunFactory,
localPeerId: publicKeyHex(sk),
signingKey: sk,
})
);
await node.start();
```
### Advanced Example
```ts
import { GunDBPlugin } from "@suaveplan/network-discovery-gundb";
const plugin = new GunDBPlugin({
peers,
gunFactory,
localPeerId,
signingKey: sk,
room: "suaveplan-transport",
});
const node = new NetworkNode();
node.use(plugin);
await node.start();
// Use the transport directly via the plugin handle.
plugin.transport.onMessage((peerId, envelope) => {
/* ... */
});
await node.stop();
```
## API Reference
- `new GunDBPlugin(config: GunDBTransportConfig)` — eagerly constructs the inner `GunDBTransport`. Throws `NetDiscGunDBError(INVALID_OPTIONS)` on bad config.
- `plugin.name: "gundb"`
- `plugin.transport: GunDBTransport` — public handle for tests + advanced wiring.
- `install(node): void` — registers the transport with the node's router.
- `start(): void` — no-op.
- `stop(): Promise<void>` — closes the transport and unregisters it.
- `dispose(): void` — releases the node reference; safe to call multiple times.
## Implementation Notes
- The plugin holds a single internal reference to the `NetworkNode` so `stop()` can call `unregisterTransport`. Reset to `null` in `dispose`.
- `stop()` is safe to call without an `install`: the optional-chained `this.node?.unregisterTransport` is a no-op when `node` is null. The transport itself is always closed.
- No security checks live here — they all live in `GunDBTransport`. Adding a "raw publish" or "skip-sign" API to this plugin would be a security regression.

View File

@@ -0,0 +1,156 @@
# signing
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Ed25519 signing + verification primitives for `GunDBPeerAnnounce`
payloads. Provides three exported helpers: `signAnnounce` builds a
signed announce from a private key + body; `verifyAnnounceSignature`
returns the cryptographic verdict over an inbound announce;
`publicKeyHex` derives the hex-encoded public key from a 32-byte
secret. Internally, both sign and verify funnel through one canonical
byte serializer (`canonicalSerializeAnnounceBody`) so the same
deterministic byte string is signed and verified — that is the entire
load-bearing invariant for the signature contract.
Every signed announce that crosses the gossip network MUST be
verifiable by the receiver. The signing key is the announcing peer's
own Ed25519 secret, and the verifying key is carried inside the
announce payload itself. This closes the trivial-spoofing hole
(anyone listening on the relay can no longer fabricate announces in
another peer's name) but does NOT establish higher-layer identity
trust — a separate trust-anchor mechanism is the consuming
application's responsibility.
## Features
- `signAnnounce(privateKey, body)` produces a complete
`GunDBPeerAnnounce` with both `signaturePublicKey` and `signature`
populated.
- `verifyAnnounceSignature(announce)` returns `true` iff the inbound
signature checks out over the canonical body. Returns `false` for
every other condition — malformed hex, wrong length, bad
signature.
- `canonicalSerializeAnnounceBody(body)` exposes the byte
representation that is signed / verified. Same bytes both
directions or the contract breaks.
- Deterministic JSON helper (`canonicalJSON`) sorts object keys
lexicographically at every level so two announce bodies with the
same logical content produce byte-identical canonical strings
regardless of runtime insertion order.
- Pure: depends only on `@suaveplan/crypto-curve` (Ed25519).
## Usage: Basic Example
```ts
import {
publicKeyHex,
signAnnounce,
} from "@suaveplan/network-discovery-gundb/signing";
const signingKey = crypto.getRandomValues(new Uint8Array(32));
const peerId = publicKeyHex(signingKey); // hex-encoded Ed25519 pub key
const announce = signAnnounce(signingKey, {
kind: "peer-announce",
peerId,
addresses: ["/webrtc/p2p/local"],
metadata: { region: "eu-west" },
capabilities: ["webrtc", "datachannel"],
lastSeen: Date.now(),
});
```
## Usage: Advanced Example
```ts
import {
signAnnounce,
verifyAnnounceSignature,
canonicalSerializeAnnounceBody,
} from "@suaveplan/network-discovery-gundb/signing";
import { ed25519 } from "@suaveplan/crypto-curve";
const sk = ed25519.randomPrivateKey();
const announce = signAnnounce(sk, {
kind: "peer-announce",
peerId: "local",
addresses: [],
metadata: {},
capabilities: [],
lastSeen: 1_700_000_000_000,
});
// Round-trip
const ok = verifyAnnounceSignature(announce); // → true
// Tampering breaks verification.
const tampered: typeof announce = {
...announce,
lastSeen: announce.lastSeen + 1,
};
verifyAnnounceSignature(tampered); // → false
// Single-bit flip in the signature also breaks verification.
const flipped: typeof announce = {
...announce,
signature:
announce.signature.slice(0, -1) +
(announce.signature.endsWith("0") ? "1" : "0"),
};
verifyAnnounceSignature(flipped); // → false
// Caller-facing canonical bytes (rarely needed; verifier uses it internally).
canonicalSerializeAnnounceBody({
kind: "peer-announce",
peerId: "local",
addresses: [],
metadata: {},
capabilities: [],
lastSeen: 1,
});
```
## API Reference
- `canonicalSerializeAnnounceBody(body: GunDBPeerAnnounceBody): Uint8Array`
— deterministic UTF-8 byte representation of the announce body. Field
order is fixed; nested objects are key-sorted at every level.
- `signAnnounce(privateKey: Uint8Array, body: GunDBPeerAnnounceBody): GunDBPeerAnnounce`
— throws if `privateKey.length !== 32`. Computes the public key from
the private key and signs the canonical bytes.
- `verifyAnnounceSignature(announce: GunDBPeerAnnounce): boolean` —
`true` iff every signature condition holds. Never throws.
- `publicKeyHex(privateKey: Uint8Array): string` — hex-encoded
Ed25519 public key derived from a 32-byte secret. Throws on wrong
length.
- `_hex.{toHex,fromHex}` — internal hex codec re-exposed so tests can
build malformed inputs without `node:buffer`.
## Implementation Notes
- The canonical line format is intentionally not valid JSON so callers
cannot accidentally substitute `JSON.stringify(body)` and silently
produce a non-deterministic, sometimes-verifiable signature. The
delimiter `|` is illegal in `kind` / hex `peerId` values so there is
no escaping ambiguity.
- Hex encoding uses lowercase `0-9a-f`; the Zod schema accepts either
case but the canonical output we sign / verify normalises to
lowercase. This is irrelevant to the contract because the hex
strings are never round-tripped through the canonical body — they
live only on the wire envelope.
- `verifyAnnounceSignature` never throws because it must be safe to
call on arbitrary attacker-controlled input. Every parse step
catches and returns `false`. The orchestrator distinguishes
"unsigned" (missing fields, caught at Zod time) from "bad-signature"
(verify returns `false`) at the call site.
- `ed25519.verify` is the noble-backed implementation from
`@suaveplan/crypto-curve` and itself returns `false` for invalid
inputs (it already catches internally), so the verifier here is a
thin orchestrator over the curve primitive.
- `signAnnounce` deliberately recomputes the public key from the
private key — callers cannot supply a mismatched (private, public)
pair, which would otherwise let a peer publish announces signed
with one key but verifiable with a different one.

View File

@@ -0,0 +1,61 @@
# envelope-signing
## Purpose
Provides the cryptographic helpers used by `GunDBTransport` to sign every outgoing `MessageEnvelope` and verify every inbound one. The transport adapter MUST go through these helpers — bypassing them would let a malicious relay or peer inject arbitrary messages into a node's inbox. The signing scheme is byte-identical in spirit to the peer-announce signing in `signing.ts`: a deterministic canonical serialization fed to Ed25519, with hex-encoded wire fields.
The canonical serializer sorts object keys lexicographically at every depth so any two impls produce the same bytes. Different bytes would silently allow signature forgery, so the canonicalization is the single most security-critical routine in this module.
## Features
- `signEnvelope(privateKey, senderPeerId, recipientPeerId, envelope) → SignedEnvelopeWire`
- `verifyEnvelopeSignature(wire) → boolean`
- `canonicalSerializeEnvelope(envelope) → Uint8Array`
- Hex-encoded public key + signature on the wire (64 / 128 chars)
- Returns `false` on every malformed input — never throws across the verify boundary
## Usage
### Basic Example
```ts
import { ed25519 } from "@suaveplan/crypto-curve";
import { createEnvelope } from "@suaveplan/network";
import {
signEnvelope,
verifyEnvelopeSignature,
} from "@suaveplan/network-discovery-gundb";
const sk = ed25519.randomPrivateKey();
const envelope = createEnvelope({
type: "ping",
senderId: "alice",
targetId: "bob",
payload: { hello: "world" },
});
const wire = signEnvelope(sk, "alice", "bob", envelope);
verifyEnvelopeSignature(wire); // true
```
### Advanced Example
A single-bit flip on the signature MUST cause `verifyEnvelopeSignature` to return `false`. The transport layer uses this to silently drop tampered envelopes and increment `gundb.signature_rejected_total{reason: "bad-signature"}`.
```ts
const wire = signEnvelope(sk, "alice", "bob", envelope);
const flippedLast = wire.signature.slice(0, -1) + "0";
const tampered = { ...wire, signature: flippedLast };
verifyEnvelopeSignature(tampered); // false — drop + count
```
## API Reference
- `canonicalSerializeEnvelope(envelope: MessageEnvelope): Uint8Array` — deterministic UTF-8 canonical form (sorted-keys JSON). Same envelope → same bytes regardless of insertion order.
- `signEnvelope(privateKey, senderPeerId, recipientPeerId, envelope) → SignedEnvelopeWire` — throws on non-32-byte keys.
- `verifyEnvelopeSignature(wire) → boolean` — returns `false` for malformed hex, wrong key/signature lengths, or verify rejection; never throws.
## Implementation Notes
The wire format is deliberately self-contained: `senderPeerId`, `recipientPeerId`, the envelope itself, the announcing public key, and the signature. The receiving transport cross-checks `senderPeerId` against `signaturePublicKey` so a peer cannot impersonate another by reusing a captured-but-still-valid envelope under a different identity. Recipient peerId is on the wire so a passive relay can route without inspecting the envelope.
Hex codecs are inlined (same as `signing.ts`) to keep the file dep-free beyond `@suaveplan/crypto-curve` and `@suaveplan/network`.

View File

@@ -0,0 +1,120 @@
# GunDBTransport
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`GunDBTransport` implements the `Transport` contract from `@suaveplan/network` over a GunDB relay set, reusing the very same Ed25519 signing key + canonical signing path that the discovery layer uses for peer-announces. The adapter is a thin facade — NOT a bypass — around the existing security primitives in this package: every outbound envelope is signed before it leaves the local process, and every inbound envelope is Zod-validated AND signature-verified before any caller-supplied `onMessage` handler is invoked. Drops are silent and counted; an attacker cannot crash a node by gossiping garbage.
This package was the natural home for a GunDB transport because the discovery package already wires the relay list, the Gun factory, and the signing key. Folding the transport into the same package keeps a single security surface and a single trust anchor; splitting would have meant duplicating signing/verifying code and a risk of drift.
## Security-preserving guarantee
**The transport adapter MUST NOT weaken the security layer.** Specifically:
- Every `send(peerId, envelope)` goes through `signEnvelope` — there is NO unsigned-message path.
- Every inbound payload goes through (1) `SignedEnvelopeWireSchema` Zod parse, (2) `verifyEnvelopeSignature` Ed25519 verify, (3) `senderPeerId == signaturePublicKey` cross-check, (4) `recipientPeerId == localPeerId` cross-check — BEFORE any caller handler is invoked.
- Drops are silent + counted. Reasons land on `gundb.invalid_messages_total{reason}` (malformed / wrong-kind) and `gundb.signature_rejected_total{reason}` (unsigned / bad-signature) — exactly the same counters the discovery layer uses for peer-announces.
If you find yourself wanting to add a "skip signing" or "skip verification" code path, STOP and ask for review — that is by construction a regression in this package's threat model.
## Features
- Full `Transport` interface (`connect` / `disconnect` / `send` / `onMessage` / `onPeerEvent` / `getConnectedPeers` / `close`)
- Ed25519 sign + verify on every envelope, mandatory and not toggleable
- Zod `SignedEnvelopeWireSchema` on every inbound payload — malformed shapes silently dropped + counted
- Constant-time peer-id compare via `@suaveplan/crypto-utils`
- AbortSignal-aware `send`
- Idempotent `connect` and `close`
- Lazy Gun-instance construction (factory called at most once per transport lifecycle)
- Inbox-key dedup so re-broadcasts from GunDB do not re-invoke handlers
- OTel surface: `gundb_transport.{connect,disconnect,send}` spans, `gundb_transport.messages_total{direction,outcome}` counter, `gundb_transport.active_peers` gauge
## Usage
### Basic Example
```ts
import { ed25519 } from "@suaveplan/crypto-curve";
import { createEnvelope } from "@suaveplan/network";
import {
GunDBTransport,
publicKeyHex,
} from "@suaveplan/network-discovery-gundb";
const sk = ed25519.randomPrivateKey();
const localPeerId = publicKeyHex(sk);
const transport = new GunDBTransport({
peers: ["https://relay.example.com/gun"],
gunFactory: (cfg) => myGunFactory(cfg),
localPeerId,
signingKey: sk,
});
transport.onMessage((peerId, envelope) => {
console.log("from", peerId, envelope);
});
await transport.connect("peer-2", "ignored-for-gundb");
await transport.send(
"peer-2",
createEnvelope({
type: "ping",
senderId: localPeerId,
targetId: "peer-2",
payload: {},
})
);
```
### Advanced Example
Pair with `GunDBDiscovery` so a single Gun + signing-key powers both peer discovery and message transport. Each plugin gets its own room namespace so the wire paths do not collide.
```ts
import {
GunDBDiscovery,
GunDBTransport,
} from "@suaveplan/network-discovery-gundb";
const discovery = new GunDBDiscovery({
peers,
gunFactory,
localPeerId,
signingKey: sk,
room: "suaveplan-discovery",
});
const transport = new GunDBTransport({
peers,
gunFactory,
localPeerId,
signingKey: sk,
room: "suaveplan-transport",
});
await discovery.start();
await transport.connect(remotePeerId, "ignored");
```
## API Reference
- `new GunDBTransport(config: GunDBTransportConfig)` — throws `NetDiscGunDBError(INVALID_OPTIONS)` on missing/empty `peers`, missing `gunFactory`, or non-32-byte `signingKey`.
- `transport.id: "gundb"` — canonical transport id used by `NetworkNode` to route messages.
- `connect(peerId, address): Promise<void>` — records the peer locally + emits `connected`. Throws `TRANSPORT_CLOSED` after `close()`.
- `disconnect(peerId): Promise<void>` — removes the peer + emits `disconnected`. No-op if unknown.
- `send(peerId, envelope, signal?): Promise<void>` — signs the envelope and writes it to the remote inbox. Throws `PEER_NOT_CONNECTED` / `TRANSPORT_CLOSED` / `SEND_FAILED` on failure paths.
- `onMessage(handler): () => void` — subscribe to verified inbound envelopes only.
- `onPeerEvent(handler): () => void` — subscribe to local connect/disconnect events.
- `getConnectedPeers(): readonly PeerId[]` — snapshot of currently connected peers.
- `close(): Promise<void>` — releases the inbox subscription and clears every peer + handler.
## Implementation Notes
- Wire format is `{kind: "envelope", senderPeerId, recipientPeerId, envelope, signaturePublicKey, signature}` — fully self-contained so no out-of-band context is required to verify integrity.
- `senderPeerId` is required to equal `signaturePublicKey` hex; this enforces a "peerId-from-key" contract that prevents key-substitution attacks where a captured-but-valid envelope is replayed under a different claimed identity.
- The Gun instance is opened lazily on first `connect` / `send` / `close` so callers can construct a transport without committing to a relay connection.
- `inboxKeys` deduplicates by message-id so the GunDB re-broadcast / replay-on-subscribe semantics never re-deliver the same envelope to a handler.
- Handler errors are swallowed so one bad subscriber cannot starve another — same policy as the discovery event bus.
- All security counters (`gundb.invalid_messages_total`, `gundb.signature_rejected_total`) increment from the transport path too, so dashboards do not need separate alerts for "discovery" vs "transport" abuse.

View File

@@ -0,0 +1,132 @@
# types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Public type surface for `@suaveplan/network-discovery-gundb`. Defines the
configuration contract (`GunDBDiscoveryConfig` /
`ResolvedGunDBDiscoveryConfig`), the wire-payload shapes that traverse
the gossip network (`GunDBMessage`, `GunDBPeerAnnounce`,
`GunDBPeerAnnounceBody`), the in-memory peer-record shape
(`GunDBPeerMetadata`), the structural Gun-instance contract
(`GunNodeLike` + `GunFactory`), and the typed event map
(`GunDBEventMap`) emitted on the optional `EventBusLike`.
These types are referenced by the orchestrator, the signing helpers, and
every test file. Zod schemas mirroring this surface live in
`./types.schemas.ts` and are the runtime contract enforced at every
boundary — both for caller-supplied configuration and for incoming wire
messages.
## Features
- `GunDBDiscoveryConfig` makes the **relay list mandatory and non-empty**
via Zod's `peers: z.array(z.string().url()).min(1)`. There is no public
default. Upstream's `https://gun-manhattan.herokuapp.com/gun` fallback
is gone.
- `gunFactory` is mandatory — no implicit `Gun(config)` construction.
- `signingKey` is a 32-byte `Uint8Array` (Ed25519 secret); every
outbound announce is signed over the canonical body.
- `GunDBPeerAnnounce` carries both `signaturePublicKey` and `signature`;
the body that is signed is the strict subset `GunDBPeerAnnounceBody`
(announce minus the two signature fields).
- Wire-payload validation is enforced via `GunDBMessageSchema`
(`z.discriminatedUnion("kind", ...)`) so future message kinds can
extend the union without rewriting the dispatcher.
- `GunNodeLike` is intentionally tiny — `get` / `put` / `map` / `on` /
`off` — so the deterministic in-memory `FakeGun` test fake covers the
full surface without `vi.mock`.
- `InvalidMessageReason` enumerates exactly the four reasons an inbound
payload may be silently dropped: `malformed`, `wrong-kind`,
`unsigned`, `bad-signature`. The orchestrator increments
`gundb.invalid_messages_total{reason}` for each.
## Usage: Basic Example
```ts
import type { GunDBDiscoveryConfig } from "@suaveplan/network-discovery-gundb";
const config: GunDBDiscoveryConfig = {
peers: ["wss://my-private-relay.example.com/gun"],
gunFactory: async (cfg) => {
const { default: Gun } = await import("gun");
return Gun({ peers: cfg.peers as string[] });
},
localPeerId: "peer-local",
signingKey: new Uint8Array(32), // ⚠ real callers must use a random key
};
```
## Usage: Advanced Example
```ts
import type {
GunDBDiscoveryConfig,
GunDBEventMap,
GunDBPeerMetadata,
} from "@suaveplan/network-discovery-gundb";
import { EventBus } from "@suaveplan/events";
const bus = new EventBus<GunDBEventMap>();
bus.on("gundb:peer-discovered", ({ peer }: { peer: GunDBPeerMetadata }) => {
console.log("joined:", peer.id, peer.addresses);
});
bus.on("gundb:relay-error", ({ reason }) => {
console.warn("relay:", reason);
});
const config: GunDBDiscoveryConfig = {
peers: ["wss://relay-a.example.com/gun", "wss://relay-b.example.com/gun"],
room: "my-app/v1",
maxPeers: 256,
peerTtlMs: 120_000,
announceIntervalMs: 15_000,
gunFactory: async (cfg) => {
const { default: Gun } = await import("gun");
return Gun({ peers: cfg.peers as string[], localStorage: cfg.localStorage });
},
localPeerId: "peer-local",
signingKey: crypto.getRandomValues(new Uint8Array(32)),
bus,
};
```
## API Reference
- `GunDBDiscoveryConfig` — input config. `peers` non-empty, `gunFactory`
callable, `localPeerId` non-empty, `signingKey` 32-byte Ed25519
secret. Optional: `room`, `localStorage`, `announceIntervalMs`,
`maxPeers`, `peerTtlMs`, `bus`, `registry`.
- `ResolvedGunDBDiscoveryConfig` — same shape with every optional field
defaulted. Exposed as `discovery.config`.
- `GunDBPeerMetadata` — in-memory peer record. Mutable `lastSeen`.
- `GunDBPeerAnnounce` — full wire shape including `signature` +
`signaturePublicKey`.
- `GunDBPeerAnnounceBody` — the subset that is canonically serialized
and signed.
- `GunDBMessage` — discriminated-union envelope of every wire kind.
- `GunNodeLike` — structural Gun-instance contract used by the
orchestrator and the `FakeGun` test fake.
- `GunFactory` / `GunFactoryConfig` — sync-or-async factory contract.
- `GunDBEventMap` — typed event map for the optional `EventBusLike`.
- `InvalidMessageReason` — `"malformed" | "wrong-kind" | "unsigned" |
"bad-signature"`. Drives the
`gundb.invalid_messages_total{reason}` counter.
## Implementation Notes
- Type-only — no runtime code. The runtime Zod boundary lives in
`./types.schemas.ts`.
- `signaturePublicKey` is hex-encoded (64 hex chars = 32 bytes) rather
than base64 to match the Ed25519 conventions used elsewhere in the
project and to keep the canonical signing input string-comparable.
- The `kind: "peer-announce"` discriminator is mandatory on every wire
payload. Future message kinds (heartbeat, leave) extend the
discriminated union; the orchestrator rejects anything with an
unknown `kind` as `wrong-kind`.
- `GunNodeLike` deliberately omits Gun's chained `set`/`time`/`map(cb)`
shapes — the strategy never uses them, so the test fake doesn't need
to implement them, and consumers can't accidentally lean on
behaviours we don't test.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,108 @@
# HybridDiscoveryManager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Top-level orchestrator composing any subset of the five Wave-2 sibling discovery strategies (peerjs / crdt / trystero / webtorrent / gundb) into a single composite that itself implements the `@suaveplan/network-bootstrap` `DiscoveryStrategy` contract. The manager fans `discover()` out across every healthy strategy, merges the results through the `PeerTracker` for de-duplication with `PeerSource` provenance, and surfaces a unified `HybridManagerStatus` snapshot.
Because the manager implements `DiscoveryStrategy` itself, it can be chained into a higher-level `DiscoveryChain` from `@suaveplan/network-bootstrap` — the hybrid is effectively a recursive composer.
## Features
- Composes any subset of the five Wave-2 strategies plus arbitrary `"custom"` strategies registered via `StrategyConfig.strategy`.
- Priority-based peer selection (`priority` mode), weighted concurrent fan-out (`weighted`), or fastest-non-empty-wins race (`fastest`).
- Per-strategy `StrategyHealth` tracking via the embedded `StrategyManager` (rolling success-rate EMA, consecutive failures, last error, peer count).
- Automatic fallback on degradation — an unavailable primary triggers a dormant fallback/backup to initialise.
- De-duplicated peer set with `PeerSource` provenance (`discoveredBy` is an ordered list of every strategy that has ever observed the peer).
- Unified lifecycle (`start` / `stop` / `dispose`) — idempotent; `stop()` cancels every periodic loop atomically and clears the peer set.
- Periodic health-check + stale-peer sweep driven by `@suaveplan/timing` `Scheduler` with an injectable clock for deterministic tests.
- AbortSignal threaded through every async path; `discover(timeoutMs, signal)` honours both the caller's signal and the internal `discoveryTimeoutMs` cap.
- Optional `@suaveplan/events` `EventBusLike<HybridDiscoveryEventMap>` integration for typed observability.
- OTel surface: `hybrid.peer_sources_total`, `hybrid.strategy_health_score`, `hybrid.fallback_total`, `hybrid.active_strategies`, `hybrid.discover_duration_ms`.
## Usage: Basic Example
```typescript
import { HybridDiscoveryManager } from "@suaveplan/network-discovery-hybrid";
const manager = new HybridDiscoveryManager({
strategy: "hybrid",
strategies: [
{ id: "peerjs", priority: "primary", strategy: peerJsStrategy },
{ id: "trystero", priority: "fallback", strategy: trysteroStrategy },
],
});
await manager.start();
const peers = await manager.discover(5_000);
await manager.stop();
```
## Usage: Advanced Example
```typescript
import { createEventBus } from "@suaveplan/events";
import { HybridDiscoveryManager } from "@suaveplan/network-discovery-hybrid";
import type { HybridDiscoveryEventMap } from "@suaveplan/network-discovery-hybrid";
const bus = createEventBus<HybridDiscoveryEventMap>();
bus.on("hybrid:fallback", ({ from, to }) =>
console.log(`Fallback ${from} → ${to}`)
);
const manager = new HybridDiscoveryManager(
{
strategy: "hybrid",
loadBalancing: "fastest",
autoFallback: true,
deduplication: true,
discoveryTimeoutMs: 8_000,
peerStaleMs: 30_000,
maxPeers: 500,
strategies: [
{ id: "peerjs", priority: "primary", weight: 70 },
{
id: "trystero",
priority: "fallback",
weight: 30,
factory: async () => {
const mod = await import("@suaveplan/network-discovery-trystero");
return new mod.TrysteroDiscovery({ /* ... */ });
},
},
],
},
{ bus }
);
const controller = new AbortController();
await manager.start(controller.signal);
try {
const peers = await manager.discover(5_000, controller.signal);
console.log(`Discovered ${peers.length} peers`);
console.log(manager.getStatus());
} finally {
await manager.dispose();
}
```
## API Reference
- `new HybridDiscoveryManager(config, options?)` — config validated via Zod; throws `INVALID_OPTIONS` on shape failure or `NO_STRATEGIES` on empty `strategies`. `options.bus` accepts an `EventBusLike<HybridDiscoveryEventMap>`.
- `start(signal?)` — initialise every strategy in priority order. Throws `ALL_STRATEGIES_DEGRADED` when no strategy initialises successfully.
- `stop(signal?)` — stop every active strategy + cancel periodic loops. Idempotent.
- `dispose(signal?)` — drive a hard shutdown; subsequent methods throw `DISPOSED`.
- `discover(timeoutMs, signal?)` — fan out discovery, merge results into the deduped set, return the current snapshot. Honours both the supplied `signal` and the config-level `discoveryTimeoutMs` cap (whichever is shorter).
- `isAvailable(signal?)` — `true` when running and at least one strategy is healthy.
- `getStatus()` — snapshot of `running`, `activeStrategies`, `totalPeers`, `peersByStrategy`, `strategies`, `loadBalancing`.
- `getPeerCount()` — total deduped peer count.
## Implementation Notes
`discover()` is the hot path. It composes the caller's `signal` with an internal `setTimeout` that fires after `min(timeoutMs, config.discoveryTimeoutMs)` ms, propagates abort to every strategy via the composite signal, and always cleans up its `setTimeout` in the `finally` block so a fast resolve does not leak a timer. The composite signal's outcome (`ok` / `empty` / `error` / `timeout`) is folded into the `hybrid.discover_duration_ms` histogram.
`PeerTracker` is owned exclusively by the manager. The two callbacks wired into the tracker forward dedup events directly to the optional bus. The `PeerSource.discoveredBy` array is intentionally append-only — once a strategy has seen a peer, it remains in the provenance chain until either the peer loses every strategy or the peer is swept for being stale. This guarantees provenance is a monotonic record per peer.
`Lifecycle` is started inside `start()` only after at least one strategy is active. This avoids a brief window where the health-check loop could fire before any strategy is registered and produce a no-op tick.

View File

@@ -0,0 +1,73 @@
# Lifecycle
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Self-rearming periodic loops orchestrating the hybrid manager's background work — running health checks against every registered strategy and sweeping stale peers from the `PeerTracker`. Replaces the upstream `setInterval` triple with a single injectable `Scheduler` from `@suaveplan/timing` so disposal is atomic and tests can drive the timeline deterministically.
The `Lifecycle` is a thin loop runner — it does not own any peer state, does not know about strategy health, and never communicates with the bus. The orchestrator (`HybridDiscoveryManager`) wires the loop callbacks to the `StrategyManager` and `PeerTracker` instances it owns.
## Features
- Two self-rearming loops: health check + stale-peer sweep.
- `Scheduler`-driven with injectable `clock` for deterministic tests.
- Idempotent `start()` and `stop()`.
- `stop()` disposes the scheduler atomically — pending timers are cancelled in one operation.
- Health-check failures are absorbed (the underlying `StrategyManager` records them into per-strategy health).
## Usage: Basic Example
```typescript
import { Lifecycle } from "@suaveplan/network-discovery-hybrid";
const lifecycle = new Lifecycle({
clock: () => Date.now(),
healthCheckIntervalMs: 30_000,
cleanupIntervalMs: 30_000,
runHealthCheck: () => strategyManager.runHealthCheck(),
sweepStalePeers: () => peerTracker.sweepStale(60_000),
});
lifecycle.start();
// ... later
lifecycle.stop();
```
## Usage: Advanced Example
```typescript
import { Lifecycle } from "@suaveplan/network-discovery-hybrid";
import { createScheduler } from "@suaveplan/timing";
// Inject a custom scheduler factory — useful for tests that want to
// install a manual clock and drain the queue on demand.
const lifecycle = new Lifecycle(
{
clock,
healthCheckIntervalMs: 1_000,
cleanupIntervalMs: 5_000,
runHealthCheck: async () => mgr.runHealthCheck(),
sweepStalePeers: () => tracker.sweepStale(60_000),
},
(c) => createScheduler({ clock: c })
);
lifecycle.start();
lifecycle.stop(); // safe to call multiple times
```
## API Reference
- `new Lifecycle(deps, schedulerFactory?)` — `deps` carries the clock, the two interval cadences, and the two callbacks. `schedulerFactory` defaults to `createScheduler` from `@suaveplan/timing`.
- `start()` — kick off both loops. Idempotent; no-op after `stop()`.
- `stop()` — dispose the scheduler. Idempotent.
- `isStarted` — `true` after `start()` has been called and before `stop()`.
- `isStopped` — `true` after `stop()` has been called.
## Implementation Notes
The two loops use `Scheduler.schedule` (one-shot) and re-arm themselves after every fire. This is preferable to `setInterval` because it avoids the "tick storm" where a slow health-check holds the runtime past its next-tick boundary and the scheduler enqueues multiple back-to-back fires. A re-arming one-shot only schedules the next tick after the current one has settled.
`scheduleSafe` guards against `Scheduler.disposed` so calls that race a concurrent `stop()` silently no-op instead of throwing `TIMING.SCHEDULER.DISPOSED`. This is the safe choice — by the time the scheduler is disposed, the orchestrator has already torn down every other piece of state and there is nothing meaningful for a stray callback to do.

View File

@@ -0,0 +1,84 @@
# PeerTracker
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
In-memory, dedup-aware peer registry that records every peer observation from every registered discovery strategy alongside its `PeerSource` provenance. The tracker is a pure data structure with synchronous methods only — it does no I/O, owns no timers, and is safe to drop into any orchestrator that needs to merge concurrent peer streams from heterogeneous strategies.
## Features
- Deduplication by `PeerInfo.peerId` with append-only `discoveredBy` provenance.
- Stable `primaryStrategy` field — the first strategy to ever observe the peer.
- Graceful primary-strategy demotion on peer-loss when multiple strategies still see the peer.
- Configurable `maxPeers` with LRU-by-lastSeen eviction.
- `sweepStale(staleMs)` for ageing out peers whose `lastSeen` is older than the threshold.
- Per-strategy peer counts via `countFor(strategy)`.
- Injectable `Clock` for deterministic tests.
- Optional `onPeerDiscovered` / `onPeerLost` callbacks for forwarding to a bus.
## Usage: Basic Example
```typescript
import { PeerTracker } from "@suaveplan/network-discovery-hybrid";
const tracker = new PeerTracker();
tracker.observe(
{
peerId: "alice",
addresses: [],
lastSeen: Date.now(),
source: "peerjs",
},
"peerjs"
);
console.log(tracker.size()); // 1
```
## Usage: Advanced Example
```typescript
import { PeerTracker } from "@suaveplan/network-discovery-hybrid";
let clockNow = 1_000;
const tracker = new PeerTracker(
{
onPeerDiscovered: (peer, strategy) =>
console.log(`+${peer.peerId} via ${strategy}`),
onPeerLost: (peerId) => console.log(`-${peerId}`),
},
{ clock: () => clockNow, maxPeers: 5, dedup: true }
);
const peer = {
peerId: "alice",
addresses: [],
lastSeen: clockNow,
source: "peerjs",
};
tracker.observe(peer, "peerjs");
tracker.observe(peer, "trystero"); // dedupes; discoveredBy = ["peerjs", "trystero"]
clockNow = 100_000;
tracker.sweepStale(10_000); // ages out "alice"
```
## API Reference
- `new PeerTracker(callbacks?, options?)` — `callbacks` may include `onPeerDiscovered` and `onPeerLost`; `options` accepts `clock`, `maxPeers` (default 1000), `dedup` (default true).
- `observe(peer, strategy)` — record a peer observation. New peer → emits `onPeerDiscovered`; existing peer → appends `strategy` to `discoveredBy` and refreshes `lastSeen`.
- `lose(peerId, strategy)` — record a peer-loss from a strategy. Demotes primary when needed; removes the peer when no strategies remain (or always when `dedup: false`).
- `sweepStale(staleMs)` — return the number of peers removed for being older than `now - staleMs`.
- `snapshot()` — readonly snapshot of every `PeerSource`.
- `peers()` — readonly snapshot of every `PeerInfo`.
- `size()` — current peer count.
- `countFor(strategy)` — peer count attributable to `strategy`.
- `clear()` — drop every tracked peer (silently — does not fire callbacks).
## Implementation Notes
The `discoveredBy` array is intentionally an array, not a `Set`, so insertion order is observable — the primary strategy is always the first entry, and the second entry is the natural successor when the primary loses the peer. This makes the demotion logic deterministic without needing a separate priority register.
When `maxPeers` is hit, the tracker evicts the peer with the smallest `lastSeen` value (least-recently-seen). This is preferable to FIFO because a long-lived peer that has been re-observed many times should outlive a fresh peer that was seen once and never again. The eviction emits `onPeerLost` so downstream listeners can react.

View File

@@ -0,0 +1,55 @@
# Strategy Loader
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Resolves a registered `StrategyConfig` to a concrete `DiscoveryStrategy` instance from `@suaveplan/network-bootstrap`. The loader bridges the two registration paths supported by the hybrid orchestrator: pre-built strategies (handed in directly for tests and statically-imported production code) and lazy factories (for optional peer-deps resolved via dynamic `import()`).
The loader is the single component that knows how to convert configuration into a live strategy, and it is responsible for translating every failure mode into the typed `STRATEGY_LOAD_FAILED` code so the orchestrator can branch on a stable contract.
## Features
- Two registration paths: pre-built `strategy` or lazy `factory`.
- Wraps every thrown error (sync or async) in `NetDiscHybridError(STRATEGY_LOAD_FAILED)` with `cause` preserved and `context.id` set to the strategy id.
- No static import of sibling discovery packages — optional peer-deps stay optional.
- Returns the resolved strategy synchronously when `config.strategy` is already provided (no Promise round-trip).
## Usage: Basic Example
```typescript
import { loadStrategy } from "@suaveplan/network-discovery-hybrid";
const strategy = await loadStrategy({
id: "peerjs",
priority: "primary",
strategy: prebuiltPeerJsStrategy,
});
```
## Usage: Advanced Example
```typescript
import { loadStrategy } from "@suaveplan/network-discovery-hybrid";
// Factory pattern — peer-dep is resolved lazily via dynamic import()
const strategy = await loadStrategy({
id: "trystero",
priority: "fallback",
factory: async () => {
const mod = await import("@suaveplan/network-discovery-trystero");
return new mod.TrysteroDiscovery({ /* ... */ });
},
});
```
## API Reference
- `loadStrategy(config)` — async. Returns the resolved `DiscoveryStrategy`. Throws `NetDiscHybridError(STRATEGY_LOAD_FAILED)` when both `strategy` and `factory` are absent, when the factory throws, or when the factory rejects.
## Implementation Notes
The loader does not memoise — every call to `loadStrategy` invokes `factory()` fresh. Memoisation is the orchestrator's responsibility (it caches the resolved strategy in the `StrategyManager` so subsequent `start()` / `stop()` operate against the same instance). This separation keeps the loader simple and testable.
Optional peer-deps are intentionally not imported statically by this module — the caller's factory is responsible for the dynamic `import()` and is the natural place to catch `ERR_MODULE_NOT_FOUND` and translate it into a more specific user-facing message. The loader's job is just to lift any thrown failure into the structured-error wire format.

View File

@@ -0,0 +1,81 @@
# StrategyManager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Per-strategy lifecycle, health bookkeeping, and priority-based fallback orchestration. The `StrategyManager` owns the `StrategyHealth` record for every registered strategy, drives `initializeAll()` / `stopAll()`, fans `discover()` calls out concurrently or under a fastest-wins race, and runs the periodic `runHealthCheck()` step that triggers fallback when a primary degrades.
It is a pure orchestration component — it never instantiates timers itself (the `Lifecycle` schedules health checks against it) and never speaks directly to a bus (it surfaces transitions through opt-in callbacks the `HybridDiscoveryManager` wires to an `EventBusLike`).
## Features
- Three-bucket priority ordering: `primary` → `fallback` → `backup`.
- Lazy strategy load via `loadStrategy`; load failures recorded into `StrategyHealth` without aborting the bootstrap.
- Rolling success-rate per strategy (0.7-weighted EMA) folded into OTel `hybrid.strategy_health_score`.
- `discoverAll()` supports `priority` / `weighted` fan-out and `fastest` race-to-first.
- `runHealthCheck()` calls `isAvailable()` on every live strategy and triggers `tryFallback()` when `autoFallback` is enabled.
- Active-strategy gauge incremented / decremented exactly once per lifecycle transition (OTel `hybrid.active_strategies`).
- `DUPLICATE_STRATEGY` thrown at construction when two configs share the same `id`.
- AbortSignal threaded through `initializeAll`, `stopAll`, `discoverAll`, and `runHealthCheck`.
## Usage: Basic Example
```typescript
import { StrategyManager } from "@suaveplan/network-discovery-hybrid";
const mgr = new StrategyManager({
strategy: "hybrid",
strategies: [{ id: "peerjs", priority: "primary", strategy: prebuilt }],
loadBalancing: "priority",
autoFallback: true,
deduplication: true,
discoveryTimeoutMs: 10_000,
peerStaleMs: 60_000,
maxPeers: 1000,
clock: () => Date.now(),
});
await mgr.initializeAll();
const byStrategy = await mgr.discoverAll();
```
## Usage: Advanced Example
```typescript
import { StrategyManager } from "@suaveplan/network-discovery-hybrid";
const mgr = new StrategyManager(resolvedConfig, {
onStrategyActive: (id) => bus.emit("hybrid:strategy-active", { id }),
onStrategyInactive: (id) => bus.emit("hybrid:strategy-inactive", { id }),
onStrategyFailed: (id, err) =>
bus.emit("hybrid:strategy-failed", { id, err }),
onFallback: (from, to) => bus.emit("hybrid:fallback", { from, to }),
});
const controller = new AbortController();
await mgr.initializeAll(controller.signal);
setInterval(() => mgr.runHealthCheck(controller.signal), 30_000);
```
## API Reference
- `new StrategyManager(config, callbacks?)` — `config` is `ResolvedHybridDiscoveryConfig`. Throws `DUPLICATE_STRATEGY` on dup ids.
- `initializeAll(signal?)` — lazy-load + start every strategy in priority order. Individual failures recorded, never thrown.
- `stopAll(signal?)` — stop every active strategy. Errors recorded, never thrown.
- `discoverAll(signal?)` — fan out discovery; returns `ReadonlyMap<StrategyId, readonly PeerInfo[]>`.
- `runHealthCheck(signal?)` — probe `isAvailable()` on every live strategy; trigger `tryFallback` when needed.
- `snapshotHealth()` — readonly array of `StrategyHealth`.
- `activeIds()` — list of active+healthy strategy ids.
- `updatePeerCount(id, count)` — update the per-strategy peer counter (called by the orchestrator after a `PeerTracker` sweep).
- `hasActiveStrategy()` — `true` when at least one strategy is active + healthy.
- `getEntry(id)` — read-only `{ config, health }` snapshot.
## Implementation Notes
The rolling success-rate uses an EMA with `α = 0.3` so a single failure drops the score noticeably without forgetting prior history. This score is the value recorded into `hybrid.strategy_health_score` and what dashboards should pivot on to spot a strategy degrading before it fully fails.
`raceFastest` is a hand-rolled `Promise` builder rather than `Promise.race` because the race must wait for a non-empty result, not just the first settlement — a strategy that resolves with `[]` should not win the race against a slower strategy that returns real peers. The builder tracks `outstanding` to detect "all strategies returned empty" and resolve with `null` so the orchestrator knows nothing was found.
Fallback walks the priority order (`fallback` before `backup`) and picks the first dormant entry. The `recordFallback` OTel counter is incremented exactly once per successful promotion so the `from→to` cardinality stays bounded to known strategy pairs.

View File

@@ -0,0 +1,74 @@
# Hybrid Discovery Errors
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Frozen error registry for `@suaveplan/network-discovery-hybrid`. Every public boundary throws `NetDiscHybridError` and consumers branch on `error.code`. The registry is the single contract the orchestrator exposes for failure modes — it is intentionally compact so consumers can exhaustively handle each case.
The registry is registered via `defineCodes` from `@suaveplan/error`, so the codes participate in the global error-code uniqueness gate enforced at build time. All codes share the `NET_DISC_HYBRID.` prefix.
## Features
- Six stable codes covering construction, lifecycle, and degradation failure paths.
- `NetDiscHybridError` subclasses `SuaveplanError<NetDiscHybridErrorCode>` so it integrates with the structured-error wire format, OTel span events, and i18n message resolution.
- Each thrown error carries an i18n message key (e.g. `errors.hybrid.invalid_options`) — the actual translation is resolved by the consumer's i18n stack.
- Includes the chained-cause when a Zod parse failure or a dynamic-import failure is the root cause.
## Usage: Basic Example
```typescript
import {
NET_DISC_HYBRID_CODES,
NetDiscHybridError,
} from "@suaveplan/network-discovery-hybrid";
try {
// ... call hybrid manager
} catch (error) {
if (error instanceof NetDiscHybridError) {
if (error.code === NET_DISC_HYBRID_CODES.NO_STRATEGIES) {
console.error("No strategies configured");
}
}
}
```
## Usage: Advanced Example
```typescript
import {
NET_DISC_HYBRID_CODES,
NetDiscHybridError,
} from "@suaveplan/network-discovery-hybrid";
function describe(err: NetDiscHybridError): string {
switch (err.code) {
case NET_DISC_HYBRID_CODES.NO_STRATEGIES:
return "no strategies were registered";
case NET_DISC_HYBRID_CODES.ALL_STRATEGIES_DEGRADED:
return "every strategy is unavailable";
case NET_DISC_HYBRID_CODES.STRATEGY_LOAD_FAILED:
return "an optional discovery peer-dep failed to load";
case NET_DISC_HYBRID_CODES.DUPLICATE_STRATEGY:
return "duplicate StrategyConfig.id detected";
case NET_DISC_HYBRID_CODES.INVALID_OPTIONS:
return "configuration failed Zod validation";
case NET_DISC_HYBRID_CODES.DISPOSED:
return "operation attempted after stop()";
}
}
```
## API Reference
- `NET_DISC_HYBRID_CODES` — frozen object with six keys (`NO_STRATEGIES`, `ALL_STRATEGIES_DEGRADED`, `STRATEGY_LOAD_FAILED`, `DUPLICATE_STRATEGY`, `INVALID_OPTIONS`, `DISPOSED`), each mapping to the namespaced string code.
- `NetDiscHybridErrorCode` — type union over the registry values.
- `NetDiscHybridError extends SuaveplanError<NetDiscHybridErrorCode>` — constructor `(code, messageKey, options?)`. Sets `name = "NetDiscHybridError"`.
## Implementation Notes
`STRATEGY_LOAD_FAILED` is intentionally non-terminal — the hybrid manager catches it internally, records the failure into the strategy's `StrategyHealth`, and continues with the remaining strategies. Only when every strategy has failed does the manager surface `ALL_STRATEGIES_DEGRADED` to the caller. This matches the upstream graceful-degradation behaviour and is what makes the orchestrator robust when an optional peer-dep is absent in the consumer's `node_modules`.
The six-code registry is the complete public surface. Any new failure path discovered during implementation should be classified into one of the existing codes rather than expanding the registry — the smaller the surface, the easier consumers can write exhaustive handlers.

View File

@@ -0,0 +1,72 @@
# Hybrid Discovery — OpenTelemetry Surface
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Exposes the OpenTelemetry instruments used by the hybrid orchestrator: counters for peer attribution and fallback events, a histogram for per-strategy health, an up-down counter for the active-strategy gauge, and span wrappers for `start()` and `discover()`. All instruments are namespaced under `@suaveplan/network-discovery-hybrid` so consumers can pivot dashboards by tracer / meter name.
Every public method in the orchestrator threads its observability through this module so the wire surface is uniform and easy to test (each instrument is a thin pass-through to the global OTel `meter` and `tracer`).
## Features
- `hybrid.peer_sources_total{strategy}` counter — incremented when a strategy contributes peers to the deduped set.
- `hybrid.strategy_health_score{strategy}` histogram — recorded after every `discover()` call with the rolling 0..1 success rate.
- `hybrid.fallback_total{from,to}` counter — recorded every time the orchestrator promotes a fallback past a degraded primary.
- `hybrid.active_strategies{strategy}` up-down counter — `+1` on activate, `-1` on deactivate; net gauge of currently-active strategies.
- `hybrid.discover_duration_ms{outcome}` histogram — wall-clock duration of `discover()` calls with outcome attribute (`ok` | `empty` | `error` | `timeout`).
- `withStartSpan` / `withDiscoverSpan` wrappers for span management.
## Usage: Basic Example
```typescript
import {
recordPeerSource,
recordStrategyHealth,
} from "@suaveplan/network-discovery-hybrid";
recordPeerSource("peerjs", 3);
recordStrategyHealth("peerjs", 0.95);
```
## Usage: Advanced Example
```typescript
import {
recordFallback,
recordActiveStrategyDelta,
withDiscoverSpan,
recordDiscoverDuration,
} from "@suaveplan/network-discovery-hybrid";
recordActiveStrategyDelta("trystero", 1);
recordFallback("peerjs", "trystero");
await withDiscoverSpan(async (_span) => {
const start = performance.now();
try {
// ... discovery work
recordDiscoverDuration(performance.now() - start, "ok");
} catch (e) {
recordDiscoverDuration(performance.now() - start, "error");
throw e;
}
});
```
## API Reference
- `recordPeerSource(strategy, count)` — increments `hybrid.peer_sources_total` by `count`. No-op when `count <= 0`.
- `recordStrategyHealth(strategy, score)` — records the rolling health score (0..1) for `strategy`.
- `recordFallback(from, to)` — increments `hybrid.fallback_total` with `{from, to}` attributes.
- `recordActiveStrategyDelta(strategy, delta)` — `delta ∈ {1, -1}`; tracks active-strategy gauge.
- `recordDiscoverDuration(ms, outcome)` — records `hybrid.discover_duration_ms` with the `outcome` attribute.
- `withStartSpan(fn)` — wraps `fn` in an OTel `hybrid.start` span.
- `withDiscoverSpan(fn)` — wraps `fn` in an OTel `hybrid.discover` span.
## Implementation Notes
The counters intentionally accept low-cardinality attributes only (`strategy` is one of six known IDs; `outcome` is one of four). High-cardinality data (per-peer ids, error messages) is never recorded — those go to span attributes, not metric attributes, to avoid blowing up the meter cardinality budget.
`recordActiveStrategyDelta` uses an `UpDownCounter` rather than a synchronous gauge so callers do not have to maintain a parallel observable-gauge callback. The orchestrator emits exactly one `+1` per activate and exactly one `-1` per deactivate, so the gauge stays accurate as long as those pair up — the lifecycle FSM in `lifecycle.ts` enforces that pairing.

View File

@@ -0,0 +1,78 @@
# Hybrid Discovery Types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the universal types consumed across the `@suaveplan/network-discovery-hybrid` package. The hybrid manager composes any subset of the five Wave-2 sibling discovery strategies behind a single `DiscoveryStrategy` contract from `@suaveplan/network-bootstrap`. This file is the single source of truth for the public surface of configuration, status, health, peer provenance, and bus-event payloads.
The types are intentionally framework-free and depend only on `@suaveplan/network-bootstrap` and `@suaveplan/types`. Concrete strategy implementations live in sibling packages and are pulled in lazily via the `StrategyLoader` so the universal consumer never pays for a strategy it does not register.
## Features
- `StrategyId` union covers the five shipped Wave-2 strategies plus a `"custom"` slot for caller-supplied strategies.
- `StrategyPriority` three-bucket model: `primary` / `fallback` / `backup`.
- `StrategyConfig` accepts either a pre-built `strategy` or a lazy `factory` so optional peer-deps can be resolved on demand.
- `HybridDiscoveryConfig` exposes `loadBalancing` (`priority` / `weighted` / `fastest`), `autoFallback`, `deduplication`, and tunable timeouts.
- `StrategyHealth` tracks active state, peer count, rolling success rate, consecutive failures, and last error.
- `PeerSource` records the de-duplicated peer with first-seen + last-seen provenance plus the ordered set of strategies that have observed it.
- `HybridDiscoveryEventMap` typed for use with `EventBusLike` from `@suaveplan/events`.
## Usage: Basic Example
```typescript
import type { HybridDiscoveryConfig } from "@suaveplan/network-discovery-hybrid/types";
const config: HybridDiscoveryConfig = {
strategy: "hybrid",
strategies: [
{ id: "peerjs", priority: "primary" },
{ id: "trystero", priority: "fallback" },
],
};
```
## Usage: Advanced Example
```typescript
import type {
HybridDiscoveryConfig,
StrategyHealth,
} from "@suaveplan/network-discovery-hybrid/types";
const config: HybridDiscoveryConfig = {
strategy: "hybrid",
loadBalancing: "weighted",
autoFallback: true,
deduplication: true,
discoveryTimeoutMs: 8000,
strategies: [
{ id: "peerjs", priority: "primary", weight: 70, timeoutMs: 4000 },
{ id: "trystero", priority: "fallback", weight: 30 },
{ id: "gundb", priority: "backup", weight: 10 },
],
};
function summarise(h: StrategyHealth): string {
return `${h.label}: ${h.active ? "active" : "idle"} (${h.peerCount} peers)`;
}
```
## API Reference
- `StrategyId` — `'peerjs' | 'trystero' | 'webtorrent' | 'crdt' | 'gundb' | 'custom'`.
- `StrategyPriority` — `'primary' | 'fallback' | 'backup'`.
- `StrategyConfig` — `{ id, priority, label?, weight?, timeoutMs?, strategy?, factory? }`. Either `strategy` or `factory` must be present (enforced by the schema).
- `HybridDiscoveryConfig` — top-level config. `strategies` is non-empty; all numeric tunables are optional with defaults.
- `ResolvedHybridDiscoveryConfig` — every optional field is materialised.
- `StrategyHealth` — per-strategy snapshot with `healthy`, `active`, `peerCount`, `lastDiscoverMs`, `successRate`, `consecutiveFailures`, `lastError?`, `lastCheck`.
- `PeerSource` — `{ peerId, discoveredBy, primaryStrategy, firstSeen, lastSeen, peer }`. `discoveredBy` is append-only and tracks every strategy that has seen the peer.
- `HybridManagerStatus` — `{ running, activeStrategies, totalPeers, peersByStrategy, strategies, loadBalancing }`.
- `HybridDiscoveryEventMap` — bus events keyed by topic. Use with `EventBusLike` from `@suaveplan/events`.
## Implementation Notes
The `StrategyConfig.strategy | factory` split is intentional. Tests register pre-built strategies directly; production code uses `factory` so the hybrid orchestrator can resolve a missing peer-dep with `STRATEGY_LOAD_FAILED` instead of crashing at module-evaluation time. The `clock` parameter on `HybridDiscoveryConfig` is a `() => number` per `@suaveplan/types` and is threaded into every timestamp-bearing call site so fast-check property tests can replay deterministic timelines.
`PeerSource.discoveredBy` is ordered — the first entry is always the `primaryStrategy`. When a strategy loses a peer, the strategy is filtered out of `discoveredBy`; if no strategies remain, the peer is removed entirely and `hybrid:peer-lost` fires. This matches the upstream behaviour while making provenance explicit at the type level.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,86 @@
# peer-connection-manager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the lifecycle of the `PeerLike` instance and the per-room `Map` of
WebRTC `DataConnectionLike` channels. Wraps the lower-level helpers in
`peerjs-connection.ts` behind a class so the orchestrator and the interval
loops can share one canonical view of the connection registry, the local
peer id, and the broker handle. Adds explicit `DISPOSED` semantics so a
caller cannot accidentally re-use a torn-down manager.
## Features
- `initialize(configuredPeerId, signal?)` resolves on broker `open`, threads
`AbortSignal` through, rejects with `SIGNALING_FAILED` on error.
- Incoming `peer.on("connection")` events flow through `acceptIncoming` →
`handleConnection` and emit the orchestrator's `onIncomingConnection`
callback for any per-peer bookkeeping (e.g. emitting `peerjs:peer-found`).
- `connectToPeer(peerId)` is idempotent on the registry and throws typed
errors instead of returning early.
- `broadcast(message)` fans out via `broadcastMessage`.
- `destroy()` closes every channel and destroys the `PeerLike`, swallowing
per-channel `close()` errors so disposal never throws halfway through.
## Usage: Basic Example
```ts
import { PeerConnectionManager } from "@suaveplan/network-discovery-peerjs/peer-connection-manager";
const mgr = new PeerConnectionManager(factory, peerOpts, {
onIncomingConnection: (c) => console.log("inbound", c.peer),
onMessage: (raw, c) => handleMessage(raw, c, deps),
onPeerLost: (id) => deps.handlePeerLost(id),
});
await mgr.initialize(undefined);
mgr.broadcast({ type: "announce", from: mgr.getMyPeerId()!, payload: meta, timestamp: Date.now() });
```
## Usage: Advanced Example
```ts
import { PeerConnectionManager } from "@suaveplan/network-discovery-peerjs";
const controller = new AbortController();
const mgr = new PeerConnectionManager(factory, peerOpts, callbacks);
try {
const myPeerId = await mgr.initialize(configured, controller.signal);
for (const target of seedPeers) {
try {
mgr.connectToPeer(target);
} catch (e) {
if ((e as { code?: string }).code === "NET_DISC_PEERJS.PEER_CONNECT_FAILED") {
failed.add(target);
}
}
}
} finally {
mgr.destroy();
}
```
## API Reference
- `new PeerConnectionManager(factory, peerOptions, callbacks)` — callbacks
are `{onIncomingConnection, onMessage, onPeerLost}`.
- `initialize(configuredPeerId?, signal?): Promise<string>` — returns the
assigned local peer id.
- `destroy(): void` — close + destroy. Idempotent.
- `getMyPeerId(): string | null`.
- `getConnections(): ReadonlyMap<string, DataConnectionLike>`.
- `hasConnection(peerId): boolean`.
- `removeConnection(peerId): void` — does not call `close()`.
- `connectToPeer(peerId): void` — throws `DISPOSED` or `PEER_CONNECT_FAILED`.
- `broadcast(message: PeerJSMessage): void`.
## Implementation Notes
The `disposed` flag is the only mutable lifecycle state — every guard
inspects it before touching the broker. `destroy()` clears the connections
map before destroying the peer so that any synchronous `close` event PeerJS
fires during teardown does not race against a registry mutation. The class
deliberately exposes `getConnections()` as a `ReadonlyMap` rather than a
cloned snapshot to keep the hot-path heartbeat broadcast allocation-free.

View File

@@ -0,0 +1,88 @@
# peerjs-connection
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Thin helpers around the `PeerLike` / `DataConnectionLike` structural surface
that own the WebRTC data-channel lifecycle: opening the peer against the
signalling server, wiring up the inbound `connection` / `data` / `error` /
`close` events, opening outbound `peer.connect(...)` calls, and broadcasting
to every open connection. Keeping the helpers as pure functions over the
injectable interfaces means the orchestrator stays small and every helper is
trivially unit-testable without a real PeerJS broker.
## Features
- `initializePeer` resolves on `peer.on("open")`, rejects with
`SIGNALING_FAILED` on `peer.on("error")` or constructor-throw.
- `AbortSignal` support — pre-aborted signals reject immediately; mid-flight
aborts call `peer.destroy()` and reject with the signal's `reason`.
- `handleConnection` wires open/data/error/close idempotently and adds the
connection to a shared registry on `open`.
- `connectToPeer` is idempotent on the registry and translates
synchronous `peer.connect` failures to `PEER_CONNECT_FAILED`.
- `broadcastMessage` skips closed connections and swallows per-connection
`send` errors so a single misbehaving peer cannot break the broadcast.
- Defensive guard around the user-supplied `onMessage` callback — a thrown
handler does not kill the channel.
## Usage: Basic Example
```ts
import { initializePeer, broadcastMessage } from "@suaveplan/network-discovery-peerjs/peerjs-connection";
const { peer, myPeerId } = await initializePeer(factory, "", {
host: "signaling.example.com",
port: 443,
path: "/",
secure: true,
debug: 0,
config: { iceServers: [] },
});
broadcastMessage({ type: "heartbeat", from: myPeerId, payload: {}, timestamp: Date.now() }, connections);
```
## Usage: Advanced Example
```ts
import { connectToPeer, handleConnection } from "@suaveplan/network-discovery-peerjs";
const controller = new AbortController();
const { peer, myPeerId } = await initializePeer(factory, "", opts, controller.signal);
peer.on("connection", (conn) => handleConnection(conn, connections, onRaw, onLost));
for (const id of remotePeerIds) {
try {
connectToPeer(peer, id, connections, onRaw, onLost);
} catch (err) {
if ((err as { code?: string }).code === "NET_DISC_PEERJS.PEER_CONNECT_FAILED") {
failures.add(id);
}
}
}
```
## API Reference
- `initializePeer(factory, peerId, options, signal?) → Promise<{peer, myPeerId}>`.
Rejects with `NetDiscPeerJSError` code `SIGNALING_FAILED` on broker error.
- `handleConnection(connection, connections, onMessage, onPeerLost)` — wires
the four data-channel events, mutates the `connections` map on `open`.
- `connectToPeer(peer, peerId, connections, onMessage, onPeerLost) →
DataConnectionLike | undefined` — `undefined` when already connected;
throws `NetDiscPeerJSError` code `PEER_CONNECT_FAILED` on connect failure.
- `broadcastMessage(message, connections) → void` — fan-out send.
## Implementation Notes
The `settle` helper in `initializePeer` guarantees the promise resolves
exactly once even if `peer.on("open")` and `peer.on("error")` both fire (or
the signal aborts in between). The `signal.removeEventListener` cleanup
keeps the listener set bounded so this helper is safe to call repeatedly
across reconnects. Defensive `try/catch` around the user `onMessage` is the
only `catch {}` in the package — message handlers must never throw because
PeerJS surfaces unhandled exceptions to the data-channel error path and would
disconnect the peer.

View File

@@ -0,0 +1,101 @@
# peerjs-discovery
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Top-level orchestrator for the PeerJS discovery strategy. Implements
`DiscoveryStrategy` from `@suaveplan/network-bootstrap` so the strategy can
slot directly into a `DiscoveryChain`. Wires the `PeerConnectionManager`,
`RoomManager`, `PeerJSIntervals`, message-handler dispatch table, and OTel
surface together behind a class that takes a single Zod-validated
`PeerJSDiscoveryConfig`. Refuses construction without an explicit `host`.
## Features
- Implements `DiscoveryStrategy` (`name`, `discover(timeoutMs, signal?)`,
`isAvailable(signal?)`).
- `start(signal?)` / `stop(signal?)` / `dispose()` lifecycle, all idempotent.
- Mandatory `host` + `peerFactory` — constructor throws `INVALID_OPTIONS`.
No public-`peerjs.com` fallback is reachable from this code.
- `AbortSignal` threaded through `start`, `stop`, `discover`,
`isAvailable`, `connect`.
- Optional `EventBusLike<PeerJSEventMap>` integration for typed event fan-out.
- Optional `ServiceRegistry` integration — discovered peers register under
the `peerjs:<room>` service name.
- OTel: `disc_peerjs.start` span, `peers_discovered_total{outcome}` counter,
`signaling_duration_ms` histogram.
- `DISPOSED` semantics — every public method after `dispose()` throws.
## Usage: Basic Example
```ts
import { PeerJSDiscovery } from "@suaveplan/network-discovery-peerjs";
const discovery = new PeerJSDiscovery({
host: "signaling.example.com",
room: "demo",
peerFactory: realPeerJsFactory,
});
await discovery.start();
const peers = await discovery.discover(5_000);
await discovery.stop();
```
## Usage: Advanced Example
```ts
import { PeerJSDiscovery } from "@suaveplan/network-discovery-peerjs";
import { DiscoveryChain } from "@suaveplan/network-bootstrap";
import { EventBus } from "@suaveplan/events";
import { ServiceRegistry } from "@suaveplan/service-discovery";
const bus = new EventBus<import("@suaveplan/network-discovery-peerjs/types").PeerJSEventMap>();
const registry = new ServiceRegistry({ maxServices: 1000, ttlMs: 120_000 });
const strategy = new PeerJSDiscovery({
host: "signaling.example.com",
port: 8443,
secure: true,
room: "alpha",
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
peerFactory: realPeerJsFactory,
bus,
registry,
});
bus.on("peerjs:peer-discovered", (e) => console.log("found", e.peer.id));
const chain = new DiscoveryChain({ strategies: [strategy] });
const controller = new AbortController();
const found = await chain.discover(2_000, controller.signal);
```
## API Reference
- `new PeerJSDiscovery(config, clock?)` — `clock` defaults to `Date.now`.
- `name: "peerjs"`.
- `config: ResolvedPeerJSDiscoveryConfig`.
- `start(signal?): Promise<void>`.
- `stop(signal?): Promise<void>`.
- `discover(timeoutMs, signal?): Promise<PeerInfo[]>` — returns bootstrap-
shaped `PeerInfo` records (`{peerId, addresses, lastSeen, source}`).
- `isAvailable(signal?): Promise<boolean>`.
- `connect(peerId, signal?): Promise<void>`.
- `getMyPeerId(): string | null`.
- `getPeers(): readonly PeerJSPeerRecord[]`.
- `getPeerCount(): number`.
- `dispose(): void`.
## Implementation Notes
The orchestrator deliberately keeps no state of its own beyond `running`,
`disposed`, and `myPeerId`: every other piece of state belongs to a
sub-module (room, connection manager, intervals). `start()` records signal
duration before flipping `running` so a slow broker handshake counts cleanly
even if it ends up failing. `stop()` short-circuits when called after abort,
skipping the `leave` broadcast because the transport is already gone. The
config is parsed via the strict Zod schema before any side effects so a
malformed call cannot wire up partial state.

View File

@@ -0,0 +1,87 @@
# peerjs-intervals
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Replaces upstream's three raw `setInterval` calls with a single `Scheduler`
from `@suaveplan/timing` running three self-rearming loops (announce,
heartbeat, cleanup). The injectable `Clock` makes every cadence deterministic
under test and the `scheduler.dispose()` atomicity guarantees `stop()` cannot
leak pending timers — the upstream package had no equivalent and required
manual `clearInterval` on every handle.
## Features
- Three self-rearming loops at independent cadences.
- Single underlying `Scheduler` — `stop()` disposes every pending task at
once.
- Injectable `Clock` everywhere (`clock()` is the canonical timestamp source).
- Idempotent `start()` / `stop()` — safe to call from any lifecycle hook.
- Optional `schedulerFactory` override for unit tests that need a fake
scheduler.
- Heartbeat and cleanup loops are pure; announce is async to mirror the
broadcast `await` semantics callers may expect.
## Usage: Basic Example
```ts
import { PeerJSIntervals } from "@suaveplan/network-discovery-peerjs/peerjs-intervals";
const intervals = new PeerJSIntervals({
clock: () => Date.now(),
announceIntervalMs: 30_000,
heartbeatIntervalMs: 15_000,
cleanupIntervalMs: 30_000,
timeoutMs: 60_000,
connections,
discoveredPeers,
getMyPeerId: () => myPeerId,
isRunning: () => running,
selfMetadata: () => ({ id: myPeerId, addresses: [], metadata: {}, lastSeen: Date.now() }),
handlePeerLost: (id) => removePeer(id),
});
intervals.start();
// later
intervals.stop();
```
## Usage: Advanced Example
```ts
import { PeerJSIntervals } from "@suaveplan/network-discovery-peerjs";
import { createScheduler } from "@suaveplan/timing";
let fakeNow = 0;
const fakeClock = () => fakeNow;
const intervals = new PeerJSIntervals(
{ ...deps, clock: fakeClock },
(clock) => createScheduler({ clock })
);
intervals.start();
fakeNow += 30_000;
// trigger scheduler ticks via the test scheduler in your harness...
intervals.stop();
```
## API Reference
- `PeerJSIntervalDeps` — `{clock, announceIntervalMs, heartbeatIntervalMs,
cleanupIntervalMs, timeoutMs, connections, discoveredPeers, getMyPeerId,
isRunning, selfMetadata, handlePeerLost}`.
- `new PeerJSIntervals(deps, schedulerFactory?)` — second arg overrides the
scheduler construction (defaults to `createScheduler({ clock })`).
- `start(): void` — kick off all three loops. Idempotent.
- `stop(): void` — dispose every pending timer. Idempotent.
- `isStopped: boolean` — true after `stop()`.
## Implementation Notes
Each `schedule(fn, delay)` callback re-arms itself at the tail to keep the
underlying scheduler one-shot. Re-arm is guarded by `stopped` and
`scheduler.disposed` so a callback that fires the same tick as `stop()` does
not enqueue a new task against a disposed scheduler. The announce loop awaits
the broadcast before re-arming so a slow signalling path stretches the
effective interval rather than stacking sends, which matches the upstream
behaviour but without the `setInterval` pile-up.

View File

@@ -0,0 +1,90 @@
# peerjs-message-handlers
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Pure dispatch table for inbound PeerJS discovery messages. Validates every
incoming wire payload through `PeerJSMessageSchema` (Zod) at the boundary and
routes well-formed messages to the matching case: `announce`, `discover`,
`peer_list`, `leave`, `heartbeat`. The handlers are intentionally stateless —
they never mutate the peer set directly. State lives in the orchestrator's
`RoomManager`, which the handlers call via the `admitPeer` / `touchPeer` /
`onPeerLost` callbacks. This split avoids double-bookkeeping bugs where two
places race to set the same entry.
## Features
- Zod-validated boundary — malformed messages drop silently instead of
crashing the strategy (discovery must tolerate hostile peers).
- Exhaustive switch over the wire-format enum with safe-fallthrough.
- Stateless: every state mutation is delegated to caller-supplied callbacks.
- `admitPeer` returns `"added" | "refreshed" | "rejected"` so the room can
enforce `maxPeers` admission centrally; the handler only reports the
outcome (`false` when `"rejected"`).
- Discover-request replies are skipped when the connection is closed,
guarding against a race between `discover` arrival and channel teardown.
- `knownPeerIds()` is a callback so the handler does not need to hold a Map
reference, keeping memory ownership inside the room manager.
## Usage: Basic Example
```ts
import { handleMessage } from "@suaveplan/network-discovery-peerjs/peerjs-message-handlers";
handleMessage(raw, conn, {
myPeerId,
clock: () => Date.now(),
knownPeerIds: () => room.listPeers().map((p) => p.id),
admitPeer: (r) => room.admit(r),
touchPeer: (id) => room.touch(id),
onPeerLost: (id) => room.drop(id),
connectToPeer: (id) => discovery.connect(id),
});
```
## Usage: Advanced Example
```ts
import { handleMessage } from "@suaveplan/network-discovery-peerjs";
const deps = {
myPeerId: "alpha",
clock,
knownPeerIds: () => Array.from(state.keys()),
admitPeer(r) {
const action = state.has(r.id) ? "refreshed" : "added";
state.set(r.id, r);
if (action === "added") metrics.peerFound.inc();
return action;
},
touchPeer: (id) => (state.get(id) ? (state.get(id)!.lastSeen = clock(), true) : false),
onPeerLost: (id) => state.delete(id),
connectToPeer: (id) => discovery.connect(id),
};
socket.on("data", (raw) => {
const accepted = handleMessage(raw, connection, deps);
if (!accepted) logger.debug("dropped malformed peerjs message");
});
```
## API Reference
- `PeerJSMessageHandlerDeps` — `{myPeerId, clock, knownPeerIds, admitPeer,
touchPeer, onPeerLost, connectToPeer}`.
- `handleMessage(raw, connection, deps): boolean` — returns `true` when the
message was accepted and routed, `false` when it was dropped (Zod failure,
admission rejected, heartbeat for unknown peer, discover with no local
peer id, send-to-closed-connection).
## Implementation Notes
The module never throws — drop-on-error is intentional because PeerJS data
channels can carry payloads from arbitrary remote peers and a strategy that
crashes on bad input would create a trivial DoS vector. The `default` arm of
the switch is unreachable in practice because Zod's enum constraint already
rejects unknown types; an `/* c8 ignore */` annotation acknowledges that for
the coverage gate. Fire-and-forget on `connectToPeer` is the same model
upstream used — the orchestrator owns retry policy.

View File

@@ -0,0 +1,80 @@
# room-manager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the logical-room view of discovered peers, layered on top of the raw
PeerJS connection registry. Provides admission (`maxPeers` cap), TTL-driven
pruning, deduped `lastSeen` refresh, and optional fan-out to both an
`EventBusLike` and a `ServiceRegistry` so the strategy can mirror state into
shared discovery infrastructure without baking either dependency in.
## Features
- Deduped peer set with the `maxPeers` admission cap enforced inside
`admit()` — `ROOM_FULL` outcomes increment the OTel counter and return a
discriminated `"rejected"`.
- `touch(peerId)` updates `lastSeen` in place — used by heartbeats.
- `pruneStale(timeoutMs)` iterates the map once per cleanup tick and drops
every peer past TTL, emitting `peerjs:peer-lost` for each.
- Optional bus emissions on every state transition (`peerjs:peer-discovered`,
`peerjs:peer-lost`).
- Optional `ServiceRegistry` integration — discovered peers are registered as
`peerjs:<room>` services keyed by peer id, deregistered on drop.
## Usage: Basic Example
```ts
import { RoomManager } from "@suaveplan/network-discovery-peerjs/room-manager";
const room = new RoomManager(() => Date.now(), "demo", 100, undefined, undefined);
const action = room.admit({ id: "alpha", addresses: [], metadata: {}, lastSeen: Date.now() });
// action === "added"
```
## Usage: Advanced Example
```ts
import { RoomManager } from "@suaveplan/network-discovery-peerjs";
import { ServiceRegistry } from "@suaveplan/service-discovery";
import { EventBus } from "@suaveplan/events";
const bus = new EventBus<import("@suaveplan/network-discovery-peerjs/types").PeerJSEventMap>();
const registry = new ServiceRegistry({ maxServices: 1000, ttlMs: 60_000 });
const room = new RoomManager(() => Date.now(), "alpha", 50, bus, registry);
bus.on("peerjs:peer-lost", (e) => console.log("lost", e.peerId));
// every announce arriving from the wire:
room.admit(record);
// every heartbeat:
room.touch(record.id);
// once per cleanup tick:
const dropped = room.pruneStale(60_000);
```
## API Reference
- `new RoomManager(clock, room, maxPeers, bus?, registry?)`.
- `getPeers(): ReadonlyMap<string, PeerJSPeerRecord>`.
- `listPeers(): readonly PeerJSPeerRecord[]`.
- `getPeer(peerId): PeerJSPeerRecord | undefined`.
- `size(): number`.
- `admit(record): "added" | "refreshed" | "rejected"`.
- `touch(peerId): boolean` — `false` if peer unknown.
- `drop(peerId): boolean` — `false` if peer unknown.
- `pruneStale(timeoutMs): number` — count dropped.
- `clear(): void` — drop every peer.
## Implementation Notes
`admit` writes a fresh record (spread-copy) on first admission to seal the
`metadata` and `capabilities` references against caller mutation, then
mutates `lastSeen` in place on subsequent refreshes to keep the hot heartbeat
path allocation-free. Pruning is done in a single pass over `entries()` and
uses `drop()` so emissions and registry deregistration stay consistent with
manual drops. The registry's `address` defaults to the first multiaddr or
falls back to the peer id so `ServiceRegistry.register` never rejects on an
empty `address` field.

View File

@@ -0,0 +1,78 @@
# errors
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Structured-error registry for `@suaveplan/network-discovery-peerjs`. Defines the
five frozen `NET_DISC_PEERJS_*` codes every public boundary uses and the
`NetDiscPeerJSError` class that wraps them with `SuaveplanError`'s standard
chained-cause + i18n-key plumbing. Centralising the registry here keeps every
throw site consistent and makes branching on `error.code` reliable across the
package and any downstream `network-bootstrap` chain consuming the strategy.
## Features
- Frozen `defineCodes("NET_DISC_PEERJS", [...])` registry — code names are
string-literal types, not arbitrary strings.
- `NetDiscPeerJSError` extends `SuaveplanError` with the discriminated code type.
- Stable code surface for downstream callers that switch on `error.code`.
## Usage: Basic Example
```ts
import {
NET_DISC_PEERJS_CODES,
NetDiscPeerJSError,
} from "@suaveplan/network-discovery-peerjs";
throw new NetDiscPeerJSError(
NET_DISC_PEERJS_CODES.INVALID_OPTIONS,
"errors.peerjs.invalid_options"
);
```
## Usage: Advanced Example
```ts
import {
NET_DISC_PEERJS_CODES,
NetDiscPeerJSError,
type NetDiscPeerJSErrorCode,
} from "@suaveplan/network-discovery-peerjs";
function classify(err: unknown): NetDiscPeerJSErrorCode | "unknown" {
if (err instanceof NetDiscPeerJSError) return err.code;
return "unknown";
}
try {
await discovery.start();
} catch (err) {
const code = classify(err);
if (code === NET_DISC_PEERJS_CODES.SIGNALING_FAILED) {
// back off + retry against an alternate broker URL
}
}
```
## API Reference
- `NET_DISC_PEERJS_CODES` — frozen record with five members:
`SIGNALING_FAILED`, `PEER_CONNECT_FAILED`, `ROOM_FULL`, `INVALID_OPTIONS`,
`DISPOSED`.
- `NetDiscPeerJSErrorCode` — string-literal union derived from the registry.
- `NetDiscPeerJSError(code, messageKey, options?)` — `SuaveplanError` subclass.
Accepts the standard `SuaveplanErrorOptions` (cause, params, redaction).
## Implementation Notes
`SIGNALING_FAILED` is the wrapper for any error PeerJS surfaces during the
initial `peer.on("error")` handshake. `PEER_CONNECT_FAILED` is reserved for
post-handshake `peer.connect(...)` failures so call-sites can tell apart
"broker unreachable" from "specific peer unreachable". `ROOM_FULL` is emitted
proactively when the `maxPeers` cap would be exceeded — the strategy refuses
the new peer rather than evicting an existing one to keep room semantics
predictable. `DISPOSED` is thrown by every public method after `stop()` so
that lifecycle bugs surface loudly instead of silently no-op-ing.

View File

@@ -0,0 +1,101 @@
# otel
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
OpenTelemetry surface for `@suaveplan/network-discovery-peerjs`. Owns the
tracer, meter, and instrument handles used by the strategy and its supporting
modules so every signal in the package is namespaced consistently
(`disc_peerjs.*`). Centralising instrument creation here prevents accidental
duplicate-instrument warnings from the OTel SDK when multiple modules want to
record the same metric.
## Features
- `withStartSpan(fn)` wraps `PeerJSDiscovery.start()` in an active span called
`disc_peerjs.start`, exposing the live span to the caller for attribute
enrichment.
- `recordPeersDiscovered(outcome, count?)` counter — labelled by `outcome` to
distinguish fresh peers (`found`), repeats (`duplicate`), and `ROOM_FULL`
rejections (`rejected`).
- `recordSignalingDuration(ms)` histogram of broker-handshake wall time.
- `withTransportSpan(name, fn)` wraps transport-tier async operations
(`peerjs_transport.connect`, `peerjs_transport.disconnect`,
`peerjs_transport.send`) in active spans.
- `recordTransportMessage(direction, outcome)` counter labelled by
`direction` (`inbound` / `outbound`) and `outcome` (`ok` / `error`).
- `adjustTransportActivePeers(delta)` up-down counter exposed as a gauge of
currently-connected transport peers.
- Single shared `tracer` + `meter` instance with stable names.
## Usage: Basic Example
```ts
import {
recordPeersDiscovered,
recordSignalingDuration,
withStartSpan,
} from "@suaveplan/network-discovery-peerjs";
await withStartSpan(async (span) => {
span.setAttribute("room", "demo");
const t = performance.now();
// ...open peer...
recordSignalingDuration(performance.now() - t);
recordPeersDiscovered("found", 1);
});
```
## Usage: Advanced Example
```ts
import { withStartSpan, recordPeersDiscovered } from "@suaveplan/network-discovery-peerjs";
async function discoverWithBudget(timeoutMs: number) {
return withStartSpan(async (span) => {
span.setAttribute("budget_ms", timeoutMs);
const start = performance.now();
try {
const peers = await openSignalling(timeoutMs);
recordPeersDiscovered("found", peers.length);
return peers;
} catch (err) {
span.recordException(err as Error);
recordPeersDiscovered("rejected");
throw err;
} finally {
span.setAttribute("elapsed_ms", performance.now() - start);
}
});
}
```
## API Reference
- `withStartSpan<T>(fn: (span: Span) => Promise<T>): Promise<T>` — creates an
active span named `disc_peerjs.start`, ends it in `finally`.
- `recordPeersDiscovered(outcome: "found"|"duplicate"|"rejected", count?)` —
increments the `disc_peerjs.peers_discovered_total` counter.
- `recordSignalingDuration(ms: number)` — records into the
`disc_peerjs.signaling_duration_ms` histogram, clamping negative values to
zero.
- `PeerDiscoveryOutcome` — exported string-literal union used by the counter.
- `withTransportSpan<T>(name: string, fn: (span: Span) => Promise<T>): Promise<T>` —
active-span wrapper for transport-tier operations.
- `recordTransportMessage(direction, outcome)` — increments
`peerjs_transport.messages_total`.
- `adjustTransportActivePeers(delta)` — adjusts
`peerjs_transport.active_peers`. No-op when `delta === 0`.
- `TransportMessageDirection` / `TransportMessageOutcome` — exported
string-literal unions.
## Implementation Notes
`outcome` keeps cardinality bounded to three values so downstream Prometheus
dashboards remain efficient. The histogram clamps negative inputs because the
strategy's signalling-duration measurement uses `Date.now()` differences that
can occasionally drift on systems where NTP corrects the wall clock during a
handshake. Tracer and meter names match the package name verbatim so the OTel
resource attributes line up with the npm package id without further mapping.

View File

@@ -0,0 +1,107 @@
# plugin
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`PeerJSPlugin` is the thin `NetworkPlugin` lifecycle wrapper that mounts
`PeerJSTransport` onto a `@suaveplan/network` `NetworkNode`. It mirrors the
canonical `WebRTCPlugin` / `WebSocketPlugin` shape: construct → install →
start (no-op) → stop (closes transport + unregisters) → dispose (drops the
node reference).
The plugin pairs with the rest of this package — the same `PeerLike` the
transport opens can serve a `PeerJSDiscovery` instance side-by-side because
the transport's wire frame uses a discriminator (`"suaveplan.transport.v1"`)
that the discovery Zod schema explicitly rejects. Discovery + transport on a
single PeerJS unit was the upstream model and is preserved here by folding
both into one package.
## Features
- `name: "peerjs"` lookup key.
- Constructs and owns one `PeerJSTransport` from a `PeerJSTransportConfig`.
- `install(node)` calls `node.registerTransport(transport)`.
- `start()` is a no-op (transport is lazy).
- `stop()` closes the transport and unregisters it from the node.
- `dispose()` drops the cached `NetworkNode` reference; safe to call
before `install` or twice in a row.
## Usage: Basic Example
```ts
import { NetworkNode } from "@suaveplan/network";
import { PeerJSPlugin } from "@suaveplan/network-discovery-peerjs/plugin";
const node = new NetworkNode();
node.use(
new PeerJSPlugin({
host: "signalling.example.com",
peerFactory: makePeerFactory(),
})
);
await node.start();
// ... use node.send / node.broadcast ...
await node.stop();
```
## Usage: Advanced Example
```ts
import { NetworkNode } from "@suaveplan/network";
import { PeerJSPlugin } from "@suaveplan/network-discovery-peerjs/plugin";
const plugin = new PeerJSPlugin({
host: "signalling.example.com",
port: 443,
path: "/",
secure: true,
iceServers: [
{ urls: "stun:stun.example.com:3478" },
{
urls: "turn:turn.example.com:3478",
username: "u",
credential: "p",
},
],
peerId: "alice",
debug: 0,
peerFactory: makePeerFactory(),
});
const node = new NetworkNode();
plugin.install(node);
plugin.start();
// Direct access to the transport for connect / disconnect.
await plugin.transport.connect("bob", "ignored");
plugin.transport.onPeerEvent((peerId, event) => {
console.log(`peer ${peerId} ${event}`);
});
await plugin.stop();
plugin.dispose();
```
## API Reference
- `class PeerJSPlugin implements NetworkPlugin`
- `readonly name = "peerjs"`.
- `readonly transport: PeerJSTransport`.
- `constructor(config: PeerJSTransportConfig)`.
- `install(node: NetworkNode): void`.
- `start(): void` — no-op.
- `stop(): Promise<void>` — idempotent close + unregister.
- `dispose(): void` — drops the node reference; idempotent.
## Implementation Notes
`install` caches the `NetworkNode` reference so `stop` can unregister the
transport on tear-down even when the caller forgets to invoke `stop` via
the node lifecycle. `dispose` clears that reference so the plugin can be
garbage-collected after the node is released. The plugin deliberately does
no eager broker connect — opening the signalling handshake on `start` would
make `NetworkNode.start()` block on a remote service, which violates the
upstream contract where `start` is synchronous-or-fast and connections are
opened by the surrounding mesh / discovery plugins.

View File

@@ -0,0 +1,147 @@
# transport
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`PeerJSTransport` adapts the PeerJS WebRTC stack to the
`@suaveplan/network` mesh `Transport` contract. It lazy-initializes a single
local `PeerLike` against a configured signalling broker, then opens one
`DataConnectionLike` per remote peer and routes `MessageEnvelope` traffic
through a small wire frame (`{kind: "suaveplan.transport.v1", envelope}`) so
the same `PeerLike` can host concurrent discovery traffic from the rest of
this package without confusion.
The transport composes the existing primitives in this package
(`initializePeer`, `connectToPeer`, `handleConnection`) rather than reaching
into raw `peerjs` APIs, which keeps the migrated unit byte-identical to the
upstream integrated discovery + transport bundle.
## Features
- Single `Transport` implementation with `id: "peerjs"`.
- Lazy broker connect — the first `connect()` opens the signalling
handshake; subsequent connects reuse the same `PeerLike`.
- Per-peer `DataConnectionLike` registry with `connect` / `disconnect` /
`send` / `onMessage` / `onPeerEvent` / `getConnectedPeers` / `close`.
- Pre-aborted `AbortSignal` rejected with `ABORTED`; mid-handshake aborts
surface the same code.
- Frame discriminator ensures discovery-only peers ignore transport bytes.
- OTel: `peerjs_transport.connect` / `disconnect` / `send` spans,
`peerjs_transport.messages_total{direction,outcome}` counter,
`peerjs_transport.active_peers` gauge.
## Usage: Basic Example
```ts
import { PeerJSTransport } from "@suaveplan/network-discovery-peerjs/transport";
const transport = new PeerJSTransport({
host: "signalling.example.com",
peerFactory: makePeerFactory(),
});
await transport.connect("remote-peer-id", "ignored");
transport.onMessage((peerId, envelope) => {
console.log("from", peerId, envelope);
});
await transport.send("remote-peer-id", {
id: "m1",
type: "ping",
senderId: "self",
payload: { hello: "world" },
timestamp: Date.now(),
headers: {},
ttl: 64,
visitedNodes: [],
});
```
## Usage: Advanced Example
```ts
import { NetworkNode } from "@suaveplan/network";
import { PeerJSPlugin } from "@suaveplan/network-discovery-peerjs/plugin";
const node = new NetworkNode();
const plugin = new PeerJSPlugin({
host: "signalling.example.com",
iceServers: [{ urls: "stun:stun.example.com:3478" }],
peerFactory: makePeerFactory(),
});
node.use(plugin);
await node.start();
const transport = plugin.transport;
transport.onPeerEvent((peerId, event) => {
if (event === "connected") {
console.log("peer joined", peerId);
}
});
// Open a connection with a cancellable signal — useful for UI flows that
// abort the dial when the user navigates away.
const ac = new AbortController();
setTimeout(() => ac.abort(), 5_000);
await transport.connect("alice", "ignored", ac.signal);
await node.stop();
```
## API Reference
- `class PeerJSTransport implements Transport`
- `readonly id = "peerjs"`.
- `constructor(config: PeerJSTransportConfig)` — `host` is required; all
other fields default (`port=443`, `path="/"`, `secure=true`, `debug=0`).
- `connect(peerId: string, address: string, signal?: AbortSignal): Promise<void>` —
lazy-opens the broker handshake on first call, then opens a `DataConnection`
to `peerId`. `address` is accepted for `Transport` compatibility but
unused (PeerJS centralises signalling at `host`). Throws
`PEER_ALREADY_CONNECTED` / `TRANSPORT_CLOSED` / `ABORTED` /
`PEER_CONNECT_FAILED`.
- `disconnect(peerId: string): Promise<void>` — closes the per-peer
connection. Unknown peer is a no-op.
- `send(peerId: string, envelope: MessageEnvelope): Promise<void>` —
wraps `envelope` in the transport frame and sends. Throws
`PEER_UNKNOWN` / `TRANSPORT_CLOSED` / `SEND_FAILED`.
- `onMessage(handler)` / `onPeerEvent(handler)` — return unsubscribe.
- `getConnectedPeers(): readonly string[]`.
- `close(): Promise<void>` — idempotent; closes every connection,
destroys the `PeerLike`, drops handler sets.
- `getMyPeerId(): string | null` — broker-assigned local id, or `null`
before the first `connect()`.
## Implementation Notes
The transport reuses `initializePeer` + `connectToPeer` + `handleConnection`
from `core/peerjs-connection/` so the integrated discovery + transport unit
upstream sees as a single PeerJS package is preserved without splitting it.
The discovery layer's Zod `PeerJSMessageSchema` deliberately does not list
the transport `kind` discriminator, so when a discovery-only peer receives
a transport frame the schema rejects it and the handler silently drops it —
mixing both protocols on the same `PeerLike` is safe.
Per-peer connection state is held in `#peers: Map<PeerId, PeerState>`. The
fresh-`Map` invariant in `connect()` (each call hands `openConnection` a
private map) guarantees the only path that returns `undefined` from
`openConnection` is unreachable in production; an `/* istanbul ignore next */`
guard documents the defensive branch.
`#waitForOpen` resolves on the first `open` event from the new
`DataConnectionLike`; if the channel emits `error` before `open` it
rejects with `PEER_CONNECT_FAILED`. Pre-aborted and mid-handshake
abort signals both reject with `ABORTED`.
Frame validation uses a structural type-guard (`kind === "suaveplan.transport.v1"`
- object `envelope`), which means malformed or non-transport traffic is
dropped without touching the registered handlers, preventing a hostile
peer from injecting through `onMessage`.
The transport never emits `connected` for a peer twice — `#acceptIncoming`
short-circuits when the registry already has the id, and `#handlePeerLost`
gates the `disconnected` emission on registry presence, so consumers can
treat the event stream as exactly-once per session.

View File

@@ -0,0 +1,87 @@
# types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Type-only foundation for `@suaveplan/network-discovery-peerjs`. Declares the
wire-format `PeerJSMessage` envelope, the structural `PeerLike` and
`DataConnectionLike` surfaces used to inject real or stub PeerJS implementations,
the strict `PeerJSDiscoveryConfig` (which deliberately makes `host` mandatory —
there is no public-server fallback), the resolved-config shape produced by Zod
defaulting, and the typed event map fanned out on the optional bus.
## Features
- Frozen `PeerJSMessageType` literal-record covering every wire-format verb.
- `PeerJSPeerMetadata` + `PeerJSPeerRecord` separation: wire payload vs.
in-memory room state.
- `DataConnectionLike` / `PeerLike` structural surfaces — production binds to
real `peerjs` instances, tests bind to a deterministic stub with no mocks.
- `PeerFactory` contract for injecting the constructor.
- `PeerJSDiscoveryConfig` with required `host` and Zod-defaulted optional
fields; `ResolvedPeerJSDiscoveryConfig` for the post-parse internal shape.
- `PeerJSEventMap` typed bus emissions: `peerjs:started`,
`peerjs:peer-discovered`, `peerjs:peer-lost`, `peerjs:signaling-failed`.
## Usage: Basic Example
```ts
import type {
PeerJSDiscoveryConfig,
PeerJSEventMap,
} from "@suaveplan/network-discovery-peerjs/types";
const config: PeerJSDiscoveryConfig = {
host: "signaling.example.com",
room: "demo",
};
```
## Usage: Advanced Example
```ts
import type {
PeerJSDiscoveryConfig,
PeerFactory,
PeerLike,
} from "@suaveplan/network-discovery-peerjs/types";
import { EventBus } from "@suaveplan/events";
const bus = new EventBus<import("@suaveplan/network-discovery-peerjs/types").PeerJSEventMap>();
const factory: PeerFactory = (id, opts): PeerLike => fakePeer(id, opts);
const config: PeerJSDiscoveryConfig = {
host: "signaling.example.com",
port: 8443,
secure: true,
room: "alpha",
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
bus,
peerFactory: factory,
};
```
## API Reference
- `PeerJSMessageType` — frozen `{ANNOUNCE,DISCOVER,PEER_LIST,LEAVE,HEARTBEAT}`.
- `PeerJSPeerMetadata` — announce payload shape.
- `PeerJSMessage` — full envelope `{type, from, payload, timestamp}`.
- `PeerJSPeerRecord` — internal room-manager record (mutable `lastSeen`).
- `DataConnectionLike` — `{peer, open, send, on(event,...), close}`.
- `PeerLike` — `{connect, on(event,...), destroy}`.
- `PeerFactory` — `(peerId, opts) => PeerLike`.
- `PeerJSPeerOptions` — host/port/path/secure/key/debug/config bundle.
- `PeerJSDiscoveryConfig` — caller-facing options, `host`+`room` required.
- `ResolvedPeerJSDiscoveryConfig` — internal post-default shape.
- `PeerJSEventMap` — typed bus event keys + payloads.
## Implementation Notes
`host` has no default. Constructing without one throws `INVALID_OPTIONS` at
the Zod boundary inside `PeerJSDiscovery` — this is the explicit decision the
parity checklist records as an enterprise uplift over the upstream package,
which silently fell back to the public `peerjs.com` cloud. Wire-format types
keep `readonly` modifiers so downstream code cannot mutate buffers shared
across the message-handler dispatch table.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,109 @@
# IPFS Transport Adapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`IpfsTransportAdapter` implements the universal `TrysteroTransportAdapter`
contract over Trystero's IPFS libp2p-pubsub backend. It joins a
pubsub-hosted room identified by the configured `appId`, exchanges
peer-discovery messages with other participants, and proxies the resulting
peer lifecycle events back to the orchestrator via the injected
`TrysteroAdapterCallbacks`. There is no default bootstrap list inside the
adapter — the caller MUST supply a non-empty `bootstrap` array (the Zod
schema rejects empty lists at the package boundary).
This separation between adapter and orchestrator is deliberate: the
orchestrator handles fallback policy, peer dedup, intervals, and OTel; the
adapter only wraps the room lifecycle.
## Features
- Wraps any caller-supplied `IpfsRoomFactory` — production binds
`joinRoom` from `trystero/ipfs`, tests bind a deterministic in-memory
fake.
- Optional `relay` list carried through the config block for hole-punching
scenarios.
- Per-instance state: room handle, send-action, peer-join/leave unsub,
destroyed flag. All cleaned up in `destroy()` (idempotent).
- AbortSignal-aware `initialize()`.
- Maps every factory failure to `IPFS_BOOTSTRAP_FAILED` with the original
error preserved on `error.cause`.
## Usage: Basic Example
```ts
import { IpfsTransportAdapter } from "@suaveplan/network-discovery-trystero";
const adapter = new IpfsTransportAdapter(
"my-app",
{ bootstrap: ["/ip4/1.2.3.4/tcp/4001/p2p/QmExample"] },
{},
async () => fakeRoom,
);
await adapter.initialize({
onPeerJoined: (id) => console.log("join", id),
onPeerLost: (id) => console.log("leave", id),
onMessage: (m) => console.log("msg", m),
});
```
## Usage: Advanced Example
```ts
import { IpfsTransportAdapter } from "@suaveplan/network-discovery-trystero";
const adapter = new IpfsTransportAdapter(
"my-app",
{
bootstrap: [
"/ip4/1.2.3.4/tcp/4001/p2p/QmExample",
"/ip4/5.6.7.8/tcp/4001/p2p/QmAnother",
],
relay: ["/dnsaddr/relay.libp2p.io/p2p/QmRelay"],
},
{},
ipfsJoinRoom,
);
const ctrl = new AbortController();
try {
await adapter.initialize(callbacks, ctrl.signal);
await adapter.send({
type: "heartbeat",
from: "me",
payload: {},
timestamp: Date.now(),
transport: "ipfs",
});
} finally {
adapter.destroy();
}
```
## API Reference
- `new IpfsTransportAdapter(appId, config, common, factory)`.
- `initialize(callbacks, signal?): Promise<string>` — returns local peer
id. Throws `IPFS_BOOTSTRAP_FAILED` on factory throw, `DISPOSED` after
`destroy()`, AbortError on pre-aborted signal.
- `send(message, target?): Promise<void>` — throws `NOT_RUNNING` before
`initialize` resolves.
- `leave(): void` — best-effort `room.leave()`.
- `destroy(): void` — idempotent teardown.
- `ping(peerId): Promise<number>` — throws `NOT_RUNNING` before
`initialize`.
- `get isConnected(): boolean`.
- `readonly kind: "ipfs"`.
## Implementation Notes
- The bootstrap list must contain libp2p-format multiaddrs. The adapter
treats them as opaque strings — validation that they are actually
reachable lives in the underlying Trystero room implementation.
- The Zod schema enforces the non-empty-bootstrap invariant, so the
adapter does not re-validate.
- Unsub handles for the room's `onPeerJoin` / `onPeerLeave` subscriptions
are held for the lifetime of the adapter and called during teardown.

View File

@@ -0,0 +1,107 @@
# MQTT Transport Adapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`MqttTransportAdapter` implements the universal `TrysteroTransportAdapter`
contract over Trystero's MQTT-broker backend. It joins a broker-hosted
room identified by the configured `appId`, exchanges peer-discovery
messages with other participants, and proxies the resulting peer lifecycle
events back to the orchestrator via the injected
`TrysteroAdapterCallbacks`. There is no default broker list inside the
adapter — the caller MUST supply a non-empty `brokers` array (the Zod
schema rejects empty lists at the package boundary).
This separation between adapter and orchestrator is deliberate: the
orchestrator handles fallback policy, peer dedup, intervals, and OTel; the
adapter only wraps the room lifecycle.
## Features
- Wraps any caller-supplied `MqttRoomFactory` — production binds
`joinRoom` from `trystero/mqtt`, tests bind a deterministic in-memory
fake.
- Optional MQTT `username` / `password` carried through the config block.
- Per-instance state: room handle, send-action, peer-join/leave unsub,
destroyed flag. All cleaned up in `destroy()` (idempotent).
- AbortSignal-aware `initialize()`.
- Maps every factory failure to `MQTT_BROKER_FAILED` with the original
error preserved on `error.cause`.
## Usage: Basic Example
```ts
import { MqttTransportAdapter } from "@suaveplan/network-discovery-trystero";
const adapter = new MqttTransportAdapter(
"my-app",
{ brokers: ["wss://broker.example:1883"] },
{},
async () => fakeRoom,
);
await adapter.initialize({
onPeerJoined: (id) => console.log("join", id),
onPeerLost: (id) => console.log("leave", id),
onMessage: (m) => console.log("msg", m),
});
```
## Usage: Advanced Example
```ts
import { MqttTransportAdapter } from "@suaveplan/network-discovery-trystero";
const adapter = new MqttTransportAdapter(
"my-app",
{
brokers: ["wss://broker-a:1883", "wss://broker-b:1883"],
username: "u",
password: "p",
},
{ password: "secret-room" },
mqttJoinRoom,
);
const ctrl = new AbortController();
try {
await adapter.initialize(callbacks, ctrl.signal);
await adapter.send({
type: "heartbeat",
from: "me",
payload: {},
timestamp: Date.now(),
transport: "mqtt",
});
} finally {
adapter.destroy();
}
```
## API Reference
- `new MqttTransportAdapter(appId, config, common, factory)`.
- `initialize(callbacks, signal?): Promise<string>` — returns local peer
id. Throws `MQTT_BROKER_FAILED` on factory throw, `DISPOSED` after
`destroy()`, AbortError on pre-aborted signal.
- `send(message, target?): Promise<void>` — throws `NOT_RUNNING` before
`initialize` resolves.
- `leave(): void` — best-effort `room.leave()`.
- `destroy(): void` — idempotent teardown.
- `ping(peerId): Promise<number>` — throws `NOT_RUNNING` before
`initialize`.
- `get isConnected(): boolean`.
- `readonly kind: "mqtt"`.
## Implementation Notes
- MQTT broker credentials (`username` / `password`) are carried as plain
fields on the config block. They are not redacted by this package — if
they are sensitive, configure your logging sinks to redact the
`mqtt.username` / `mqtt.password` paths via `@suaveplan/data-masking`.
- The Zod schema enforces the non-empty-brokers invariant, so the adapter
does not re-validate.
- Unsub handles for the room's `onPeerJoin` / `onPeerLeave` subscriptions
are held for the lifetime of the adapter and called during teardown.

View File

@@ -0,0 +1,105 @@
# Nostr Transport Adapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`NostrTransportAdapter` implements the universal
`TrysteroTransportAdapter` contract over Trystero's Nostr-relay backend.
It joins a Nostr-relay-hosted room identified by the configured `appId`,
exchanges peer-discovery messages with other participants, and proxies the
resulting peer lifecycle events back to the orchestrator via the injected
`TrysteroAdapterCallbacks`. There is no default relay list inside the
adapter — the caller MUST supply a non-empty `relays` array (the Zod
schema rejects empty lists at the package boundary).
This separation between adapter and orchestrator is deliberate: the
orchestrator handles fallback policy, peer dedup, intervals, and OTel; the
adapter only wraps the room lifecycle.
## Features
- Wraps any caller-supplied `NostrRoomFactory` — production binds
`joinRoom` from `trystero/nostr`, tests bind a deterministic in-memory
fake.
- Per-instance state: room handle, send-action, peer-join/leave unsub,
destroyed flag. All cleaned up in `destroy()` (idempotent).
- AbortSignal-aware `initialize()`.
- Maps every factory failure to `NOSTR_RELAY_FAILED` with the original
error preserved on `error.cause`.
- `ping(peerId)` proxies through to the underlying Trystero room's
`ping()` (round-trip ms).
## Usage: Basic Example
```ts
import { NostrTransportAdapter } from "@suaveplan/network-discovery-trystero";
const adapter = new NostrTransportAdapter(
"my-app",
{ relays: ["wss://relay.example"] },
{},
async () => fakeRoom,
);
await adapter.initialize({
onPeerJoined: (id) => console.log("join", id),
onPeerLost: (id) => console.log("leave", id),
onMessage: (m) => console.log("msg", m),
});
```
## Usage: Advanced Example
```ts
import { NostrTransportAdapter } from "@suaveplan/network-discovery-trystero";
const adapter = new NostrTransportAdapter(
"my-app",
{ relays: ["wss://relay-1", "wss://relay-2"] },
{ password: "secret" },
nostrJoinRoom,
);
const ctrl = new AbortController();
try {
await adapter.initialize(callbacks, ctrl.signal);
await adapter.send({
type: "heartbeat",
from: "me",
payload: {},
timestamp: Date.now(),
transport: "nostr",
});
} finally {
adapter.destroy();
}
```
## API Reference
- `new NostrTransportAdapter(appId, config, common, factory)`.
- `initialize(callbacks, signal?): Promise<string>` — returns local peer
id. Throws `NOSTR_RELAY_FAILED` on factory throw, `DISPOSED` after
`destroy()`, AbortError on pre-aborted signal.
- `send(message, target?): Promise<void>` — throws `NOT_RUNNING` before
`initialize` resolves.
- `leave(): void` — best-effort `room.leave()`.
- `destroy(): void` — idempotent teardown.
- `ping(peerId): Promise<number>` — throws `NOT_RUNNING` before
`initialize`.
- `get isConnected(): boolean`.
- `readonly kind: "nostr"`.
## Implementation Notes
- The Zod schema enforces the non-empty-relays invariant, so the adapter
does not re-validate. Empty arrays should never reach the adapter.
- Unsub handles for the room's `onPeerJoin` / `onPeerLeave` subscriptions
are held during the lifetime of the adapter and called during teardown.
Exceptions from those unsub calls are swallowed because Trystero may
detach them itself during room leave.
- `myPeerId` is captured from `room.getPeerId()` at initialise time and
cleared in `destroy()`. The orchestrator should treat the adapter as
opaque after `destroy()`.

View File

@@ -0,0 +1,105 @@
# Torrent Transport Adapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`TorrentTransportAdapter` implements the universal
`TrysteroTransportAdapter` contract over Trystero's BitTorrent-tracker
backend. It joins a tracker-hosted room identified by the configured
`appId`, exchanges peer-discovery messages with other participants in that
room, and proxies the resulting peer lifecycle events back to the
orchestrator via the injected `TrysteroAdapterCallbacks`. The adapter never
contains a default tracker list; the caller MUST supply a non-empty
`trackers` array (the Zod schema rejects empty lists at the package
boundary).
This separation between the adapter and the orchestrator is deliberate: the
orchestrator handles fallback policy, peer dedup, intervals, and OTel; the
adapter only wraps the room lifecycle.
## Features
- Wraps any caller-supplied `TorrentRoomFactory` — production binds
`joinRoom` from `trystero/torrent`, tests bind a deterministic in-memory
fake.
- Per-instance state: room handle, send-action, peer-join/leave unsub,
destroyed flag. All cleaned up in `destroy()` (idempotent).
- AbortSignal-aware `initialize()`.
- Maps every factory failure to `TORRENT_TRACKER_FAILED` with the original
error preserved on `error.cause`.
- `ping(peerId)` proxies through to the underlying Trystero room's
`ping()` (round-trip ms).
## Usage: Basic Example
```ts
import { TorrentTransportAdapter } from "@suaveplan/network-discovery-trystero";
const adapter = new TorrentTransportAdapter(
"my-app",
{ trackers: ["wss://tracker.example"] },
{},
async () => fakeRoom,
);
await adapter.initialize({
onPeerJoined: (id) => console.log("join", id),
onPeerLost: (id) => console.log("leave", id),
onMessage: (m) => console.log("msg", m),
});
```
## Usage: Advanced Example
```ts
import { TorrentTransportAdapter } from "@suaveplan/network-discovery-trystero";
const adapter = new TorrentTransportAdapter(
"my-app",
{ trackers: ["wss://t1", "wss://t2"], trackerRedundancy: 3 },
{ password: "secret", rtcConfig: { iceServers: [] } },
trackerJoinRoom,
);
const ctrl = new AbortController();
try {
await adapter.initialize(callbacks, ctrl.signal);
await adapter.send({
type: "heartbeat",
from: "me",
payload: {},
timestamp: Date.now(),
transport: "torrent",
});
} finally {
adapter.destroy();
}
```
## API Reference
- `new TorrentTransportAdapter(appId, config, common, factory)`.
- `initialize(callbacks, signal?): Promise<string>` — returns local peer id.
Throws `TORRENT_TRACKER_FAILED` on factory throw, `DISPOSED` after
`destroy()`, AbortError on pre-aborted signal.
- `send(message, target?): Promise<void>` — throws `NOT_RUNNING` before
`initialize` resolves.
- `leave(): void` — best-effort `room.leave()`.
- `destroy(): void` — idempotent teardown.
- `ping(peerId): Promise<number>` — throws `NOT_RUNNING` before `initialize`.
- `get isConnected(): boolean`.
- `readonly kind: "torrent"`.
## Implementation Notes
- The adapter holds the `myPeerId` returned by `room.getPeerId()` at
initialise time and clears it in `destroy()`. The orchestrator should
treat the adapter as opaque after `destroy()`.
- Every callback (`onPeerJoined` / `onPeerLost` / `onMessage`) is wired
through the room's own subscribe API; unsub handles are held and called
during teardown. Exceptions from those unsub calls are swallowed because
Trystero's library may detach them itself during room leave.
- The Zod schema enforces the non-empty-trackers invariant, so the
adapter does not re-validate.

View File

@@ -0,0 +1,129 @@
# TrysteroDiscovery — Orchestrator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`TrysteroDiscovery` is the orchestrator that ties every other module in
`@suaveplan/network-discovery-trystero` together and implements the
`@suaveplan/network-bootstrap` `DiscoveryStrategy` contract. It owns the
active transport adapter, the peer manager, the interval scheduler, and
the recursive fallback chain. The orchestrator never branches on the
transport-id string at runtime — every lifecycle call goes through the
universal `TrysteroTransportAdapter` interface, and construction-time
selection chooses the initial adapter via the internal `pickAdapter` map.
The strategy is browser-tier (the underlying Trystero library is itself
browser-tier) and headless — no React or DOM rendering surface ships from
this package. Devtools UI is the responsibility of downstream packages.
## Features
- Implements `name`, `discover`, `isAvailable`, plus `start`, `stop`,
`announce`, `getMyPeerId`, `getPeers`, `getPeerCount`,
`getCurrentTransport`, `getStatus`, `ping`, `dispose`.
- Construction-time Zod boundary validation + cross-field constraint that
every selected transport (primary or fallback) must have its
config-block AND factory present. Constructor throws
`INVALID_OPTIONS` otherwise.
- AbortSignal threaded through `start`, `stop`, `discover`, `isAvailable`,
`announce`.
- Automatic fallback between transports via `tryFallback` — exponential
backoff between attempts optional via `@suaveplan/retry`.
- Service-registry integration via `@suaveplan/service-discovery`: peers
are registered as `trystero:<transport>:<appId>:<peerId>`.
- Typed `EventBusLike` integration with the `TrysteroEventMap`.
- OTel: `disc_trystero.start` span, per-transport
`disc_trystero.transport.<id>.connect` span, fallback counter, connect
histogram, per-peer-discovery counter.
## Usage: Basic Example
```ts
import { TrysteroDiscovery } from "@suaveplan/network-discovery-trystero";
const strategy = new TrysteroDiscovery({
appId: "demo",
transport: "nostr",
nostr: { relays: ["wss://relay.example"] },
nostrRoomFactory: async () => realJoinRoom(),
});
await strategy.start();
const peers = await strategy.discover(5000);
await strategy.stop();
```
## Usage: Advanced Example
```ts
import { EventBus } from "@suaveplan/events";
import { ServiceRegistry } from "@suaveplan/service-discovery";
import {
TrysteroDiscovery,
type TrysteroEventMap,
} from "@suaveplan/network-discovery-trystero";
const bus = new EventBus<TrysteroEventMap>();
bus.on("trystero:fallback", (e) => console.log(`fallback ${e.from} -> ${e.to}`));
bus.on("trystero:transport-failed", (e) => console.warn(`failed ${e.transport}: ${e.reason}`));
const strategy = new TrysteroDiscovery({
appId: "demo",
transport: "nostr",
nostr: { relays: ["wss://relay-1", "wss://relay-2"] },
mqtt: { brokers: ["wss://broker:1883"] },
torrent: { trackers: ["wss://tracker"] },
fallbackTransports: ["mqtt", "torrent"],
nostrRoomFactory: realNostrJoin,
mqttRoomFactory: realMqttJoin,
torrentRoomFactory: realTorrentJoin,
interval: 10_000,
maxPeers: 50,
bus,
registry: new ServiceRegistry(),
});
const ctrl = new AbortController();
await strategy.start(ctrl.signal);
```
## API Reference
- `new TrysteroDiscovery(config: TrysteroDiscoveryConfig, clock?: Clock)`.
- `start(signal?): Promise<void>` — idempotent; emits `trystero:started`
on success; throws `DISPOSED` after `dispose()`,
`ALL_TRANSPORTS_DEGRADED` if the entire fallback chain fails, plus the
per-transport init code (`NOSTR_RELAY_FAILED`, etc.) when no fallback is
configured.
- `stop(signal?): Promise<void>` — emits `LEAVE` to peers, tears down the
adapter, clears the peer map.
- `discover(timeoutMs, signal?): Promise<PeerInfo[]>` — returns a snapshot
of the live peer set.
- `isAvailable(signal?): Promise<boolean>` — `true` iff running and the
underlying adapter reports `isConnected`.
- `announce(peerInfo, signal?): Promise<void>`.
- `getMyPeerId / getPeers / getPeerCount / getCurrentTransport / getStatus
/ ping / dispose`.
- `readonly name = "trystero"`.
## Implementation Notes
- The constructor runs the Zod schema first, then walks the union of
`transport` + `fallbackTransports` and asserts each transport's config
block AND factory are present. This is a cross-field constraint Zod
cannot express cleanly; the error code is always `INVALID_OPTIONS`.
- `start()` opens a single `disc_trystero.start` span and inside it opens a
`disc_trystero.transport.<id>.connect` span per init attempt. Fallback
swaps reuse the same outer span but record a new transport span per
swap.
- The interval scheduler keeps a stable reference to the peer-manager's
`getMap()` so transport swaps do not invalidate the cleanup loop.
- `announce` emits the message _after_ admitting the local record so the
peer manager's `transport_fallback_total` and
`peers_discovered_total` counters reflect locally-known state even if
the underlying send fails.
- After fallback, `peers.retagTransport(next)` rewrites every existing
peer entry's `transport` field so consumers (event listeners,
registry) see a consistent view of which transport peers are on.

View File

@@ -0,0 +1,105 @@
# Trystero Fallback
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`tryFallback` implements the inter-transport fallback algorithm: when the
active Trystero transport fails to initialise, walk the configured
`fallbackTransports` array in order, attempting to bring each one up
sequentially. The function recurses across the chain — every individual
transport gets one attempt — and throws `ALL_TRANSPORTS_DEGRADED` only if
every transport in the chain fails.
Backoff between attempts is delegated to `@suaveplan/retry`'s
`createExponentialBackoff` so the fallback policy reuses the suaveplan retry
primitives (instead of re-implementing setTimeout-based delays) and so
tests can swap in deterministic backoff via injectable sleep.
## Features
- Sequential walk over the caller-supplied `fallbackTransports` list.
- Optional exponential backoff via `ExponentialBackoffOptions` (no
backoff by default — preserves upstream parity).
- Injectable `sleepFn` for deterministic tests.
- OTel: increments `transport_fallback_total{from,to}` on every transition.
- `ALL_TRANSPORTS_DEGRADED` carries the original failure on `error.cause`.
- Best-effort `notifyFallback` after a successful swap — failures
swallowed because the chain has already stabilised.
- Recursive — at most `fallbackTransports.length` levels deep, bounded by
the caller-supplied array length.
## Usage: Basic Example
```ts
import { tryFallback } from "@suaveplan/network-discovery-trystero";
await tryFallback(
new Error("nostr down"),
{ fallbackAttempts: 0, currentTransport: "nostr" },
{
fallbackTransports: ["mqtt", "torrent"],
clock: () => Date.now(),
initializeTransport: async (t) => connect(t),
notifyFallback: async () => {},
},
);
```
## Usage: Advanced Example
```ts
import { tryFallback } from "@suaveplan/network-discovery-trystero";
await tryFallback(
new Error("torrent down"),
state,
{
fallbackTransports: ["nostr", "mqtt", "ipfs"],
clock: () => Date.now(),
backoff: { initial: 100, max: 5000, base: 2, jitter: "equal" },
sleepFn: async (ms) => {
console.log(`backoff ${ms}ms`);
// deterministic test sleep
},
initializeTransport: async (t) => {
if (t === "nostr") throw new Error("nostr also down");
await connect(t);
},
notifyFallback: async (next) => {
await broadcast({ type: "fallback", payload: { transport: next } });
},
},
);
```
## API Reference
- `async tryFallback(error, state, deps): Promise<void>` — mutates
`state.fallbackAttempts` / `state.currentTransport`; throws
`ALL_TRANSPORTS_DEGRADED` once the chain is exhausted.
- `interface FallbackState` — `{ fallbackAttempts: number; currentTransport: TrysteroTransport; }`.
- `interface FallbackDeps`:
- `fallbackTransports: readonly TrysteroTransport[]`.
- `clock: Clock`.
- `backoff?: ExponentialBackoffOptions` — `{ initial, max?, base?, jitter? }`.
- `sleepFn?: (ms) => Promise<void>` — defaults to `@suaveplan/retry.sleep`.
- `initializeTransport(transport): Promise<void>`.
- `notifyFallback(next): Promise<void>`.
## Implementation Notes
- The recursion is tail-position-ish but not actually tail-call-optimised;
the call stack is bounded by `fallbackTransports.length` which has a
natural upper bound of 3 (each of `torrent`, `nostr`, `mqtt`, `ipfs`
except the active one). Stack depth is not a concern.
- `recordTransportFallback` is called BEFORE the new transport
initialises — the counter reflects fallback intent, not success. Use
`disc_trystero.transport.<id>.connect` span + counter to differentiate.
- Backoff is computed off `state.fallbackAttempts` AFTER incrementing —
the first fallback gets `attempt = 1`, so under exponential strategy
the delay is `initial * base^0 = initial`.
- Errors from `notifyFallback` are swallowed because the chain has
already moved to the new transport; failing to tell other peers is a
visibility problem, not a correctness one.

View File

@@ -0,0 +1,110 @@
# Trystero Intervals
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`TrysteroIntervals` replaces the upstream package's three raw `setInterval`
handles with a single `@suaveplan/timing` `Scheduler` driving three
self-rearming loops: announce, heartbeat, and cleanup. The scheduler is
injectable, so tests deterministically drive every tick without
`setTimeout` actually firing, and `dispose()` cancels every pending timer
atomically when the strategy stops.
By isolating timer policy in this module, the orchestrator stays focused on
adapter lifecycle and the cleanup loop has a stable Map handle it can
iterate during staleness sweeps.
## Features
- One `Scheduler` instance, three self-rearming loops.
- `start()` is idempotent and `stop()` is idempotent.
- Each loop reschedules itself only if the scheduler has not been disposed
AND `stop()` has not been called.
- Announce / heartbeat emit a `TrysteroMessage` (type
`ANNOUNCE` / `HEARTBEAT`) via the injected `sendDiscovery` callback.
- Cleanup iterates `peers`, dropping any whose `now - lastSeen > timeoutMs`
via `handlePeerLost`.
- Errors from `sendDiscovery` are swallowed so a single bad tick does not
starve the loop.
## Usage: Basic Example
```ts
import { TrysteroIntervals } from "@suaveplan/network-discovery-trystero";
const intervals = new TrysteroIntervals({
clock: () => Date.now(),
announceIntervalMs: 30_000,
heartbeatIntervalMs: 15_000,
cleanupIntervalMs: 30_000,
timeoutMs: 60_000,
peers: new Map(),
getMyPeerId: () => "me",
getCurrentTransport: () => "nostr",
isRunning: () => true,
selfMetadata: () => ({
id: "me",
addresses: [],
metadata: {},
lastSeen: Date.now(),
transport: "nostr",
}),
sendDiscovery: async () => {},
handlePeerLost: () => {},
});
intervals.start();
intervals.stop();
```
## Usage: Advanced Example
```ts
import { createScheduler } from "@suaveplan/timing";
import { TrysteroIntervals } from "@suaveplan/network-discovery-trystero";
// Custom scheduler factory — useful in tests.
const intervals = new TrysteroIntervals(deps, (clock) =>
createScheduler({ clock }),
);
intervals.start();
intervals.start(); // idempotent
intervals.stop();
intervals.stop(); // idempotent
console.log(intervals.isStopped); // true
```
## API Reference
- `new TrysteroIntervals(deps, schedulerFactory?)`.
- `start(): void` — start every loop; idempotent.
- `stop(): void` — stop every loop, dispose scheduler; idempotent.
- `get isStopped(): boolean`.
`TrysteroIntervalDeps`:
- `clock: Clock`.
- `announceIntervalMs / heartbeatIntervalMs / cleanupIntervalMs / timeoutMs: number`.
- `peers: Map<string, TrysteroPeerMetadata>` — read-write reference to the
manager's underlying map.
- `getMyPeerId(): string | null`.
- `getCurrentTransport(): TrysteroTransport`.
- `isRunning(): boolean`.
- `selfMetadata(): TrysteroPeerMetadata`.
- `sendDiscovery(message): Promise<void>`.
- `handlePeerLost(peerId): void`.
## Implementation Notes
- The scheduler is disposed in `stop()`. Once disposed, no new tasks can
be scheduled. The loop-self-rearm code defends against this race by
checking `scheduler.disposed` before each `schedule()`.
- `sendDiscovery` errors are intentionally swallowed. The orchestrator
emits `trystero:transport-failed` on initialise failures; transient
send failures on a single tick are not surfaced through the bus.
- `runHeartbeat` and `runAnnounce` share a message shape; the difference
is only the `type` literal. Keeping them as two separate methods keeps
the test surface small.

View File

@@ -0,0 +1,121 @@
# Trystero Message Handlers
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Pure dispatch function (`handleMessage`) routing every inbound
`TrysteroMessage` to the appropriate handler based on `message.type`. Each
handler closes over a `TrysteroMessageHandlerDeps` bag wired by the
orchestrator at construction — keeping the dispatch surface free of any
orchestrator imports avoids a circular-dependency edge between
`trystero-discovery` and `trystero-message-handlers`.
The module owns the wire-format protocol contract: every supported message
type (announce, discover, peer_list, leave, heartbeat, fallback) is handled
exactly once, malformed payloads are coerced to `false` returns, and there
are no side effects beyond the explicit callback invocations.
## Features
- Six message types (announce / discover / peer_list / leave / heartbeat /
fallback) dispatched via a `switch` on a frozen literal union.
- Pure handlers — every side effect goes through the `deps` callbacks.
- Announces re-tag the peer's `transport` with the active transport when
the inbound payload omits one (defensive default).
- Discover requests are answered with the active peer-id set, sent
unicast to the requester via `deps.sendDiscovery(..., target)`.
- Heartbeats touch the sender's `lastSeen`. Leaves drop the sender.
- Fallback notifications are observational — the receiving peer does not
need to switch transports just because a remote did.
## Usage: Basic Example
```ts
import {
handleMessage,
TrysteroMessageType,
} from "@suaveplan/network-discovery-trystero";
await handleMessage(
{
type: TrysteroMessageType.ANNOUNCE,
from: "p1",
payload: {
id: "p1",
addresses: ["wss://x"],
metadata: {},
lastSeen: 0,
transport: "nostr",
},
timestamp: Date.now(),
},
deps,
);
```
## Usage: Advanced Example
```ts
import {
handleMessage,
TrysteroMessageType,
type TrysteroMessageHandlerDeps,
} from "@suaveplan/network-discovery-trystero";
const deps: TrysteroMessageHandlerDeps = {
clock: () => Date.now(),
getMyPeerId: () => "me",
getCurrentTransport: () => "nostr",
knownPeerIds: () => Array.from(peerMap.keys()),
admitPeer: (r) => (peerMap.set(r.id, r), "added"),
touchPeer: (id) => {
const p = peerMap.get(id);
if (p) p.lastSeen = Date.now();
},
onPeerLost: (id) => peerMap.delete(id),
sendDiscovery: async (m, t) => transport.send(m, t),
};
await handleMessage(
{
type: TrysteroMessageType.DISCOVER,
from: "p2",
payload: {},
timestamp: Date.now(),
},
deps,
);
```
## API Reference
- `async handleMessage(message, deps): Promise<boolean>` — `false` for
malformed / unknown messages.
- `interface TrysteroMessageHandlerDeps` — callback bag:
- `clock: Clock`.
- `getMyPeerId(): string | null`.
- `getCurrentTransport(): TrysteroTransport`.
- `knownPeerIds(): readonly string[]`.
- `admitPeer(record): "added" | "updated" | "rejected"`.
- `touchPeer(peerId): void`.
- `onPeerLost(peerId): void`.
- `sendDiscovery(message, target?): Promise<void>`.
## Implementation Notes
- The handler is exhaustive over the frozen `TrysteroMessageType`
registry. The `default` branch is `istanbul ignore`d because Zod
validates the wire-format type at the orchestrator boundary before
dispatch; the literal-union switch is provably exhaustive.
- `handleDiscoverRequest` swallows send errors and returns `false`. The
caller (the message-receive callback wired into the room) cannot do
anything useful with the failure, so propagating would just crash the
fire-and-forget receive loop.
- `handleFallback` is structurally permissive (`payload.transport: string`)
rather than strictly schema-validated because the fallback message is
informational; rejecting on schema mismatch would do nothing useful.
- The announce-path admits the peer even if `admitPeer` reports
`"rejected"` (room full) — the return value lets the caller
observe rejections without losing the message.

View File

@@ -0,0 +1,110 @@
# Trystero Peer Manager
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`TrysteroPeerManager` owns the deduped set of currently-discovered peers
for a single `TrysteroDiscovery` instance. It is the single source of
truth for the peer-list snapshot returned by `discover()`, mirrors every
peer transition onto the optional `EventBusLike` (`trystero:peer-discovered`
and `trystero:peer-lost`), and (when supplied) onto a
`@suaveplan/service-discovery` `ServiceRegistry` so other parts of a
running application can react to peers without coupling to the discovery
strategy. The manager also enforces the `maxPeers` cap by throwing a
`ROOM_FULL` `NetDiscTrysteroError` on insert.
Pulling all of this into a dedicated module keeps the orchestrator small
(it never touches the peer-map directly) and ensures the cleanup loop has
a stable Map handle it can iterate during staleness sweeps.
## Features
- `admit(metadata)` — insert or update. Throws `ROOM_FULL` when over cap on
insert.
- `touch(peerId)` — heartbeat refresh of `lastSeen`.
- `drop(peerId)` — emits `trystero:peer-lost`, deregisters from registry.
- `list()` / `get()` / `size()` / `getMap()`.
- `clear()` — drops every peer (emitting `trystero:peer-lost` for each).
- `retagTransport(transport)` — rewrites every entry's `transport` field
when the strategy falls back to a different transport so consumers see a
consistent view.
- OTel: increments `peers_discovered_total{transport,outcome}` with
outcome `"found" / "duplicate" / "rejected"`.
## Usage: Basic Example
```ts
import { TrysteroPeerManager } from "@suaveplan/network-discovery-trystero";
const peers = new TrysteroPeerManager({
clock: () => Date.now(),
maxPeers: 100,
appId: "demo",
});
peers.admit({
id: "p1",
addresses: ["wss://x"],
metadata: {},
lastSeen: 0,
transport: "nostr",
});
console.log(peers.size()); // 1
```
## Usage: Advanced Example
```ts
import { EventBus } from "@suaveplan/events";
import { ServiceRegistry } from "@suaveplan/service-discovery";
import {
TrysteroPeerManager,
type TrysteroEventMap,
} from "@suaveplan/network-discovery-trystero";
const bus = new EventBus<TrysteroEventMap>();
const registry = new ServiceRegistry();
const peers = new TrysteroPeerManager({
clock: () => Date.now(),
bus,
registry,
maxPeers: 50,
appId: "demo",
});
bus.on("trystero:peer-discovered", (e) => console.log("found", e.peer.id));
bus.on("trystero:peer-lost", (e) => console.log("lost", e.peerId));
peers.admit(peerMeta);
peers.retagTransport("mqtt"); // after fallback torrent -> mqtt
peers.drop("p1");
```
## API Reference
- `new TrysteroPeerManager({clock, bus?, registry?, maxPeers, appId})`.
- `admit(metadata): "added" | "updated"` — throws `NetDiscTrysteroError`
with code `ROOM_FULL` on insert when at cap.
- `touch(peerId): void`.
- `drop(peerId): boolean` — returns whether a peer was actually removed.
- `list(): readonly TrysteroPeerMetadata[]`.
- `get(peerId): TrysteroPeerMetadata | undefined`.
- `size(): number`.
- `getMap(): Map<string, TrysteroPeerMetadata>` — exposed so the cleanup
interval can iterate without paying for an array snapshot every tick.
- `clear(): void`.
- `retagTransport(transport): void`.
## Implementation Notes
- The service-registry id format is
`trystero:<transport>:<appId>:<peerId>` so multiple `TrysteroDiscovery`
instances against the same registry but with different `appId`s never
collide.
- `retagTransport` writes a new metadata object for every entry rather
than mutating in place — the existing entries are exposed externally via
`list()` and consumers may have captured prior references.
- The OTel `outcome` label is one of three literals — never user-supplied
— so dashboards can pivot on it safely.

View File

@@ -0,0 +1,95 @@
# Trystero Discovery — Structured Errors
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the closed registry of error codes emitted by
`@suaveplan/network-discovery-trystero` and the `NetDiscTrysteroError`
subclass of `SuaveplanError` that every public boundary throws on failure.
Consumers branch on `error.code` — never on `error.message` — and the
registry keeps the surface auditable per `@suaveplan/error` conventions.
The codes are split between **lifecycle / option violations** that surface
at construction or via mid-flight invariant violations (`INVALID_OPTIONS`,
`DISPOSED`, `NOT_RUNNING`, `ROOM_FULL`, `ALL_TRANSPORTS_DEGRADED`) and
**transport-specific initialisation failures** keyed by which of the four
Trystero transports went down (`TORRENT_TRACKER_FAILED`,
`NOSTR_RELAY_FAILED`, `MQTT_BROKER_FAILED`, `IPFS_BOOTSTRAP_FAILED`). The
specific codes let dashboards and alerting rules attribute failures to the
correct transport without grepping `error.cause` chains.
## Features
- Codes registered via `defineCodes("NET_DISC_TRYSTERO", [...])` —
monomorphic, namespaced, autocompletable.
- `NetDiscTrysteroError` carries `code`, optional `cause`, and a
message-resolver-friendly `messageKey`.
- Stable wire compatibility with the rest of the
`network-discovery-*` family — code keys follow the family convention.
## Usage: Basic Example
```ts
import {
NET_DISC_TRYSTERO_CODES,
NetDiscTrysteroError,
} from "@suaveplan/network-discovery-trystero";
throw new NetDiscTrysteroError(
NET_DISC_TRYSTERO_CODES.NOSTR_RELAY_FAILED,
"errors.trystero.nostr_init_failed",
{ cause: new Error("ECONNREFUSED") },
);
```
## Usage: Advanced Example
```ts
import {
NET_DISC_TRYSTERO_CODES,
NetDiscTrysteroError,
} from "@suaveplan/network-discovery-trystero";
try {
await strategy.start();
} catch (err) {
if (err instanceof NetDiscTrysteroError) {
switch (err.code) {
case NET_DISC_TRYSTERO_CODES.INVALID_OPTIONS:
return reportMisconfiguration(err);
case NET_DISC_TRYSTERO_CODES.ALL_TRANSPORTS_DEGRADED:
return surfaceDegradedBanner();
case NET_DISC_TRYSTERO_CODES.NOSTR_RELAY_FAILED:
case NET_DISC_TRYSTERO_CODES.MQTT_BROKER_FAILED:
case NET_DISC_TRYSTERO_CODES.IPFS_BOOTSTRAP_FAILED:
case NET_DISC_TRYSTERO_CODES.TORRENT_TRACKER_FAILED:
return logTransportOutage(err);
default:
throw err;
}
}
throw err;
}
```
## API Reference
- `const NET_DISC_TRYSTERO_CODES` — frozen registry containing exactly the
10 codes listed above.
- `type NetDiscTrysteroErrorCode` — string-literal union of the registered
codes.
- `class NetDiscTrysteroError extends SuaveplanError<NetDiscTrysteroErrorCode>`
— constructor `(code, messageKey, options?)`.
## Implementation Notes
- The transport-specific codes intentionally do not double as "running" /
"degraded" status flags. A single transport failure does not necessarily
mean the discovery strategy as a whole has failed — the fallback chain
may recover. Only `ALL_TRANSPORTS_DEGRADED` signals terminal failure.
- `RELAY_FAILED` is reserved as the generic transport-agnostic code; the
per-transport codes are preferred when the failing transport is known.
- All codes are surfaced through `withStartSpan` / `withTransportSpan`
attributes so OTel back-ends can pivot on `code` without parsing logs.

View File

@@ -0,0 +1,107 @@
# Trystero Discovery — OpenTelemetry Surface
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns every OpenTelemetry instrument emitted by
`@suaveplan/network-discovery-trystero`. The surface is deliberately small:
one parent span (`disc_trystero.start`), one transport-keyed connect span
(`disc_trystero.transport.<id>.connect`), three counters / histograms keyed
by transport so dashboards can pivot per-transport without parsing
attributes, and explicit fallback tracking.
The shared `tracer` / `meter` instances are obtained once at module load via
the OTel global provider. In a fresh process with no OTel SDK registered
this collapses to the cheap no-op instruments, which is what the test suite
exercises — the API is identical either way, so the same code paths cover
both observability-on and observability-off runs.
## Features
- `withStartSpan(fn)` — wraps `TrysteroDiscovery.start()` in
`disc_trystero.start`.
- `withTransportSpan(transport, fn)` — wraps a transport-specific connect
block in `disc_trystero.transport.<transport>.connect`.
- `recordPeersDiscovered(transport, outcome, count?)` — counter
`disc_trystero.peers_discovered_total{transport,outcome}`. Outcomes:
`found` / `duplicate` / `rejected`.
- `recordTransportFallback(from, to)` — counter
`disc_trystero.transport_fallback_total{from,to}`.
- `recordConnectDuration(transport, ms)` — histogram
`disc_trystero.connect_duration_ms{transport}` (unit: `ms`).
## Usage: Basic Example
```ts
import {
recordPeersDiscovered,
withStartSpan,
} from "@suaveplan/network-discovery-trystero";
await withStartSpan(async (span) => {
span.setAttribute("transport", "nostr");
// ... open the room ...
recordPeersDiscovered("nostr", "found", 1);
});
```
## Usage: Advanced Example
```ts
import {
recordConnectDuration,
recordTransportFallback,
withTransportSpan,
} from "@suaveplan/network-discovery-trystero";
async function connect(transport: "torrent" | "nostr" | "mqtt" | "ipfs") {
return withTransportSpan(transport, async () => {
const t0 = performance.now();
try {
await openRoom();
} finally {
recordConnectDuration(transport, performance.now() - t0);
}
});
}
async function failover() {
try {
await connect("nostr");
} catch {
recordTransportFallback("nostr", "mqtt");
await connect("mqtt");
}
}
```
## API Reference
- `withStartSpan<T>(fn: (span: Span) => Promise<T>): Promise<T>` — opens
`disc_trystero.start` and ends it on resolve/reject.
- `withTransportSpan<T>(transport, fn): Promise<T>` — opens
`disc_trystero.transport.<transport>.connect`, sets the `transport`
attribute, ends on resolve/reject.
- `recordPeersDiscovered(transport, outcome, count = 1): void` — outcome
literal `"found" | "duplicate" | "rejected"`. Count `<= 0` is ignored.
- `recordTransportFallback(from, to): void` — increment by one with the
`from` and `to` labels.
- `recordConnectDuration(transport, ms): void` — record the value clamped
to `[0, +Infinity)`.
- `type PeerDiscoveryOutcome = "found" | "duplicate" | "rejected"`.
## Implementation Notes
- Tracer and meter are intentionally module-level globals, fetched once
via `trace.getTracer` / `metrics.getMeter`. Production setups that swap
the provider after module-load will still see new instruments — the
global proxy obtained at load time delegates to whichever provider is
current at call time.
- The counter dimensions (`transport`, `outcome`, `from`, `to`) are
low-cardinality by design: four transports, three outcomes. Adding more
outcomes (e.g. timing buckets) must reuse the histogram instead.
- The histogram unit is `ms`. OTel back-ends should configure histogram
bucket boundaries to match peer-discovery latency expectations
(typically `[10, 50, 250, 1000, 5000, 30000]`).

View File

@@ -0,0 +1,129 @@
# TrysteroPlugin
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`TrysteroPlugin` is the `NetworkPlugin` that exposes
`TrysteroNodeTransport` to a host `NetworkNode`. It mirrors the thin
lifecycle wrappers shipped by the sibling network-transport packages
(`@suaveplan/network-transport-webrtc`,
`@suaveplan/network-transport-websockets`): `install` registers the
transport with the node's `MessageRouter`, `start` is intentionally a
no-op so the underlying Trystero room only opens lazily on the first
`connect()` call, and `stop` closes the transport and unregisters it
from the router.
Combined with `TrysteroDiscovery` from the same package, callers can
build a fully-decentralised network node: discovery learns the peer
set over Trystero rooms, and `TrysteroPlugin`'s transport carries the
application data plane on a separate action channel inside the same
Trystero infrastructure.
## Features
- Implements `NetworkPlugin` (`name`, `install`, `start`, `stop`,
optional `dispose`)
- Re-uses every `TrysteroNodeTransportConfig` field — `appId`,
primary transport, per-transport config blocks, factories,
password, ICE config, `maxPeers`
- Idempotent `dispose()`
- Safe `stop()` even when no node was installed
## Usage: Basic Example
```ts
import { NetworkNode } from "@suaveplan/network";
import { TrysteroPlugin } from "@suaveplan/network-discovery-trystero/plugin";
const node = new NetworkNode();
node.use(
new TrysteroPlugin({
appId: "my-app",
transport: "torrent",
torrent: { trackers: ["wss://tracker.example"] },
torrentRoomFactory,
})
);
await node.start();
```
## Usage: Advanced Example
```ts
import { NetworkNode } from "@suaveplan/network";
import { TrysteroPlugin } from "@suaveplan/network-discovery-trystero/plugin";
import { TrysteroDiscovery } from "@suaveplan/network-discovery-trystero";
const plugin = new TrysteroPlugin({
appId: "my-app",
transport: "nostr",
nostr: { relays: ["wss://relay.example"] },
nostrRoomFactory,
maxPeers: 64,
});
const node = new NetworkNode();
node.use(plugin);
await node.start();
// Hand-off: discovery learns peer ids; the plugin's transport
// delivers application envelopes to them.
const discovery = new TrysteroDiscovery({
appId: "my-app",
transport: "nostr",
nostr: { relays: ["wss://relay.example"] },
nostrRoomFactory,
});
await discovery.start();
// ... wire discovery events to plugin.transport.connect(peerId, roomPeerId)
await node.stop(); // closes the transport
plugin.dispose();
```
## API Reference
### `class TrysteroPlugin implements NetworkPlugin`
#### Constructor
`new TrysteroPlugin(config: TrysteroNodeTransportConfig)` — same
config object as the underlying transport.
#### Properties
- `name: "trystero"` — canonical plugin identifier.
- `transport: TrysteroNodeTransport` — the wrapped transport instance
for direct API access when needed.
#### Methods
- `install(node: NetworkNode): void` — stores the node reference and
calls `node.registerTransport(this.transport)`.
- `start(): void` — no-op. The Trystero room opens lazily on the
first `transport.connect()` call so plugin start is non-blocking
and side-effect free.
- `stop(): Promise<void>` — calls `transport.close()` then
`node.unregisterTransport(this.transport.id)`. Safe to call even
when `install()` was never called.
- `dispose(): void` — idempotent; clears the node reference.
## Implementation Notes
- Lazy room init is a deliberate departure from "open on `start()`":
Trystero rooms hold open WebSocket connections to relays/trackers,
and a plugin used only as a fallback should not pay that cost
until its transport is actually exercised.
- The plugin does NOT install `TrysteroDiscovery` automatically.
Discovery and transport share the same room infrastructure but
remain independent surfaces so consumers can swap discovery
strategies without losing the transport (e.g. discovery via
PeerJS, data plane via Trystero, or vice versa).
- `stop()` is the only place the underlying room is torn down. Once
`transport.close()` runs the transport instance is permanently
unusable; callers must construct a new plugin to reconnect.
- `dispose()` does not call `stop()`. Callers MUST `await stop()`
themselves to release the underlying Trystero room — disposing
alone simply releases the node reference for GC.

View File

@@ -0,0 +1,148 @@
# TrysteroNodeTransport
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`TrysteroNodeTransport` adapts the serverless Trystero WebRTC transport
family (BitTorrent / Nostr / MQTT / IPFS) into the
`@suaveplan/network` `Transport` contract. While the sibling
`TrysteroDiscovery` strategy uses the same room infrastructure as a
control plane to **find** peers, this transport carries the **data
plane** — `MessageEnvelope` payloads addressed to specific peers on a
dedicated `makeAction("envelope")` channel so discovery traffic and
application data stay namespace-isolated on the wire.
It reuses the four existing room factories (`TorrentRoomFactory`,
`NostrRoomFactory`, `MqttRoomFactory`, `IpfsRoomFactory`) so the same
configuration material works in both surfaces — production callers
bind the real `trystero/<transport>` `joinRoom` exports; tests bind
the deterministic in-memory `FakeTrysteroBroker` / `FakeTrysteroRoom`
pair.
## Features
- Implements the `Transport` interface (`connect`, `disconnect`,
`send`, `onMessage`, `onPeerEvent`, `getConnectedPeers`, `close`)
- Per-peer registry keyed by `peerId` with reverse-lookup by the
underlying Trystero room peer id
- Lazy room initialisation — the Trystero room opens on the first
`connect()` call, not at construction time
- Pre-aborted `AbortSignal` short-circuits `connect()` with `ABORTED`
- Auto-prunes peers when their Trystero room peer leaves
- Targeted send via Trystero's typed-action `send(envelope, target)`
- Optional `maxPeers` cap surfaces as `ROOM_FULL`
- Structured `NetDiscTrysteroError` at every boundary
- Full OTel surface — `trystero_transport.{connect,disconnect,send}`
spans, `trystero_transport.messages_total{direction,outcome}`
counter, `trystero_transport.active_peers` gauge
## Usage: Basic Example
```ts
import { TrysteroNodeTransport } from "@suaveplan/network-discovery-trystero/transport";
import { joinRoom } from "trystero/nostr";
const transport = new TrysteroNodeTransport({
appId: "my-app",
transport: "nostr",
nostr: { relays: ["wss://relay.example"] },
nostrRoomFactory: (appId, config) => joinRoom(config, appId),
});
await transport.connect("peer-a", "trystero-room-peer-id-1");
await transport.send("peer-a", envelope);
```
## Usage: Advanced Example
```ts
import { TrysteroNodeTransport } from "@suaveplan/network-discovery-trystero/transport";
const transport = new TrysteroNodeTransport({
appId: "my-app",
transport: "torrent",
torrent: { trackers: ["wss://tracker.example"] },
torrentRoomFactory,
password: "shared-secret",
rtcConfig: {
iceServers: [{ urls: "stun:stun.example:3478" }],
},
maxPeers: 64,
});
const offMessage = transport.onMessage((peerId, envelope) => {
console.log("inbound", peerId, envelope.id);
});
const offEvent = transport.onPeerEvent((peerId, event) => {
if (event === "disconnected") {
// peer pruned automatically; clear app-level state
}
});
const ac = new AbortController();
try {
await transport.connect("alice", "tr-peer-a", ac.signal);
await transport.send("alice", envelope);
} finally {
offMessage();
offEvent();
await transport.close();
}
```
## API Reference
### `class TrysteroNodeTransport implements Transport`
#### Constructor
`new TrysteroNodeTransport(config: TrysteroNodeTransportConfig)`
Reads `appId`, `transport`, the matching per-transport config block,
the matching room factory, and optional `password` / `relayPeerId` /
`rtcConfig` / `maxPeers`.
#### Methods
- `connect(peerId, address, signal?)` — registers peer; opens the
underlying Trystero room on first call. Throws `ABORTED` on
pre-aborted signal, `PEER_ALREADY_CONNECTED` on duplicate,
`ROOM_FULL` past `maxPeers`, `DISPOSED` after `close()`,
`INVALID_OPTIONS` on missing config/factory.
- `disconnect(peerId)` — removes registry entry, emits `disconnected`;
no-op on unknown peer.
- `send(peerId, envelope)` — dispatches via Trystero's targeted
send. Throws `PEER_UNKNOWN`, `DISPOSED`, `NOT_RUNNING`, or
`RELAY_FAILED`.
- `onMessage(handler)` / `onPeerEvent(handler)` — returns an
unsubscribe function.
- `getConnectedPeers()` — readonly snapshot.
- `close()` — idempotent; emits `disconnected` for every remaining
peer and tears down the room.
- `isInitialized` — true once the Trystero room is open.
- `getRoomPeers()` — diagnostic snapshot of room peer ids seen via
`onPeerJoin`.
## Implementation Notes
- The transport keeps the **data plane** on a separate Trystero
action namespace (`"envelope"`) from the discovery namespace
(`"discovery"`) used by `TrysteroDiscovery`. Two transports
sharing the same room therefore do not cross-deliver each other's
payloads.
- Address semantics: the `address` argument to `connect()` is the
underlying Trystero room peer id. Tests and integrations can use
`TrysteroDiscovery`'s emitted peer metadata to map app-level
`peerId` to Trystero peer id and then drive `connect()`.
- Inbound envelopes from room peers that have not been explicitly
registered via `connect()` are still delivered to `onMessage`
handlers — the room peer id is passed through as the `PeerId`.
Callers MAY choose to ignore unsolicited traffic at the
application layer.
- Failures to open the room map to the same transport-specific codes
the discovery strategy uses (`TORRENT_TRACKER_FAILED`,
`NOSTR_RELAY_FAILED`, etc.) so dashboards can pivot uniformly.
- `close()` is idempotent and never throws — internal `room.leave()`
failures are swallowed to keep teardown deterministic.

View File

@@ -0,0 +1,106 @@
# Trystero Discovery — Public Types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns every type and Zod schema exposed by
`@suaveplan/network-discovery-trystero`. It defines the four transport
configuration shapes (BitTorrent / Nostr / MQTT / IPFS), the single
`TrysteroTransportAdapter` contract every transport conforms to, the
wire-format message and peer-metadata shapes, the typed event map emitted
through the optional `EventBusLike`, and the runtime Zod schemas that gate
every public option struct at the package boundary.
Keeping every type in a single module (with a sibling `.schemas.ts` for
runtime validation) lets the orchestrator stay completely transport-agnostic:
the four transport adapters all conform to the same interface, and
construction-time selection happens via `transport` + the matching factory.
At runtime the orchestrator never branches on the transport-string itself.
## Features
- `TrysteroTransport` literal-union — `"torrent" | "nostr" | "mqtt" | "ipfs"`.
- Per-transport config interfaces: `TorrentTransportConfig`,
`NostrTransportConfig`, `MqttTransportConfig`, `IpfsTransportConfig`. All
enforce non-empty relay / tracker / broker lists via the matching Zod
schema.
- `TrysteroTransportAdapter` — the universal adapter contract every transport
conforms to. Owns `initialize`, `send`, `leave`, `destroy`, `ping`,
`isConnected`.
- `TrysteroAdapterCallbacks` — `onPeerJoined` / `onPeerLost` / `onMessage`.
- `TrysteroMessage` + frozen `TrysteroMessageType` registry — discovery wire
format.
- `TrysteroDiscoveryConfig` + `ResolvedTrysteroDiscoveryConfig`.
- `TrysteroEventMap` — typed events emitted on the optional `EventBusLike`.
- Zod schemas: `TrysteroDiscoveryConfigSchema`,
`TrysteroPeerMetadataSchema`, plus one strict schema per transport block.
## Usage: Basic Example
```ts
import {
type TrysteroDiscoveryConfig,
TrysteroDiscoveryConfigSchema,
} from "@suaveplan/network-discovery-trystero/types";
const cfg: TrysteroDiscoveryConfig = {
appId: "demo",
transport: "nostr",
nostr: { relays: ["wss://relay.example"] },
nostrRoomFactory: async () => { throw new Error("test"); },
};
const parsed = TrysteroDiscoveryConfigSchema.parse(cfg);
console.log(parsed.interval); // 30_000
```
## Usage: Advanced Example
```ts
import type {
TrysteroEventMap,
TrysteroTransportAdapter,
} from "@suaveplan/network-discovery-trystero/types";
import { EventBus } from "@suaveplan/events";
const bus = new EventBus<TrysteroEventMap>();
bus.on("trystero:fallback", (e) => {
console.log(`fell back from ${e.from} -> ${e.to} (attempt ${e.attempt})`);
});
function describe(a: TrysteroTransportAdapter): string {
return `kind=${a.kind} connected=${a.isConnected}`;
}
```
## API Reference
- `type TrysteroTransport` — `"torrent" | "nostr" | "mqtt" | "ipfs"`.
- `interface TrysteroPeerMetadata` — `id, addresses, metadata, lastSeen, transport`.
- `interface TrysteroMessage` — `type, from, payload, timestamp, transport?`.
- `interface TrysteroTransportAdapter` — adapter contract.
- `interface TrysteroAdapterCallbacks` — adapter→orchestrator callbacks.
- `interface TrysteroDiscoveryConfig` — caller-supplied config struct.
- `interface ResolvedTrysteroDiscoveryConfig` — post-Zod struct (no
`undefined` for defaulted fields).
- `type TrysteroEventMap` — typed events emitted on `EventBusLike`.
- `TrysteroDiscoveryConfigSchema.parse(input)` → `output` — throws Zod error
on malformed input.
- Each transport schema rejects empty `trackers` / `relays` / `brokers` /
`bootstrap` arrays.
## Implementation Notes
- The empty-list rejection is implemented on the Zod schemas via `.min(1)`,
not in the constructor. This guarantees there is no execution path where
the orchestrator sees an empty relay list, regardless of how the config
arrives at the boundary.
- `TrysteroRoomLike.makeAction` returns a `readonly` triple to discourage
callers from mutating it; the actual `trystero` library returns a mutable
array but the orchestrator only reads.
- `ResolvedTrysteroDiscoveryConfig` keeps callable factories as `undefined`
for the transports that were not selected at construction; the
orchestrator enforces "factory present for selected + fallback transports"
in code, since Zod cannot express that cross-field constraint cleanly.

View File

@@ -0,0 +1,114 @@
# @suaveplan/network-discovery-webtorrent - Browser-tier WebTorrent DiscoveryStrategy: WebTorrent-tracker-driven WebRTC peer discovery via p2pt. Tracker list REQUIRED — no public-tracker fallback. AbortSignal + EventBusLike + ServiceRegistry + OTel + Zod boundaries.
## Summary
Browser-tier `DiscoveryStrategy` for `@suaveplan/network-bootstrap` chains.
Wraps a [`p2pt`](https://github.com/subins2000/p2pt) instance —
WebTorrent-tracker-driven WebRTC peer discovery — and exposes a deduped,
capped peer set to the bootstrap pipeline.
<!-- begin:auto-docs -->
## Documentation tree
- [webtorrent-discovery](./src/core/webtorrent-discovery/webtorrent-discovery.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [webtorrent-discovery](./src/core/webtorrent-discovery/webtorrent-discovery.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [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 -->
- [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 -->
- [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 --> - [webtorrent-discovery](./src/core/webtorrent-discovery/webtorrent-discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.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)_- [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)_- [webtorrent-discovery](./src/core/webtorrent-discovery/webtorrent-discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.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)_- [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)_- [webtorrent-discovery](./src/core/webtorrent-discovery/webtorrent-discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.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)_- [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)_- [webtorrent-discovery](./src/core/webtorrent-discovery/webtorrent-discovery.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_ - [errors](./src/errors/errors.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)_- [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)_
## Why this package
- Plug-and-play `DiscoveryStrategy` slot for network bootstrap
chains. Works alongside `@suaveplan/network-discovery-peerjs`,
`@suaveplan/network-discovery-trystero`, and
`@suaveplan/network-discovery-crdt`.
- **Tracker list REQUIRED.** Upstream's hard-coded public-tracker fallback
was removed. Callers must supply their own tracker infrastructure.
- Injectable `p2ptFactory` — production wires the real `p2pt`; tests bind
a deterministic `P2PTLike` fake (no `vi.mock` ever used).
- Typed `EventBusLike<WebTorrentEventMap>` integration.
- Optional `@suaveplan/service-discovery` `ServiceRegistry` mirroring.
- OTel surface: `disc_webtorrent.start` span;
`peers_discovered_total{outcome}`, `tracker_errors_total{tracker}`,
`tracker_announce_duration_ms` instruments.
- AbortSignal honoured on every async public method.
- 100% test + branch + function + line coverage.
## Basic example
```ts
import { WebTorrentDiscovery } from "@suaveplan/network-discovery-webtorrent";
const discovery = new WebTorrentDiscovery({
trackers: ["wss://my-tracker.example.com"],
p2ptFactory: async (trackers, announceId) => {
const { default: P2PT } = await import("p2pt");
return new P2PT(trackers as string[], announceId);
},
});
await discovery.start();
const peers = await discovery.discover(5_000);
console.log(`found ${peers.length} peers`);
await discovery.stop();
```
## Advanced example
```ts
import { EventBus } from "@suaveplan/events";
import { ServiceRegistry } from "@suaveplan/service-discovery";
import { DiscoveryChain } from "@suaveplan/network-bootstrap";
import {
WebTorrentDiscovery,
type WebTorrentEventMap,
} from "@suaveplan/network-discovery-webtorrent";
const bus = new EventBus<WebTorrentEventMap>();
const registry = new ServiceRegistry();
bus.on("webtorrent:peer-discovered", ({ peer }) => {
console.log("peer:", peer.id, peer.addresses);
});
bus.on("webtorrent:tracker-error", ({ reason }) => {
console.warn("tracker:", reason);
});
const discovery = new WebTorrentDiscovery({
trackers: [
"wss://my-tracker-a.example.com",
"wss://my-tracker-b.example.com",
],
announceId: "my-app/v1",
maxPeers: 256,
p2ptFactory: async (trackers, id) => {
const { default: P2PT } = await import("p2pt");
return new P2PT(trackers as string[], id);
},
bus,
registry,
});
const chain = new DiscoveryChain([discovery]);
const peers = await chain.discover(5_000);
```
## API
See sibling co-located markdown:
- [`src/types/types.md`](./src/types/types.md)
- [`src/errors/errors.md`](./src/errors/errors.md)
- [`src/otel/otel.md`](./src/otel/otel.md)
- [`src/core/webtorrent-discovery/webtorrent-discovery.md`](./src/core/webtorrent-discovery/webtorrent-discovery.md)
## License
MIT

View File

@@ -0,0 +1,170 @@
# webtorrent-discovery
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`WebTorrentDiscovery` is the browser-tier `DiscoveryStrategy` for
`@suaveplan/network-bootstrap` chains. It wraps a single
[`p2pt`](https://github.com/subins2000/p2pt) instance — WebTorrent-tracker-
driven WebRTC peer discovery — and exposes a deduped, capped peer set to
the bootstrap pipeline. The strategy is purely an observer: peers join, the
strategy emits typed events and registers them with the optional
`ServiceRegistry`; peers drop, the strategy removes them.
The `trackers` list is REQUIRED and non-empty. Upstream's hard-coded
public-tracker fallback (`tracker.openwebtorrent.com`, `tracker.btorrent.xyz`,
`tracker.files.fm:7073`, `spacetradersapi-chatbox.herokuapp.com:443`) was
deliberately removed — callers must supply their own tracker infrastructure
or supply an explicit list. Missing or empty `trackers` raises
`NET_DISC_WEBTORRENT.INVALID_OPTIONS` at construction time, not at
`start()` time.
## Features
- Implements the `DiscoveryStrategy` contract (`name`, `discover`,
`isAvailable`) so it slots directly into `@suaveplan/network-bootstrap`
`DiscoveryChain` instances.
- Lifecycle: idempotent `start()` / `stop()` / `dispose()`.
- Injectable `p2ptFactory` — production wires the real `p2pt`; tests pass a
deterministic in-memory `P2PTLike` fake (no `vi.mock` ever used).
- Zod-validated configuration. Tracker list is non-optional and
`.min(1)`-enforced. The factory is enforced at runtime in addition to
schema-level acceptance.
- Typed `EventBusLike<WebTorrentEventMap>` integration: `started`,
`peer-discovered`, `peer-lost`, `tracker-error`, `stopped`.
- Optional `@suaveplan/service-discovery` `ServiceRegistry` integration —
peers auto-register on join, auto-deregister on drop and on
`stop()`/`dispose()`.
- OTel instrumentation: `disc_webtorrent.start` span;
`peers_discovered_total{outcome}`, `tracker_errors_total{tracker}`,
`tracker_announce_duration_ms` instruments.
- `AbortSignal` honoured on every async public method.
- Peer cap (`maxPeers`, default `100`) enforced at admission.
- Local-peer admission counters: `found`, `duplicate`, `rejected`.
## Usage: Basic Example
```ts
import { WebTorrentDiscovery } from "@suaveplan/network-discovery-webtorrent";
const discovery = new WebTorrentDiscovery({
trackers: ["wss://my-tracker.example.com"],
p2ptFactory: async (trackers, announceId) => {
const { default: P2PT } = await import("p2pt");
return new P2PT(trackers as string[], announceId);
},
});
await discovery.start();
const peers = await discovery.discover(5_000);
console.log(`found ${peers.length} peers`);
await discovery.stop();
```
## Usage: Advanced Example
```ts
import { createEventBus } from "@suaveplan/events";
import { ServiceRegistry } from "@suaveplan/service-discovery";
import { DiscoveryChain } from "@suaveplan/network-bootstrap";
import {
WebTorrentDiscovery,
type WebTorrentEventMap,
} from "@suaveplan/network-discovery-webtorrent";
const bus = createEventBus<WebTorrentEventMap>();
const registry = new ServiceRegistry();
bus.on("webtorrent:peer-discovered", ({ peer }) => {
console.log("peer joined:", peer.id, peer.addresses);
});
bus.on("webtorrent:tracker-error", ({ reason }) => {
console.warn("tracker error:", reason);
});
const discovery = new WebTorrentDiscovery({
trackers: [
"wss://my-tracker-a.example.com",
"wss://my-tracker-b.example.com",
],
announceId: "my-app/v1",
maxPeers: 256,
timeout: 30_000,
p2ptFactory: async (trackers, id) => {
const { default: P2PT } = await import("p2pt");
return new P2PT(trackers as string[], id);
},
bus,
registry,
});
const ac = new AbortController();
const chain = new DiscoveryChain([discovery]);
const peers = await chain.discover(5_000, ac.signal);
for (const peer of peers) {
// service-discovery has already mirrored these registrations.
console.log(peer.peerId, peer.addresses);
}
await discovery.stop();
discovery.dispose();
```
## API Reference
- `new WebTorrentDiscovery(config, clock?)` — Zod-validates the config,
enforces a callable `p2ptFactory`, stores the resolved configuration.
Throws `NET_DISC_WEBTORRENT.INVALID_OPTIONS` on validation / missing
factory.
- `start(signal?)` — instantiates the underlying p2pt via the factory,
wires every event handler, calls `p2pt.start()`, emits `started`. Throws
`TRACKER_FAILED` (with the underlying error as `cause`) on factory or
start failure; throws `DISPOSED` if disposed; rethrows abort-error if
signalled.
- `stop(signal?)` — idempotent. Tears the p2pt instance down, deregisters
every peer from the registry, clears local state. Emits `stopped` when
the call wasn't aborted.
- `discover(timeoutMs, signal?)` — returns the current dedup'd peer
snapshot mapped into `network-bootstrap` `PeerInfo[]`. Honours signal
up-front.
- `isAvailable(signal?)` — `true` only while running, not disposed, and
the underlying transport has reported `trackerconnect`.
- `announce(peerInfo, signal?)` — registers the local descriptor with the
optional service registry. Throws `DISPOSED` / `NOT_RUNNING` when
appropriate.
- `getPeers()` / `getPeerCount()` / `isRunning()` /
`isTrackerConnected()` — synchronous introspection.
- `dispose()` — permanent teardown; subsequent lifecycle calls throw
`DISPOSED`.
## Implementation Notes
- The `peers` map keys on the p2pt-assigned id (preferring `peer.id`, then
`peer._id`). Anything else surfaces as `recordPeersDiscovered("rejected")`
but never bubbles an exception — the underlying transport runs its own
retries.
- The `maxPeers` cap is enforced at admission: once the map is full,
further `peerconnect` events emit `recordPeersDiscovered("rejected")`
and are dropped. The cap is intentionally simple — no LRU — because
WebTorrent rooms tend to grow monotonically and operators are expected
to set the cap to a sane upper bound for their tracker.
- `trackerconnect` flips `trackerConnected` to `true` exactly once, even
if the underlying p2pt instance re-fires the event during reconnects.
The first transition records the announce-duration histogram.
- The factory is `async`-tolerant so production code can dynamically
`import("p2pt")` (a Node-flavoured package) without forcing this
package itself to bundle it. Tests pass a synchronous in-memory fake.
- `AbortSignal` is honoured up-front on every public method; in
`start()`, after the factory resolves we re-check the signal so a
cancellation during the dynamic import doesn't leak the p2pt instance.
- The `ServiceRegistry` integration uses the canonical id form
`webtorrent:<announceId>:<peerId>` mirroring the `trystero` and `peerjs`
packages, so a single `ServiceRegistry` watcher can see peers from
every discovery family without colliding.
- Every catch around `instance.destroy()` swallows because real p2pt
instances can throw during teardown if a tracker is mid-flight — the
swallowed error is already covered by the structured `TRACKER_FAILED`
emission, so re-throwing would double-report.

View File

@@ -0,0 +1,107 @@
# errors
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the structured-error registry for
`@suaveplan/network-discovery-webtorrent`. Every public boundary in the
package surfaces failures as a `NetDiscWebTorrentError` whose `code` field
is drawn from the frozen `NET_DISC_WEBTORRENT_CODES` registry built via
`@suaveplan/error`'s `defineCodes` helper. Consumers branch on `error.code`
without parsing message strings.
## Features
- Frozen `NET_DISC_WEBTORRENT_CODES` registry — five stable codes covering
every failure mode the strategy can hit.
- `NetDiscWebTorrentError` class extending `SuaveplanError`, carrying a
typed `code`, a stable message-key, and an optional `cause` chain for
upstream provenance.
- Round-trippable with the rest of the error ecosystem — wire
serialization, redaction, and the OTel span-event adapter all work
out-of-the-box.
## Usage: Basic Example
```ts
import {
NET_DISC_WEBTORRENT_CODES,
NetDiscWebTorrentError,
} from "@suaveplan/network-discovery-webtorrent";
throw new NetDiscWebTorrentError(
NET_DISC_WEBTORRENT_CODES.INVALID_OPTIONS,
"errors.webtorrent.invalid_options"
);
```
## Usage: Advanced Example
```ts
import {
NET_DISC_WEBTORRENT_CODES,
NetDiscWebTorrentError,
} from "@suaveplan/network-discovery-webtorrent";
try {
await discovery.start();
} catch (err) {
if (
err instanceof NetDiscWebTorrentError &&
err.code === NET_DISC_WEBTORRENT_CODES.TRACKER_FAILED
) {
// Fall back to another DiscoveryStrategy in the chain.
console.warn("webtorrent trackers unreachable:", err.cause);
} else {
throw err;
}
}
```
## API Reference
- `NET_DISC_WEBTORRENT_CODES` — frozen object whose keys are:
- `INVALID_OPTIONS` — caller-supplied config failed Zod validation, the
`trackers` list was empty, or the required `p2ptFactory` was missing.
- `DISPOSED` — operation attempted after `dispose()` cleared state.
- `NOT_RUNNING` — operation attempted before `start()` completed or after
`stop()` cleared state.
- `TRACKER_FAILED` — the underlying transport's tracker stack failed and
no peers can be discovered.
- `PEER_CONNECT_FAILED` — a peer-connect event was received but the
record could not be admitted.
- `TRANSPORT_CLOSED` — transport-level: a `send`/`connect` was attempted
after `close()`.
- `PEER_ALREADY_CONNECTED` — transport-level: `connect(peerId)` invoked
twice for the same id.
- `PEER_UNKNOWN` — transport-level: `send` invoked for an unknown peer.
- `MAX_PEERS_EXCEEDED` — transport-level: per-peer cap reached on
`connect`.
- `SEND_FAILED` — transport-level: the underlying `p2pt.send` rejected.
- `ABORTED` — transport-level: pre-aborted `AbortSignal` observed on
`connect`.
- `CONNECT_TIMEOUT` — transport-level: peer never showed up before the
configured connect timeout.
- `SEND_UNSUPPORTED` — transport-level: the supplied `p2pt` instance
omits the `send` capability.
- `NetDiscWebTorrentError extends SuaveplanError<NetDiscWebTorrentErrorCode>`
— constructor `(code, messageKey, options?)` accepting an optional
`{ cause }` for chained errors.
## Implementation Notes
- Codes are registered with `@suaveplan/error`'s `defineCodes` so they
participate in the global stable-code namespace; duplicate registrations
fail at import time rather than silently overlapping.
- `messageKey` is a translation key, not human-facing copy — i18n bundles
resolve these via `@suaveplan/i18n` when running in apps that wire the
resolver.
- The `cause` chain is preserved across `JSON.stringify` via
`SuaveplanError.toJSON`; consumers can serialise discovery failures to
structured logs without losing the original exception.
- The registry is intentionally small. `TRACKER_FAILED` covers every
transport-level failure (we have only one transport here); `INVALID_OPTIONS`
is the umbrella for every static configuration error including the
missing-tracker-list case.

View File

@@ -0,0 +1,105 @@
# otel
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
OpenTelemetry instrumentation surface for the WebTorrent discovery
strategy. Provides one span helper for the `start()` lifecycle, one counter
for discovered peers (labelled by outcome), one counter for tracker errors
(labelled by tracker URL), and one histogram measuring tracker-announce
wall-clock latency.
All instruments resolve against `@opentelemetry/api` only — no SDK is
required for the package to load or run. When the host application wires an
SDK, every instrument records into the same pipelines as the rest of the
suaveplan observability stack.
## Features
- `withStartSpan(fn)` — wraps the `start()` body in a
`disc_webtorrent.start` span; surfaces errors through the span's standard
`recordException` path.
- `recordPeersDiscovered(outcome, count?)` — increments
`disc_webtorrent.peers_discovered_total{outcome}`. Outcomes are
`"found" | "duplicate" | "rejected"`. Negative or zero counts are
silently ignored.
- `recordTrackerError(tracker)` — increments
`disc_webtorrent.tracker_errors_total{tracker}`. Missing tracker labels
fall back to `"unknown"` to keep cardinality bounded.
- `recordTrackerAnnounceDuration(ms)` — records into
`disc_webtorrent.tracker_announce_duration_ms`. Negative values are
clamped to zero.
## Usage: Basic Example
```ts
import {
recordPeersDiscovered,
withStartSpan,
} from "@suaveplan/network-discovery-webtorrent";
await withStartSpan(async (span) => {
span.setAttribute("app_id", "my-app/v1");
recordPeersDiscovered("found", 1);
});
```
## Usage: Advanced Example
```ts
import {
recordPeersDiscovered,
recordTrackerAnnounceDuration,
recordTrackerError,
withStartSpan,
} from "@suaveplan/network-discovery-webtorrent";
await withStartSpan(async (span) => {
span.setAttribute("tracker_count", 3);
const t0 = Date.now();
try {
await connectToTrackers();
recordTrackerAnnounceDuration(Date.now() - t0);
} catch (err) {
recordTrackerError("wss://tracker.example.com");
throw err;
}
// Inside event loop:
recordPeersDiscovered("found", 1);
recordPeersDiscovered("duplicate", 1);
});
```
## API Reference
- `withStartSpan<T>(fn: (span: Span) => Promise<T>): Promise<T>` — runs
`fn` inside an active `disc_webtorrent.start` span, ending the span on
resolution and on failure.
- `PeerDiscoveryOutcome = "found" | "duplicate" | "rejected"` — outcome
label union for the peers-discovered counter.
- `recordPeersDiscovered(outcome: PeerDiscoveryOutcome, count?: number)` —
increments the discovered-peers counter (default count `1`; non-positive
counts are no-ops).
- `recordTrackerError(tracker: string | undefined)` — increments the
tracker-error counter; an `undefined` label is normalised to `"unknown"`.
- `recordTrackerAnnounceDuration(ms: number)` — records milliseconds into
the announce-duration histogram, clamping negative values to zero.
## Implementation Notes
- Instrument names are namespaced under `disc_webtorrent.*` so dashboards
can filter the entire WebTorrent discovery family without parsing
attribute values.
- Label cardinality is intentionally bounded: outcomes are a closed
three-way union, and unknown tracker labels collapse to `"unknown"` so
the counter cannot explode under adversarial input.
- The tracker-announce histogram measures from `start()` entry to the
underlying transport's first `trackerconnect` event, not to the first
peer. The two latencies are deliberately separated so dashboards can
distinguish slow trackers from slow peers.
- The span helper is a thin pass-through over `tracer.startActiveSpan`
with mandatory `span.end()` in a `finally` block. Errors propagate
unchanged so the caller's catch-block sees the original exception.

View File

@@ -0,0 +1,105 @@
# plugin
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`WebTorrentPlugin` is the `NetworkPlugin` adapter that exposes a
`WebTorrentTransport` to a host `NetworkNode` from `@suaveplan/network`.
It is a thin lifecycle wrapper — the heavy lifting (tracker
rendezvous, peer registry, message framing, error mapping, OTel) lives
inside the underlying `WebTorrentTransport` — so the plugin keeps the
shape callers already learned from the `WebSocketPlugin` and
`WebRTCPlugin` siblings.
This package is the "fold" Wave-3.4 addition to the existing
`@suaveplan/network-discovery-webtorrent` package: both the
`DiscoveryStrategy` and the `Transport` family of plugins live under the
same package umbrella, mirroring the upstream layout while preserving a
clean subpath surface (`./transport`, `./plugin`).
## Features
- Stable `name: "webtorrent"` plugin identifier — discovery and routing
plumbing key off this value.
- `install(node)` registers the underlying transport with the node's
`MessageRouter` so routed envelopes can find it.
- `start()` is intentionally a no-op — the p2pt tracker socket is opened
lazily inside the transport on the first `connect()`.
- `stop()` closes the transport (rejecting pending connects, draining
peer state, destroying the p2pt instance) and unregisters it from the
node.
- `dispose()` is idempotent.
## Usage: Basic Example
```ts
import { NetworkNode } from "@suaveplan/network";
import { WebTorrentPlugin } from "@suaveplan/network-discovery-webtorrent/plugin";
import P2PT from "p2pt";
const node = new NetworkNode();
const plugin = new WebTorrentPlugin({
trackers: ["wss://tracker.openwebtorrent.com"],
p2ptFactory: (trackers, id) => new P2PT(trackers as string[], id),
});
node.use(plugin);
await node.start();
```
## Usage: Advanced Example
```ts
import { NetworkNode } from "@suaveplan/network";
import { WebTorrentPlugin } from "@suaveplan/network-discovery-webtorrent/plugin";
async function makeNode(trackers: string[]) {
const node = new NetworkNode();
const plugin = new WebTorrentPlugin({
trackers,
announceId: "demo-room",
maxPeers: 16,
connectTimeoutMs: 8_000,
p2ptFactory: async (t, id) => {
const { default: P2PT } = await import("p2pt");
return new P2PT(t as string[], id);
},
});
node.use(plugin);
await node.start();
// Routing through the node now selects the webtorrent transport
// whenever an envelope's target peer is reachable through it.
return { node, plugin };
}
```
## API Reference
- `class WebTorrentPlugin implements NetworkPlugin`
- `readonly name: "webtorrent"`
- `readonly transport: WebTorrentTransport` — exposed for tests and
advanced wiring; consumers should drive sends through the host
`NetworkNode` rather than this field.
- `constructor(config: WebTorrentTransportConfig)` — throws
`INVALID_OPTIONS` on bad config.
- `install(node: NetworkNode): void` — registers the transport.
- `start(): void` — no-op.
- `stop(): Promise<void>` — closes the transport and unregisters it
from the node. Safe to call when no node was installed.
- `dispose(): void` — idempotent; releases the node reference.
## Implementation Notes
- `start()` is intentionally non-async — there is no remote rendezvous
to perform up-front. p2pt is created on the first `connect()` so a
headless node never opens a tracker socket.
- `stop()` always closes the transport first, then unregisters; this
ordering guarantees that any in-flight `send` rejects with
`TRANSPORT_CLOSED` rather than `ROUTER_NO_TRANSPORT`.
- Sequential `stop()`s are tolerated through the underlying transport's
idempotent `close()` and the node's idempotent
`unregisterTransport` (the second call is a no-op when the transport
is already gone).
- `dispose()` is separate from `stop()` so consumers can release the
node reference without re-closing the transport.

View File

@@ -0,0 +1,147 @@
# transport
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`WebTorrentTransport` implements the `@suaveplan/network` `Transport`
contract over a WebTorrent-tracker-signalled WebRTC mesh via `p2pt`. It
composes the same `P2PTLike` surface that `WebTorrentDiscovery` consumes,
so production code wires the real `(trackers, id) => new P2PT(trackers,
id)` and tests wire `FakeP2PT` — no `vi.mock` required. The adapter owns
its own p2pt instance (lazily constructed on the first `connect` call)
and surfaces typed peer events, JSON-framed envelope delivery, AbortSignal
cancellation, structured errors, and OTel instrumentation.
This package is "Option B (fold)" from the `network-transport-webtorrent`
proposal — the Wave 2 discovery package gains a sibling `Transport`
implementation rather than spawning a separate package, matching upstream
layout while keeping a clean public surface via the `./transport`
subpath export.
## Features
- Implements every member of the `Transport` contract (`id`, `connect`,
`disconnect`, `send`, `onMessage`, `onPeerEvent`, `getConnectedPeers`,
`close`).
- Lazy p2pt instantiation — headless nodes that never `connect()` never
open a tracker socket.
- AbortSignal threaded through `connect` (both pre-aborted and mid-flight).
- Per-`connect` timeout (`connectTimeoutMs`, default 30s) surfacing as
`CONNECT_TIMEOUT`.
- Strict per-peer registry — `connect` rejects duplicates with
`PEER_ALREADY_CONNECTED` and respects an optional `maxPeers` cap.
- Outbound `send` JSON-stringifies envelopes and delegates to
`p2pt.send(peer, msg)`; inbound `msg` events JSON-parse and fan out to
every registered handler.
- 9 transport-level error codes share the
`NET_DISC_WEBTORRENT_CODES` registry alongside the discovery codes.
- OTel: `webtorrent_transport.{connect,disconnect,send}` spans, a
`webtorrent_transport.messages_total{direction,outcome}` counter, and
a `webtorrent_transport.active_peers` up-down gauge.
## Usage: Basic Example
```ts
import { WebTorrentTransport } from "@suaveplan/network-discovery-webtorrent/transport";
import P2PT from "p2pt";
const transport = new WebTorrentTransport({
trackers: ["wss://tracker.openwebtorrent.com"],
p2ptFactory: (trackers, id) => new P2PT(trackers as string[], id),
});
transport.onMessage((peerId, envelope) => {
console.log("from", peerId, envelope);
});
await transport.connect("peer-abc", "/webrtc/p2p/peer-abc");
await transport.send("peer-abc", {
id: "m1",
type: "ping",
senderId: "self",
payload: {},
timestamp: Date.now(),
headers: {},
ttl: 64,
visitedNodes: [],
});
await transport.close();
```
## Usage: Advanced Example
```ts
import { WebTorrentTransport } from "@suaveplan/network-discovery-webtorrent/transport";
import {
NET_DISC_WEBTORRENT_CODES,
NetDiscWebTorrentError,
} from "@suaveplan/network-discovery-webtorrent";
const ac = new AbortController();
const transport = new WebTorrentTransport({
trackers: ["wss://tracker.openwebtorrent.com"],
announceId: "my-app-room",
maxPeers: 32,
connectTimeoutMs: 10_000,
p2ptFactory: async (trackers, id) => {
const { default: P2PT } = await import("p2pt");
return new P2PT(trackers as string[], id);
},
});
setTimeout(() => ac.abort(), 5_000);
try {
await transport.connect("peer-xyz", "/p2p/peer-xyz", ac.signal);
} catch (err) {
if (
err instanceof NetDiscWebTorrentError &&
err.code === NET_DISC_WEBTORRENT_CODES.CONNECT_TIMEOUT
) {
// fall back to a different transport
} else {
throw err;
}
}
```
## API Reference
- `class WebTorrentTransport implements Transport`
- `readonly id: "webtorrent"`
- `constructor(config: WebTorrentTransportConfig)` — throws
`INVALID_OPTIONS` on bad config.
- `connect(peerId, address, signal?): Promise<void>` — resolves once
p2pt reports `peerconnect` for the id. Rejects with
`TRANSPORT_CLOSED`, `ABORTED`, `PEER_ALREADY_CONNECTED`,
`MAX_PEERS_EXCEEDED`, `CONNECT_TIMEOUT`, or `TRACKER_FAILED`.
- `disconnect(peerId): Promise<void>` — no-op for unknown peers.
- `send(peerId, envelope): Promise<void>` — rejects with
`TRANSPORT_CLOSED`, `PEER_UNKNOWN`, `SEND_UNSUPPORTED`, or
`SEND_FAILED`.
- `onMessage(handler): () => void` — returns unsubscribe handle.
- `onPeerEvent(handler): () => void` — returns unsubscribe handle.
- `getConnectedPeers(): readonly PeerId[]`
- `close(): Promise<void>` — idempotent.
## Implementation Notes
- The Transport contract's `connect(peerId, address)` is a poor fit for
p2pt's room-based discovery model. The adapter therefore treats
`address` as informational and resolves the promise once p2pt emits
`peerconnect` for the matching id (within `connectTimeoutMs`).
- p2pt is created lazily and exactly once per transport instance. A
factory rejection bubbles up wrapped in `TRACKER_FAILED` and clears the
init promise so the next `connect()` can retry.
- The active-peers gauge is `UpDownCounter`-backed and only adjusted on
observed `peerconnect`/`peerclose` (or `disconnect`/`close`); `connect`
rollback paths never adjust the gauge so the metric stays consistent
with `getConnectedPeers()`.
- All AbortSignal listeners are registered with `{ once: true }` and
proactively removed on resolve/reject/timeout to avoid leaks.
- Inbound messages are tolerated as either pre-parsed objects or JSON
strings — real p2pt delivers strings; in-memory fakes can deliver
objects directly. Parse failures count as `inbound,error` on the
messages counter.

View File

@@ -0,0 +1,122 @@
# types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Public type contracts for `@suaveplan/network-discovery-webtorrent`. Defines
the wire-format-neutral peer-metadata record, the structural `P2PTLike`
surface that production code and tests both bind to, the discovery-strategy
configuration, the fully-resolved configuration shape after defaults are
applied, and the typed event-map for the optional `EventBusLike` integration.
These types are zero-runtime; every public boundary is also validated by a
Zod schema in `types.schemas.ts`.
## Features
- `WebTorrentPeerMetadata` — observed-peer record with `addresses`,
`connectedAt`, mutable `lastSeen`, and free-form metadata.
- `P2PTLike` — structural surface of a `p2pt` instance (`peerconnect`,
`peerclose`, `msg`, `trackerconnect`, `trackerwarning` events, `start` /
`destroy` lifecycle). Tests bind a deterministic in-memory implementation
to this interface — no `vi.mock` ever needed.
- `P2PTFactory` — `(trackers, announceId) => P2PTLike | Promise<P2PTLike>`
signature injected via constructor options.
- `WebTorrentDiscoveryConfig` — strict caller-supplied configuration. The
`trackers` array is REQUIRED and may not be empty; there is no implicit
public-tracker fallback in this package.
- `ResolvedWebTorrentDiscoveryConfig` — fully-defaulted configuration the
orchestrator stores after Zod parsing.
- `WebTorrentEventMap` — typed payload contract for every event emitted on
the optional `EventBusLike`.
## Usage: Basic Example
```ts
import type {
WebTorrentDiscoveryConfig,
P2PTFactory,
} from "@suaveplan/network-discovery-webtorrent/types";
const factory: P2PTFactory = (trackers, id) => {
// Tests pass a deterministic stub here.
throw new Error("supply a real factory in production");
};
const config: WebTorrentDiscoveryConfig = {
trackers: ["wss://my-tracker.example.com"],
p2ptFactory: factory,
};
```
## Usage: Advanced Example
```ts
import { ServiceRegistry } from "@suaveplan/service-discovery";
import { createEventBus } from "@suaveplan/events";
import type {
WebTorrentDiscoveryConfig,
WebTorrentEventMap,
} from "@suaveplan/network-discovery-webtorrent/types";
const bus = createEventBus<WebTorrentEventMap>();
const registry = new ServiceRegistry();
bus.on("webtorrent:peer-discovered", ({ peer }) => {
console.log("peer:", peer.id, peer.addresses);
});
const config: WebTorrentDiscoveryConfig = {
trackers: [
"wss://my-tracker-a.example.com",
"wss://my-tracker-b.example.com",
],
announceId: "my-app/v1",
maxPeers: 256,
timeout: 30_000,
p2ptFactory: async (trackers, id) => {
const { default: P2PT } = await import("p2pt");
return new P2PT(trackers as string[], id);
},
bus,
registry,
};
```
## API Reference
- `WebTorrentPeerMetadata` — `{ id, addresses, protocols, metadata,
connectedAt, lastSeen }`. `lastSeen` is intentionally mutable.
- `P2PTLike` — `on(event, handler)`, `start()`, `destroy()`. The structural
surface every p2pt-like backend (real + fake) must conform to.
- `P2PTFactory` — `(trackers, announceId) => P2PTLike | Promise<P2PTLike>`.
Production binds to the real `p2pt` package; tests pass an in-memory
implementation.
- `WebTorrentDiscoveryConfig` — `trackers` (REQUIRED, non-empty),
`announceId` (default `"suaveplan-discovery"`), `maxPeers` (default `100`),
`timeout` (default `60_000`), `p2ptFactory` (REQUIRED), `bus`, `registry`.
- `ResolvedWebTorrentDiscoveryConfig` — defaulted form stored on the
orchestrator instance.
- `WebTorrentEventMap` — `webtorrent:started`,
`webtorrent:peer-discovered`, `webtorrent:peer-lost`,
`webtorrent:tracker-error`, `webtorrent:stopped` payload types.
## Implementation Notes
- The `trackers` array is never defaulted by this package. Upstream's public
tracker fallback (`wss://tracker.openwebtorrent.com` / `tracker.btorrent.xyz`
/ `tracker.files.fm:7073/announce` / `spacetradersapi-chatbox.herokuapp.com:443/announce`)
was deliberately removed — callers must own their tracker infrastructure
or supply an explicit list. The Zod schema enforces `.min(1)` and the
constructor surfaces violations as `INVALID_OPTIONS`.
- `P2PTLike` is intentionally narrower than the real `p2pt` event-emitter
surface — only the events this package consumes are typed. The real
`p2pt` instance conforms; the test fake conforms; nothing else is required.
- `P2PTFactory` returns `P2PTLike | Promise<P2PTLike>` so production code
can dynamically import `p2pt` (a Node-flavoured package) without forcing
the discovery package itself to bundle it.
- `WebTorrentPeerMetadata.lastSeen` is `let`-mutable to allow zero-allocation
in-place touch updates — this is the only field that mutates after the
record is built.

View File

@@ -0,0 +1,108 @@
# @suaveplan/network-gundb-server - Server-tier GunDB relay daemon for @suaveplan/network-discovery-gundb clients — Node-only HTTP/WebSocket relay with structural Zod wire-payload validation, AbortSignal lifecycle, health/ready/live endpoints, OTel observability, and injectable Gun factory for real-fake integration tests.
## Summary
Server-tier GunDB relay daemon. Hosts a Gun relay peer plus standard
`/health` / `/ready` / `/live` probes on a single Node `http.Server`.
Browser-tier clients running `@suaveplan/network-discovery-gundb` pass
`http(s)://<host>:<port>/gun` in their `peers[]` list and gossip
signed peer-announces through the relay.
<!-- 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/health.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [health](./src/health/health.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 -->
- [server](./src/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [listen-http](./src/server/listen-http.md) — Free function that binds a `node:http` server to `(port, host)` and translates the resulting Node EventEmitter signals…
- [server](./src/server/server.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 -->
- [validation](./src/validation/validation.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [validation](./src/validation/validation.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/health.md) — <!-- begin:auto-docs --> _(no submodules)_- [health](./src/health/health.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)_- [server](./src/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/server/server.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)_- [validation](./src/validation/validation.md) — <!-- begin:auto-docs --> _(no submodules)_- [validation](./src/validation/validation.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [health](./src/health/health.md) — <!-- begin:auto-docs --> _(no submodules)_- [health](./src/health/health.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)_- [server](./src/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/server/server.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)_- [validation](./src/validation/validation.md) — <!-- begin:auto-docs --> _(no submodules)_- [validation](./src/validation/validation.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [health](./src/health/health.md) — <!-- begin:auto-docs --> _(no submodules)_- [health](./src/health/health.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)_- [server](./src/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [server](./src/server/server.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)_- [validation](./src/validation/validation.md) — <!-- begin:auto-docs --> _(no submodules)_- [validation](./src/validation/validation.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/errors.md) — <!-- begin:auto-docs --> _(no submodules)_- [health](./src/health/health.md) — <!-- begin:auto-docs --> _(no submodules)_ - [health](./src/health/health.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)_- [server](./src/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_ - [server](./src/server/server.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)_- [validation](./src/validation/validation.md) — <!-- begin:auto-docs --> _(no submodules)_ - [validation](./src/validation/validation.md) — <!-- begin:auto-docs --> _(no submodules)_
## Why this package
- **Single daemon, no orchestrator coupling.** Boot the relay,
publish the endpoint, watch the health probes.
- **Defensive by default.** Every inbound announce is structurally
validated against the shared Zod schema; malformed records are
dropped silently and counted on
`gundb_server.invalid_messages_total{reason}`.
- **Injection-friendly.** The Gun runtime is a peer dep — production
wires real `Gun`, tests bind a deterministic in-memory fake.
- **Lifecycle-correct.** `start({ signal? })` resolves with the bound
`{ address, port }`. `stop()` is idempotent.
## Run the daemon
```ts
import { GunDBServer } from "@suaveplan/network-gundb-server";
import Gun from "gun";
const server = new GunDBServer({
port: 8765,
host: "0.0.0.0",
storagePath: "./data/gun",
radisk: true,
maxConnections: 256,
gunFactory: (cfg) => Gun(cfg),
});
const { address, port } = await server.start();
console.log(`relay listening on ${address}:${port}/gun`);
process.on("SIGTERM", async () => {
await server.stop();
process.exit(0);
});
```
## Configuration reference
See [`src/types/types.md`](./src/types/types.md) for the full strict
`GunDBServerConfig`. All fields except `port` and `gunFactory` are
optional and have spec-documented defaults.
## Health endpoints
| Verb | 200 OK when | 503 when |
|----------|--------------------------------------|--------------------------------------|
| `/live` | process alive (always, until stop) | after `stop()` |
| `/ready` | started AND below `maxConnections` | starting, at-cap, or stopped |
| `/health`| started AND below `maxConnections` | starting, at-cap, or stopped |
See [`src/health/health.md`](./src/health/health.md).
## Observability
OpenTelemetry instrument names emitted by the daemon:
- `gundb_server.start` / `gundb_server.stop` — spans.
- `gundb_server.connections_total{outcome}` — counter.
- `gundb_server.messages_relayed_total{outcome}` — counter.
- `gundb_server.invalid_messages_total{reason}` — security counter.
- `gundb_server.active_connections` — up-down gauge.
- `gundb_server.start_duration_ms` — histogram.
See [`src/otel/otel.md`](./src/otel/otel.md).
## Security model
The relay does **not** verify Ed25519 signatures on inbound
announces — signature verification is the client's responsibility per
the gossip security model. The relay structurally validates the
presence and shape of every signature field so a malformed announce
cannot poison the namespace, then forwards the signed payload
unchanged.
See [`src/validation/validation.md`](./src/validation/validation.md).
## Companion package
[`@suaveplan/network-discovery-gundb`](../network-discovery-gundb) is
the browser-tier client this daemon serves.

View File

@@ -0,0 +1,84 @@
# `network-gundb-server/errors`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the frozen error-code registry and `NetGunDBServerError` class
used by every public boundary in `@suaveplan/network-gundb-server`. The
registry is built via `defineCodes("NET_GUNDB_SERVER", [...] as const)`
so codes are stable string literals, type-narrowable at the call site,
and surfaced as labels on the structural counters in `./otel`.
The relay must never crash on a malformed inbound payload — wire-payload
rejection codes are surfaced via counters only, not thrown. Lifecycle
codes (`START_FAILED` / `PORT_IN_USE` / `DISPOSED` / `NOT_RUNNING` /
`INVALID_OPTIONS`) ARE thrown out of `start` / `stop`.
## Features
- 6-code frozen registry: `INVALID_OPTIONS`, `START_FAILED`,
`PORT_IN_USE`, `DISPOSED`, `NOT_RUNNING`, `INVALID_WIRE_PAYLOAD`.
- `NetGunDBServerError extends SuaveplanError<NetGunDBServerErrorCode>`
preserves the canonical `code` / `messageKey` / `cause` / `context`
surface from `@suaveplan/error`.
- Codes also serve as low-cardinality labels for the OTel counter
surface (`reason` attribute on
`gundb_server.invalid_messages_total`).
## Usage: Basic Example
```ts
import {
NET_GUNDB_SERVER_CODES,
NetGunDBServerError,
} from "@suaveplan/network-gundb-server";
throw new NetGunDBServerError(
NET_GUNDB_SERVER_CODES.NOT_RUNNING,
"gundb_server.start-required"
);
```
## Usage: Advanced Example
```ts
import {
NET_GUNDB_SERVER_CODES,
NetGunDBServerError,
} from "@suaveplan/network-gundb-server";
try {
await server.start();
} catch (err) {
if (
err instanceof NetGunDBServerError &&
err.code === NET_GUNDB_SERVER_CODES.PORT_IN_USE
) {
// Fallback to a random free port.
return retryWithFreshPort();
}
throw err;
}
```
## API Reference
- `NET_GUNDB_SERVER_CODES` — frozen `Record<UpperSnake, string>` of
registered codes.
- `NetGunDBServerErrorCode` — union of every registered code literal.
- `class NetGunDBServerError` — constructor
`(code, messageKey, options?)` — throws on every public lifecycle
boundary.
## Implementation Notes
- `INVALID_WIRE_PAYLOAD` is **registered** but **not thrown** out of
the inbound message handler — the relay drops malformed payloads
silently and increments the
`gundb_server.invalid_messages_total{reason}` counter so dashboards
can spot spikes from misbehaving clients without taking the node
down.
- `defineCodes` produces an `as const` frozen object so every code is
type-narrowable in `switch` / `if` chains downstream.

View File

@@ -0,0 +1,89 @@
# `network-gundb-server/health`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Inline health/readiness/liveness endpoints for the `GunDBServer`
daemon. The package surfaces three standard verbs at `/health`,
`/ready`, and `/live` on the same HTTP server the Gun relay listens
on — orchestrators (Kubernetes probes, AWS ALB, Docker healthchecks)
hit them to decide whether to route traffic, restart the container, or
mark the instance terminated. The module ships as a pure function set
(no `http.Server` import) so the daemon can keep its single HTTP
listener and dispatch health routes before the Gun relay sees them.
Once `@suaveplan/health-check` lands as a dedicated package this module
will become a thin shim over that surface; today it owns the
implementation directly so the relay is not blocked by an
out-of-package dependency.
## Features
- Three standard verbs: `/health`, `/ready`, `/live`.
- `live` returns 200 unless `stop()` has been called.
- `ready` returns 200 only when started AND has spare capacity.
- `health` returns 200 when running and not at-cap, 503 when degraded.
- JSON-only response body with structured status fields.
- Query-string and trailing-slash agnostic path classifier.
- Pure functions — every input is captured at call-time so the same
response is byte-deterministic for any given snapshot.
## Usage: Basic Example
```ts
import {
classifyHealthRequest,
handleHealth,
} from "@suaveplan/network-gundb-server";
if (handleHealth(req, res, snapshot)) {
return; // Health route handled; do not fall through to Gun.
}
```
## Usage: Advanced Example
```ts
import {
buildHealthResponse,
type HealthSnapshot,
} from "@suaveplan/network-gundb-server";
const snap: HealthSnapshot = {
running: true,
disposed: false,
activeConnections: 42,
maxConnections: 100,
startedAt: Date.now() - 5_000,
};
const { status, body } = buildHealthResponse("ready", snap);
console.log(status, body.status); // 200 "ok"
```
## API Reference
- `HEALTH_PATHS` — frozen `{ health, ready, live }` path constants.
- `HealthKind` — `"health"|"ready"|"live"|null`.
- `classifyHealthRequest(url)` — match URL path → verb.
- `HealthSnapshot` — daemon state record (running, disposed, active,
max, startedAt).
- `HealthResponse` — JSON body shape.
- `buildHealthResponse(verb, snap)` — pure decision function.
- `handleHealth(req, res, snap)` — dispatch + write. Returns `true`
when the request was handled.
## Implementation Notes
- The classifier strips a single trailing slash and the entire
query-string portion so probes that hit `/health?ts=12345` or
`/health/` both match — the strict match would otherwise leak 404s
into production telemetry.
- The `live` verb is decoupled from `ready` so a node that has hit
`maxConnections` keeps reporting live (the process is fine) while
reporting not-ready (do not send more traffic) — the standard
K8s pattern.
- Returning `false` from `handleHealth` keeps the dispatcher composable:
the daemon's HTTP listener can fall through to the Gun relay when
the URL is not a health route.

View File

@@ -0,0 +1,95 @@
# `network-gundb-server/otel`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Owns the full OpenTelemetry instrumentation surface for the
`GunDBServer` daemon. Every counter, histogram, and gauge consumed by
the daemon is defined here and accessed through small typed helper
functions — direct `metrics.getMeter()` calls outside this module are
forbidden so dashboards never need to reference more than one set of
instrument names. Tracer spans wrap the public `start()` / `stop()`
boundaries so downstream OTel exporters can correlate lifecycle latency
with relay traffic.
The instruments here are also the **security-observability lever** —
the `gundb_server.invalid_messages_total{reason}` counter is the
primary signal a relay operator uses to detect a misbehaving client
flooding the namespace with malformed announces.
## Features
- `gundb_server.start` / `gundb_server.stop` span helpers.
- `gundb_server.connections_total{outcome}` counter (lifecycle).
- `gundb_server.messages_relayed_total{outcome}` counter (data plane).
- `gundb_server.invalid_messages_total{reason}` counter (security).
- `gundb_server.active_connections` up-down gauge.
- `gundb_server.start_duration_ms` histogram (lifecycle latency).
- Tiny adapter helpers (`recordConnection`, `recordMessageRelayed`,
`recordInvalidMessage`, `adjustActiveConnections`,
`recordStartDuration`).
## Usage: Basic Example
```ts
import {
recordConnection,
withStartSpan,
} from "@suaveplan/network-gundb-server/otel";
await withStartSpan(async () => {
// start logic
recordConnection("connected");
});
```
## Usage: Advanced Example
```ts
import {
adjustActiveConnections,
recordInvalidMessage,
recordMessageRelayed,
withStopSpan,
} from "@suaveplan/network-gundb-server/otel";
// Per-frame inbound:
recordInvalidMessage("malformed");
// Live peer-count tracking:
adjustActiveConnections(+1);
// ... peer detaches ...
adjustActiveConnections(-1);
// Per-relayed-frame outbound:
recordMessageRelayed("ok");
// Stop wrap:
await withStopSpan(async () => {
/* drain + close */
});
```
## API Reference
- `withStartSpan(fn)` / `withStopSpan(fn)` — async-span wrappers.
- `recordConnection(outcome)` — `"connected"|"disconnected"|"rejected"`.
- `recordMessageRelayed(outcome)` — `"ok"|"error"`.
- `recordInvalidMessage(reason)` — `InvalidWirePayloadReason`.
- `adjustActiveConnections(delta)` — `+1` on connect, `-1` on detach.
- `recordStartDuration(ms)` — observation in milliseconds.
## Implementation Notes
- The `reason` attribute on `gundb_server.invalid_messages_total` is
drawn from a closed enum (`InvalidWirePayloadReason`) so the metric
remains low-cardinality and safe for production telemetry.
- `adjustActiveConnections` uses `UpDownCounter` rather than a polled
`ObservableGauge` because the relay already knows the exact delta at
every transition — there is no underlying sampling concern.
- The package never registers an exporter — that is the consumer's
responsibility. We rely on the global `@opentelemetry/api` no-op
provider when no SDK is wired up, which keeps unit tests free of
exporter setup.

View File

@@ -0,0 +1,54 @@
# listen-http
## Purpose
Free function that binds a `node:http` server to `(port, host)` and translates the resulting Node EventEmitter signals into a Promise-rejecting error envelope keyed off `NET_GUNDB_SERVER_CODES`. Extracted out of `GunDBServer.#listen` so the generic-errno branch can be exercised in a unit test — Bun's `http.Server.listen` coerces every listen failure to `EADDRINUSE` through the public surface, making the non-EADDRINUSE branch otherwise unreachable from `GunDBServer.start()` under Bun.
## Features
- Maps `EADDRINUSE` to `NET_GUNDB_SERVER.PORT_IN_USE`.
- Maps every other errno to a generic `NET_GUNDB_SERVER.START_FAILED`.
- Maps a fired `AbortSignal` to `START_FAILED` with the dedicated `gundb_server.aborted` message key.
- Cleans up listeners on every resolution path (success, error, abort).
## Usage: Basic
```ts
import { createServer } from "node:http";
import { listenHttp } from "@suaveplan/network-gundb-server/server/listen-http";
const http = createServer();
await listenHttp(http, 8765, "127.0.0.1", undefined);
```
## Usage: Advanced
```ts
import { createServer } from "node:http";
import { listenHttp } from "@suaveplan/network-gundb-server/server/listen-http";
const http = createServer();
const ctrl = new AbortController();
queueMicrotask(() => ctrl.abort());
try {
await listenHttp(http, 8765, "127.0.0.1", ctrl.signal);
} catch (err) {
// err.code === NET_GUNDB_SERVER_CODES.START_FAILED
// err.messageKey === "gundb_server.aborted"
}
```
## API Reference
### `listenHttp(http, port, host, signal): Promise<void>`
- `http` — A `node:http.Server` instance.
- `port` — Port number (use `0` for an ephemeral port).
- `host` — Host name or IP address to bind.
- `signal` — Optional `AbortSignal` that, when fired, rejects the promise and `close()`s the server.
Resolves once the underlying `listening` event fires. Rejects with a `NetGunDBServerError` keyed appropriately.
## Implementation Notes
The non-EADDRINUSE branch is intentionally distinct from `EADDRINUSE` so callers (operators, dashboards) can distinguish "port collision" from "real fault" — the former is recoverable by retrying on another port, the latter usually indicates a misconfigured host or permission problem. The `gundb_server.aborted` message key on the abort path is separate from the listen-failure key so observability can count abort-driven rollbacks separately from listen failures.

View File

@@ -0,0 +1,114 @@
# `network-gundb-server/server`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`GunDBServer` is the server-tier daemon class that hosts a GunDB relay
peer plus inline health/readiness/liveness endpoints on a Node
`http.Server`. Browser-tier clients running
`@suaveplan/network-discovery-gundb` pass `http(s)://<host>:<port>/gun`
in their `peers[]` list and gossip signed peer-announces through the
relay. The daemon owns the HTTP listener, attaches a Gun instance via
an injected factory (production wires real `Gun`, tests bind a
deterministic fake), subscribes to the announce room, validates every
inbound record against the shared Zod schema, and tracks per-peer
connection state for observability.
The daemon is single-purpose — it does not handle authentication,
TLS termination, or rate-limiting. Those concerns live in front of the
daemon (reverse proxy / API gateway) or in the security-layer plugins
on the client side. The relay's sole defensive responsibility is to
drop structurally-invalid wire payloads so a misbehaving client cannot
poison the namespace.
## Features
- `start({ signal? })` and `stop()` lifecycle, idempotent on stop.
- Injectable `gunFactory` — never resolves `gun` at module-eval time.
- Inline `/health` / `/ready` / `/live` JSON probes on the same HTTP
listener as the relay.
- Structural Zod wire-payload validation on every inbound announce,
with counter-only rejections.
- Per-peer tracking + `peer-connected` / `peer-disconnected` events on
the optional `EventBusLike`.
- `maxConnections` cap with `connected/disconnected/rejected` counter
outcomes.
- AbortSignal threaded through `start()` for graceful cancellation.
- OTel spans + counters + gauge + histogram.
## Usage: Basic Example
```ts
import { GunDBServer } from "@suaveplan/network-gundb-server";
import Gun from "gun";
const server = new GunDBServer({
port: 8765,
gunFactory: (cfg) => Gun(cfg),
});
const { address, port } = await server.start();
console.log(`relay listening on ${address}:${port}`);
// ... later
await server.stop();
```
## Usage: Advanced Example
```ts
import { EventBus } from "@suaveplan/events";
import { GunDBServer, type GunDBServerEventMap } from "@suaveplan/network-gundb-server";
import Gun from "gun";
const bus = new EventBus<GunDBServerEventMap>();
bus.on("gundb_server:peer-connected", (e) =>
console.log(`peer joined: ${e.peerId}`)
);
const controller = new AbortController();
const server = new GunDBServer({
port: 8765,
host: "0.0.0.0",
peers: ["https://upstream.example.com/gun"],
storagePath: "./data/gun",
radisk: true,
maxConnections: 256,
room: "suaveplan-discovery",
bus,
gunFactory: (cfg) => Gun(cfg),
});
process.on("SIGTERM", () => controller.abort());
await server.start({ signal: controller.signal });
```
## API Reference
- `new GunDBServer(config)` — constructor, validates via Zod;
throws `INVALID_OPTIONS`.
- `start({ signal? })` — `Promise<{ address, port }>`. Throws
`START_FAILED` / `PORT_IN_USE` / `DISPOSED`.
- `stop()` — `Promise<void>`. Idempotent.
- `peers()` — readonly snapshot of every observed peer.
- `activeConnections` — getter, live count.
- `snapshot()` — `HealthSnapshot` for probe responses.
- `config` — `ResolvedGunDBServerConfig`.
## Implementation Notes
- The HTTP listener is wired with `handleHealth` first so probes never
reach Gun's WebSocket upgrade handler — this matters under load
because Gun's handler is synchronous-heavy.
- `stop()` flips `disposed` before draining so any in-flight inbound
handler call sees `running=false` and skips state mutation.
- `AbortSignal` is honoured _before_ the `listen()` syscall fires
(pre-aborted signal short-circuits) and _during_ listen (mid-flight
abort calls `close()` on the listener and rejects the start promise).
- The relay does not verify Ed25519 signatures on inbound announces —
that is the client's responsibility per the gossip security model.
Structural validation (presence + length of signature fields) is
enforced to prevent shape-poisoning attacks.
- `peer-connected` is emitted on the _first_ valid announce seen for
a `peerId`; subsequent announces refresh `lastSeen` and bump
`messagesRelayed`. There is no explicit "leave" semantics — peers
age out via the client's `peerTtlMs` mechanism.

View File

@@ -0,0 +1,100 @@
# `network-gundb-server/types`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Public type surface for `@suaveplan/network-gundb-server`. Owns the
`GunDBServerConfig` strict-options struct, the `GunFactory` /
`GunNodeLike` adapter interfaces injected by callers, the
`GunDBServerEventMap` of typed lifecycle events surfaced on the optional
`EventBusLike`, and the supporting per-peer / start-result / invalid-
payload-reason records. Every other module in this package consumes this
file — keeping it framework- and runtime-agnostic ensures the daemon
remains injection-friendly and the test fakes never reach for the real
`gun` module.
## Features
- Strict `GunDBServerConfig` with REQUIRED `gunFactory` (no implicit
default — production wires real `Gun`, tests bind a fake).
- `GunNodeLike` minimal structural surface (`get` / `map` / `on` /
`off`) covering every operation the relay performs.
- `GunFactoryConfig` enumerates exactly which constructor options the
daemon forwards (the rest must be wired inside the caller-supplied
factory).
- Typed `GunDBServerEventMap` for lifecycle pub-sub via
`@suaveplan/types` `EventBusLike`.
- `ResolvedGunDBServerConfig` mirrors the public struct with every
optional field defaulted — what the daemon actually carries
internally.
- `InvalidWirePayloadReason` enumerates the reason labels attached to
the `gundb_server.invalid_messages_total{reason}` counter.
## Usage: Basic Example
```ts
import type { GunDBServerConfig } from "@suaveplan/network-gundb-server";
import Gun from "gun";
const config: GunDBServerConfig = {
port: 8765,
gunFactory: (cfg) => Gun(cfg),
};
```
## Usage: Advanced Example
```ts
import type {
GunDBServerConfig,
GunDBServerEventMap,
} from "@suaveplan/network-gundb-server";
import { EventBus } from "@suaveplan/events";
import Gun from "gun";
const bus = new EventBus<GunDBServerEventMap>();
bus.on("gundb_server:started", (e) =>
console.log(`relay listening on ${e.address}:${e.port}`)
);
const config: GunDBServerConfig = {
port: 8765,
host: "127.0.0.1",
peers: ["https://upstream.example.com/gun"],
storagePath: "./data/gun",
radisk: true,
maxConnections: 256,
room: "suaveplan-discovery",
gunFactory: (cfg) => Gun(cfg),
bus,
};
```
## API Reference
- `GunDBServerConfig` — public, strict options struct.
- `ResolvedGunDBServerConfig` — internal, every optional field
defaulted.
- `GunFactory` / `GunFactoryConfig` / `GunNodeLike` — adapter triple.
- `GunDBServerStartResult` — `{ address, port }` returned by `start()`.
- `GunDBServerPeerInfo` — per-client connection record.
- `GunDBServerEventMap` — typed lifecycle event topics.
- `InvalidWirePayloadReason` — `"malformed" | "wrong-kind" |
"missing-signature" | "missing-public-key"`.
## Implementation Notes
- The `gunFactory` is a hard requirement — even the fake `Gun` used by
tests passes through it. This keeps the runtime dependency optional
(`peerDependenciesMeta.gun.optional = true`) and makes the daemon
trivially substitutable for in-process integration tests.
- The relay does **not** verify Ed25519 signatures on incoming
announces — signature verification is the client's responsibility per
the gossip security model. The relay validates the _structural_
presence of `signature` + `signaturePublicKey` so a malformed
announce cannot poison the namespace, but the signature itself is
passed through unchanged.
- `GunDBServerEventMap` is shaped to interoperate with both
`@suaveplan/events` and any other `EventBusLike` adapter.

View File

@@ -0,0 +1,85 @@
# `network-gundb-server/validation`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Wire-payload validation for inbound `peer-announce` records arriving on
the GunDB relay namespace. The relay enforces the same structural
contract the browser-tier `@suaveplan/network-discovery-gundb` client
enforces on outbound frames — Zod-validated discriminated union with
mandatory `kind`, `signature`, and `signaturePublicKey` fields — so a
single misbehaving client cannot poison the namespace with malformed
records that every other client would then have to filter on read.
The relay deliberately does **not** verify the Ed25519 signature
itself: cryptographic verification is a client responsibility per the
gossip security model. The relay only confirms that the payload is
shape-correct and carries the signature material, then passes it
through unchanged so authorized clients can verify it locally.
## Features
- Structural Zod validation against the shared
`GunDBPeerAnnounceSchema` exported by the companion client package
(single source of truth — server and client cannot drift).
- Distinct rejection reasons for low-cardinality OTel labelling:
`"malformed"`, `"wrong-kind"`, `"missing-signature"`,
`"missing-public-key"`.
- Silent rejection: every rejection increments the
`gundb_server.invalid_messages_total{reason}` counter; the function
returns a discriminated union and never throws.
- Strips Gun's internal `_` metadata key before validation so the
payload shape is invariant under the relay's wire envelope.
## Usage: Basic Example
```ts
import { validateInboundAnnounce } from "@suaveplan/network-gundb-server";
const result = validateInboundAnnounce(rawRecord);
if (result.ok) {
relayForward(result.payload);
}
```
## Usage: Advanced Example
```ts
import { validateInboundAnnounce } from "@suaveplan/network-gundb-server";
gun.get("suaveplan-discovery")
.map()
.on((data) => {
const result = validateInboundAnnounce(data);
if (!result.ok) {
// Counter already incremented in the helper.
log.debug("dropped malformed announce", { reason: result.reason });
return;
}
forwardToSubscribers(result.payload);
});
```
## API Reference
- `WirePayloadValidation` — discriminated union
`{ ok: true; payload } | { ok: false; reason }`.
- `validateInboundAnnounce(raw)` — structural validator. Side-effect:
increments `gundb_server.invalid_messages_total{reason}` on
rejection. Never throws.
## Implementation Notes
- The function performs a fast structural pre-check before invoking
the full Zod schema so the `reason` label is informative — a Zod
failure alone cannot distinguish "missing signature" from
"malformed JSON".
- Pulling the Zod schema from the companion client package guarantees
the server and client agree on wire format — a breaking change to
the schema breaks both packages' tests simultaneously, which is the
correct failure mode.
- Returning a discriminated union (rather than throwing) is
load-bearing: the relay's inbound handler must remain crash-proof
even when an adversarial peer floods garbage records.

View File

@@ -0,0 +1,129 @@
# @suaveplan/network-libp2p-server - Server-tier libp2p relay daemon (bootstrap + circuit-relay-v2) paired with @suaveplan/network-transport-libp2p — Node-only HTTP/libp2p host with structural Zod option validation, AbortSignal lifecycle, health/ready/live endpoints, OTel observability, and injectable libp2p factory for real-fake integration tests.
## Summary
Server-tier libp2p relay daemon paired with
[`@suaveplan/network-transport-libp2p`](../network-transport-libp2p).
Hosts a libp2p node configured as a bootstrap + circuit-relay-v2 server
alongside an inline HTTP listener exposing `/health`, `/ready`, `/live`
verbs.
<!-- 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/health.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [health](./src/health/health.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 -->
- [server](./src/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [health-listener](./src/server/health-listener.md) — Wraps the asynchronous `http.Server.listen(port, host)` flow with the listener-attach / `EADDRINUSE` classification / `…
- [peer-events](./src/server/peer-events.md) — Wires the `peer:connect` and `peer:disconnect` event listeners onto a `Libp2pServerNodeLike` and bridges them onto the…
- [server](./src/server/server.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/health.md) — <!-- begin:auto-docs --> _(no submodules)_- [health](./src/health/health.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)_- [server](./src/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [health-listener](./src/server/health-listener.md) — Wraps the asynchronous `http.Server.listen(port, host)` flow with the listener-attach / `EADDRINUSE` classification / `…
- [peer-events](./src/server/peer-events.md) — Wires the `peer:connect` and `peer:disconnect` event listeners onto a `Libp2pServerNodeLike` and bridges them onto the…
- [server](./src/server/server.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/health.md) — <!-- begin:auto-docs --> _(no submodules)_- [health](./src/health/health.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)_- [server](./src/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [health-listener](./src/server/health-listener.md) — Wraps the asynchronous `http.Server.listen(port, host)` flow with the listener-attach / `EADDRINUSE` classification / `…
- [peer-events](./src/server/peer-events.md) — Wires the `peer:connect` and `peer:disconnect` event listeners onto a `Libp2pServerNodeLike` and bridges them onto the…
- [server](./src/server/server.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/health.md) — <!-- begin:auto-docs --> _(no submodules)_- [health](./src/health/health.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)_- [server](./src/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_- [health-listener](./src/server/health-listener.md) — Wraps the asynchronous `http.Server.listen(port, host)` flow with the listener-attach / `EADDRINUSE` classification / `…
- [peer-events](./src/server/peer-events.md) — Wires the `peer:connect` and `peer:disconnect` event listeners onto a `Libp2pServerNodeLike` and bridges them onto the…
- [server](./src/server/server.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/health.md) — <!-- begin:auto-docs --> _(no submodules)_ - [health](./src/health/health.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)_- [server](./src/server/server.md) — <!-- begin:auto-docs --> _(no submodules)_ - [health-listener](./src/server/health-listener.md) — Wraps the asynchronous `http.Server.listen(port, host)` flow with the listener-attach / `EADDRINUSE` classification / `…
- [peer-events](./src/server/peer-events.md) — Wires the `peer:connect` and `peer:disconnect` event listeners onto a `Libp2pServerNodeLike` and bridges them onto the…
- [server](./src/server/server.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)_
## Run the daemon
```ts
import { createLibp2p } from "libp2p";
import { tcp } from "@libp2p/tcp";
import { webSockets } from "@libp2p/websockets";
import { noise } from "@chainsafe/libp2p-noise";
import { yamux } from "@chainsafe/libp2p-yamux";
import { identify } from "@libp2p/identify";
import { circuitRelayServer } from "@libp2p/circuit-relay-v2";
import { bootstrap } from "@libp2p/bootstrap";
import { Libp2pServer } from "@suaveplan/network-libp2p-server";
const server = new Libp2pServer({
listen: ["/ip4/0.0.0.0/tcp/9090", "/ip4/0.0.0.0/tcp/9091/ws"],
bootstrap: ["/dnsaddr/bootstrap.libp2p.io/p2p/QmNnoo..."],
autoRelay: { enabled: true, maxConnections: 500 },
healthPort: 9099,
libp2pFactory: async (cfg) =>
createLibp2p({
addresses: { listen: [...cfg.listen] },
transports: [tcp(), webSockets()],
connectionEncrypters: [noise()],
streamMuxers: [yamux()],
peerDiscovery:
cfg.bootstrap.length > 0
? [bootstrap({ list: [...cfg.bootstrap] })]
: [],
services: {
identify: identify(),
relay: circuitRelayServer({
reservations: {
maxReservations: cfg.autoRelay.maxConnections,
},
}),
},
connectionManager: { maxConnections: cfg.maxConnections },
}),
});
const { peerId, multiaddrs } = await server.start();
console.log(`libp2p relay ${peerId} on:`);
for (const a of multiaddrs) console.log(` ${a}`);
// Graceful shutdown
process.on("SIGTERM", () => server.stop());
```
## Test environment
Tests inject a deterministic `FakeLibp2pServerNode` so the native
transitive deps of the real `libp2p` package never enter the test
surface — the daemon never resolves `libp2p` at module evaluation time
and the integration suite boots a real `Libp2pServer` inside the test
process.
```ts
import { Libp2pServer } from "@suaveplan/network-libp2p-server";
import {
FakeLibp2pHub,
makeFakeServerFactory,
} from "./tests/setup/fake-libp2p-server.js";
const hub = new FakeLibp2pHub();
const { factory } = makeFakeServerFactory(hub);
const server = new Libp2pServer({
libp2pFactory: factory,
listen: ["/ip4/0.0.0.0/tcp/0"],
healthPort: 0,
});
await server.start();
// ... drive the daemon, then:
await server.stop();
```
## Acceptance gates
- `bunx turbo lint typecheck test --filter=@suaveplan/network-libp2p-server`
- `bunx turbo test:coverage --filter=@suaveplan/network-libp2p-server` → 100/100/100/100
- `bun run docs:check --filter=@suaveplan/network-libp2p-server`
## API
See per-module `.md` docs:
- [`src/server/server.md`](src/server/server.md) — `Libp2pServer` daemon
- [`src/server/peer-events.md`](src/server/peer-events.md) — peer
connect/disconnect bridging
- [`src/server/health-listener.md`](src/server/health-listener.md) —
inline-HTTP `listenHttp` helper
- [`src/health/health.md`](src/health/health.md) — `/health` / `/ready`
/ `/live` verb pipeline
- [`src/otel/otel.md`](src/otel/otel.md) — OTel instrumentation surface
- [`src/errors/errors.md`](src/errors/errors.md) — error registry
- [`src/types/types.md`](src/types/types.md) — public type contracts

View File

@@ -0,0 +1,89 @@
# `errors/errors.ts` — error registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the `NET_LIBP2P_SERVER` code registry plus `NetLibp2pServerError`
class used throughout the libp2p relay daemon. Every public boundary in
`@suaveplan/network-libp2p-server` throws this typed error on failure so
callers can branch deterministically on `error.code` without parsing
messages or stack traces.
## Features
- Frozen `NET_LIBP2P_SERVER_CODES` registry — six codes covering option
validation, lifecycle, listener bind failures, and circuit-relay-v2
setup.
- `NetLibp2pServerError` extends `SuaveplanError<NetLibp2pServerErrorCode>`
so the cause-chain traversal, structured logging adapters, and OTel
span-event integrations already wired into the suaveplan error
framework all apply automatically.
- `NetLibp2pServerErrorCode` derived type exposed for exhaustive
switches in callers.
## Usage: basic example
```ts
import {
NET_LIBP2P_SERVER_CODES,
NetLibp2pServerError,
} from "@suaveplan/network-libp2p-server";
throw new NetLibp2pServerError(
NET_LIBP2P_SERVER_CODES.INVALID_OPTIONS,
"libp2p_server.invalid-config"
);
```
## Usage: advanced example
```ts
try {
await server.start();
} catch (e) {
if (e instanceof NetLibp2pServerError) {
switch (e.code) {
case NET_LIBP2P_SERVER_CODES.LISTEN_FAILED:
// health port in use — retry with another port
break;
case NET_LIBP2P_SERVER_CODES.START_FAILED:
// libp2p node failed to start
break;
default:
throw e;
}
}
}
```
## API reference
- `NET_LIBP2P_SERVER_CODES` — `Readonly<{ INVALID_OPTIONS, START_FAILED,
LISTEN_FAILED, DISPOSED, NOT_RUNNING, RELAY_FAILED }>`.
- `NetLibp2pServerError(code, messageKey, options?)` — constructor.
Sets `name = "NetLibp2pServerError"`. Forwards `options` (cause,
context) to `SuaveplanError`.
## Implementation notes
The registry is intentionally narrow. The relay daemon is not in the
business of forwarding application-level libp2p stream errors — those
belong to the consumer's protocol-level error model. The codes here
cover only daemon-lifecycle failures so dashboards and SLO panels can
key on a tight, stable surface.
`INVALID_OPTIONS` is the only code thrown synchronously from the
constructor; the remaining codes surface from `start()` and `stop()`.
`LISTEN_FAILED` covers every bind failure on the inline health HTTP
server, including `EADDRINUSE`. `START_FAILED` covers libp2p factory
failures, `node.start()` rejections, double-start attempts, and signal
aborts; aborts deliberately reuse the same code so dashboards see a
single bucket. `DISPOSED` is the post-`stop()` state guard; `NOT_RUNNING`
is reserved for future operations that are only valid mid-lifecycle.
`RELAY_FAILED` is reserved for the circuit-relay-v2 stream-setup
failure path and is exported now so consumers' switch-statements stay
exhaustive when the relay-stream-wiring story expands. All codes are
frozen at definition time by `defineCodes` so accidental mutation at
runtime is impossible.

View File

@@ -0,0 +1,72 @@
# `health/health.ts` — inline health endpoints
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Exposes the `/health`, `/ready`, `/live` verb pipeline used by the
libp2p relay daemon's inline HTTP listener. Allows orchestrators
(Kubernetes, ECS, Nomad, docker-compose healthchecks) to probe the
daemon without pulling in `@suaveplan/health-check` as a hard dep.
Returns deterministic JSON bodies so dashboards can key on the
`status` field as well as inspect `activePeers` / `peerId` for at-a
glance triage.
## Features
- Three pure verbs: `live` (process alive, hasn't `stop()`'d), `ready`
(running with spare capacity), `health` (overall posture).
- Pure `classifyHealthRequest(url)` returning `"health" | "ready" |
"live" | null` — strips querystrings and trailing slashes.
- Pure `buildHealthResponse(verb, snap)` returning `{ status, body }`
for unit testability and reuse outside the inline HTTP server.
- `handleHealth(req, res, snap)` dispatcher returning a boolean so the
daemon can fall through to non-health routes deterministically.
## Usage: basic example
```ts
import { handleHealth } from "@suaveplan/network-libp2p-server/health";
import { createServer } from "node:http";
createServer((req, res) => {
if (handleHealth(req, res, server.snapshot())) return;
res.writeHead(404).end();
}).listen(9091);
```
## Usage: advanced example
```ts
import {
buildHealthResponse,
classifyHealthRequest,
HEALTH_PATHS,
} from "@suaveplan/network-libp2p-server/health";
// Custom routing — wire health into an existing fastify/express tree
const verb = classifyHealthRequest(req.url);
if (verb !== null) {
const { status, body } = buildHealthResponse(verb, snapshot);
reply.code(status).send(body);
}
```
## API reference
- `HEALTH_PATHS` — `Readonly<{ health: "/health", ready: "/ready",
live: "/live" }>`.
- `classifyHealthRequest(url) => HealthKind`.
- `buildHealthResponse(verb, snap) => { status, body }`.
- `handleHealth(req, res, snap) => boolean` — returns `true` if the
request was handled and the response was written.
## Implementation notes
The `live` verb intentionally returns 200 even when the libp2p node has
not yet finished `start()` — process liveness is independent of node
readiness so orchestrators don't churn restarts during slow bootstrap.
`ready` returns 503 with `status: "starting"` until `start()` resolves.
`health` returns 503 with `status: "degraded"` when at-cap so load
balancers shed traffic before connection rejections occur.

View File

@@ -0,0 +1,75 @@
# `otel/otel.ts` — OTel instrumentation surface
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Centralises every OpenTelemetry instrument the libp2p relay daemon
emits. The daemon never touches `@opentelemetry/api` directly — it
always calls through the helpers in this module so the instrument names
are owned in exactly one place and dashboards stay stable across
refactors. Operators wire an OTel SDK once at process start; the
suaveplan no-op default applies otherwise so the package stays
zero-overhead by default.
## Features
- Two named spans: `libp2p_server.start`, `libp2p_server.stop`.
- One counter `libp2p_server.connections_total{outcome}` covering
`connected`/`disconnected`/`rejected` per-peer events.
- One counter `libp2p_server.relayed_streams_total` for circuit-relay-v2
stream brokering events.
- One up-down counter `libp2p_server.active_peers` tracking live peer
count.
- One histogram `libp2p_server.start_duration_ms` recording `start()`
wall-clock duration.
## Usage: basic example
```ts
import {
recordConnection,
adjustActivePeers,
} from "@suaveplan/network-libp2p-server/otel";
recordConnection("connected");
adjustActivePeers(+1);
```
## Usage: advanced example
```ts
import {
withStartSpan,
recordStartDuration,
recordRelayedStream,
} from "@suaveplan/network-libp2p-server/otel";
await withStartSpan(async (span) => {
span.setAttribute("listen", listen.join(","));
const t0 = Date.now();
await node.start();
recordStartDuration(Date.now() - t0);
});
// On a relayed stream:
recordRelayedStream();
```
## API reference
- `withStartSpan<T>(fn) => Promise<T>` — wraps `fn` in an active span.
- `withStopSpan<T>(fn) => Promise<T>` — wraps `fn` in an active span.
- `recordConnection(outcome)` — `connected | disconnected | rejected`.
- `recordRelayedStream()` — incremented per brokered relay stream.
- `adjustActivePeers(delta)` — `+1` or `-1` on connection events.
- `recordStartDuration(ms)` — non-negative ms.
## Implementation notes
The `recordStartDuration` helper clamps negative values to zero so
caller-side `Date.now()` clock skew never corrupts the histogram. All
labels are low-cardinality enums; no peer-id or multiaddr appears in
any metric attribute so we don't blow up the metrics backend cardinality
budget when the relay is busy.

View File

@@ -0,0 +1,62 @@
# `server/health-listener.ts` — inline health HTTP bind helper
## Purpose
Wraps the asynchronous `http.Server.listen(port, host)` flow with the
listener-attach / `EADDRINUSE` classification / `AbortSignal` plumbing
the libp2p relay daemon needs around its inline health HTTP listener.
Extracted as a sibling module to keep `server.ts` under the 300-line
hard cap mandated by CLAUDE.md while preserving the deterministic
error-code mapping callers depend on.
## Features
- Resolves once the `listening` event fires.
- Rejects with `NET_LIBP2P_SERVER.LISTEN_FAILED` + `messageKey =
"libp2p_server.port-in-use"` when the bind fails with `EADDRINUSE`.
- Rejects with `NET_LIBP2P_SERVER.LISTEN_FAILED` + `messageKey =
"libp2p_server.listen-failed"` for every other errno.
- Rejects with `NET_LIBP2P_SERVER.START_FAILED` + `messageKey =
"libp2p_server.aborted"` when a supplied `AbortSignal` fires before
the `listening` event, after closing the half-bound socket
best-effort.
- Cleans up its own listeners on every exit path so the `http.Server`
doesn't leak handlers when the daemon retries with a different port.
## Usage: basic example
```ts
import { listenHttp } from "@suaveplan/network-libp2p-server";
import { createServer } from "node:http";
const http = createServer();
await listenHttp(http, 9091, "127.0.0.1", undefined);
```
## Usage: advanced example
```ts
const ctrl = new AbortController();
queueMicrotask(() => ctrl.abort());
try {
await listenHttp(http, 0, "0.0.0.0", ctrl.signal);
} catch (e) {
if (e instanceof NetLibp2pServerError) {
// Match `code` to discriminate listen vs abort failure paths.
}
}
```
## API reference
- `listenHttp(http, port, host, signal?) => Promise<void>` — resolves
on `listening`, rejects per the mapping above.
## Implementation notes
The helper is responsible only for the listen lifecycle of the inline
health HTTP server — the libp2p node lifecycle is owned by the daemon
itself, so caller-side rollback (stopping the libp2p node on a failed
health-port bind) lives in `server.ts`. All listener attachments use
`once` so reject and resolve paths cannot fire twice and the `http`
instance never retains stale callbacks after this Promise settles.

View File

@@ -0,0 +1,70 @@
# `server/peer-events.ts` — peer connect/disconnect bridging
## Purpose
Wires the `peer:connect` and `peer:disconnect` event listeners onto a
`Libp2pServerNodeLike` and bridges them onto the daemon's internal peer
set, the OTel `libp2p_server.connections_total` counter / `active_peers`
gauge, and the optional `EventBusLike` lifecycle surface. Extracted from
`server.ts` to keep that module under the 300-line hard cap mandated by
CLAUDE.md.
## Features
- `wirePeerEvents(node, ctx)` — attaches both listeners and returns the
bound functions for later removal.
- `unwirePeerEvents(node, binding)` — removes previously-bound listeners
on graceful stop. Tolerates `undefined` for both arguments so the
daemon can call it unconditionally on `stop()`.
- Enforces `maxConnections` at the wire boundary — when a connect event
would push the peer set past the cap, the listener increments the
`connections_total{outcome="rejected"}` counter and bails without
recording the peer.
## Usage: basic example
```ts
import {
wirePeerEvents,
unwirePeerEvents,
} from "@suaveplan/network-libp2p-server";
const binding = wirePeerEvents(node, {
peers,
maxConnections: 1000,
emit: (topic, payload) => bus.emit(topic, payload),
});
// ... later, on shutdown
unwirePeerEvents(node, binding);
```
## Usage: advanced example
```ts
const ctx: PeerEventsContext = {
peers: new Set<string>(),
maxConnections: 100,
emit: (topic, payload) => {
if (topic === "libp2p_server:peer-connected") {
console.log("peer joined", (payload as { peerId: string }).peerId);
}
bus.emit(topic, payload);
},
};
const binding = wirePeerEvents(node, ctx);
```
## API reference
- `wirePeerEvents(node, ctx) => PeerEventsBinding` — `ctx` is `{ peers,
maxConnections, emit }`. Returns `{ onConnect, onDisconnect }`.
- `unwirePeerEvents(node?, binding?) => void` — idempotent.
## Implementation notes
The connect handler is a no-op when the peer id is already tracked so
duplicate `peer:connect` events from libp2p's internal dial fan-out
don't double-count. The disconnect handler is symmetric — only known
peers cause counter decrement. Both listeners derive the peer id via
`String(event.detail.toString())` so any structurally compatible peer-id
object satisfies the binding without leaking the concrete `PeerId` type.

View File

@@ -0,0 +1,105 @@
# `server/server.ts` — `Libp2pServer` daemon
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Hosts a libp2p server node — typically configured as a bootstrap +
circuit-relay-v2 relay — paired with an inline HTTP listener exposing
the `/health` / `/ready` / `/live` verbs. Browser-tier clients dial the
libp2p node via `@suaveplan/network-transport-libp2p`. The daemon is
designed for in-process integration testing as well as production
deployment: every dependency on the real `libp2p` runtime flows through
the injected `libp2pFactory`, so test environments can wire a
deterministic `FakeLibp2pServerNode` without pulling native transitive
deps into the test surface.
## Features
- Idempotent lifecycle: `start({ signal? })` resolves with
`{ peerId, multiaddrs, healthAddress, healthPort }`; `stop()` is safe
to call repeatedly and drains tracked peers via the OTel surface.
- Pre-aborted signal: `start({ signal })` with an already-aborted
signal short-circuits to `START_FAILED` before any side effects.
- Required injectable `libp2pFactory` — the package never resolves the
`libp2p` module at evaluation time.
- Inline health HTTP listener (`/health`, `/ready`, `/live`) bound to
`healthHost:healthPort`. Falls through to a plaintext `"libp2p relay"`
marker on non-health routes.
- Auto-relay configuration forwarded to the factory via
`Libp2pServerFactoryConfig.autoRelay`.
- Strict Zod validation of `Libp2pServerConfig` at the constructor
boundary — invalid options throw `INVALID_OPTIONS` with the Zod
issue list attached for diagnostics.
- Peer-event bridging (`peer:connect` / `peer:disconnect`) into the
internal peer set, OTel counters/gauge, and optional `EventBusLike`.
- Hand-off helper `observeRelayedStream(peerId)` so consumers wiring a
custom circuit-relay-v2 stream handler in the factory body keep the
`libp2p_server.relayed_streams_total` counter accurate.
## Usage: basic example
```ts
import { Libp2pServer } from "@suaveplan/network-libp2p-server";
const server = new Libp2pServer({
libp2pFactory: async (cfg) => createLibp2p({
addresses: { listen: cfg.listen },
// ... compose transports/encrypters/muxers/services here
}),
listen: ["/ip4/0.0.0.0/tcp/0", "/ip4/0.0.0.0/tcp/0/ws"],
healthPort: 9091,
});
const { peerId, multiaddrs } = await server.start();
console.log(`relay ${peerId} on ${multiaddrs.join(", ")}`);
// later
await server.stop();
```
## Usage: advanced example
```ts
const ctrl = new AbortController();
const server = new Libp2pServer({
libp2pFactory: myFactory,
listen: ["/ip4/0.0.0.0/tcp/9090"],
bootstrap: ["/dnsaddr/bootstrap.libp2p.io"],
autoRelay: { enabled: true, maxConnections: 500 },
maxConnections: 5000,
protocolId: "/myapp/1.0.0",
healthPort: 9091,
bus,
});
await server.start({ signal: ctrl.signal });
// On a circuit-relay-v2 stream brokered between two clients:
server.observeRelayedStream("12D3KooW...client");
```
## API reference
- `new Libp2pServer(config)` — validates `config`; throws
`INVALID_OPTIONS` on Zod failure or missing `libp2pFactory`.
- `server.start(options?) => Promise<Libp2pServerStartResult>` — throws
`DISPOSED`, `START_FAILED`, or `LISTEN_FAILED`.
- `server.stop() => Promise<void>` — idempotent.
- `server.observeRelayedStream(peerId)` — increments OTel counter and
emits `libp2p_server:relay-stream`.
- `server.snapshot() => HealthSnapshot` — for health-probe responses.
- `server.peers()` / `server.activePeers` — observed peers.
- `server.config` — resolved config snapshot.
- `server.healthAddress` / `server.healthPort` — bound HTTP listener
inspectors.
## Implementation notes
The libp2p node lifecycle and the inline-HTTP lifecycle are wound
independently: a successful libp2p start followed by a failed HTTP bind
rolls the libp2p node back via `stop()` so the daemon never leaks a
half-running node. `stop()` reverses the wiring in the opposite order
(unwire listeners, stop node, drain peers, close HTTP) and is wrapped
in a try-around-`node.stop()` so half-started instances cannot poison
the disposed state. The `#runStart` helper exists purely to keep the
public `start()` method short enough for the discipline-metrics cap on
this file (≤300 LOC).

View File

@@ -0,0 +1,75 @@
# `types/types.ts` — public type contracts
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines every TypeScript contract `@suaveplan/network-libp2p-server`
exposes at its public boundary. The daemon never depends on the concrete
`Libp2p` interface from the upstream `libp2p` package — only on the
structural `Libp2pServerNodeLike` shape declared here. This keeps the
relay's runtime cost zero on the test surface (no native transitive
deps pulled in) while still preserving full parity with the production
libp2p hosting story.
## Features
- `Libp2pServerNodeLike` structural interface — lifecycle + peer enum +
event listeners.
- `Libp2pServerFactory` / `Libp2pServerFactoryConfig` for injectable
factory wiring (production binds `createLibp2p({...})`, tests bind a
deterministic fake).
- Typed event map (`Libp2pServerEventMap`) covering start, stop,
peer-connect, peer-disconnect, and circuit-relay-v2 stream emissions.
- `Libp2pServerConfig` — strict callable boundary; required fields are
declared non-optional so consumers cannot silently fall through to an
implicit default.
- `ResolvedLibp2pServerConfig` — fully-defaulted snapshot returned via
the daemon's `config` getter for introspection.
## Usage: basic example
```ts
import type { Libp2pServerConfig } from "@suaveplan/network-libp2p-server";
const config: Libp2pServerConfig = {
libp2pFactory: async (cfg) => createLibp2p({ /* compose */ }),
listen: ["/ip4/0.0.0.0/tcp/0", "/ip4/0.0.0.0/tcp/0/ws"],
healthPort: 0,
};
```
## Usage: advanced example
```ts
const config: Libp2pServerConfig = {
libp2pFactory: myFactory,
listen: ["/ip4/0.0.0.0/tcp/9090"],
bootstrap: ["/dnsaddr/bootstrap.libp2p.io"],
autoRelay: { enabled: true, maxConnections: 200 },
maxConnections: 5000,
protocolId: "/myapp/1.0.0",
healthPort: 9091,
healthHost: "127.0.0.1",
bus: eventBus,
};
```
## API reference
- `Libp2pServerNodeLike` — structural libp2p node surface.
- `Libp2pServerFactory(config) => Promise<Libp2pServerNodeLike>` —
factory callable.
- `Libp2pServerConfig` — required `libp2pFactory`, `listen`,
`healthPort`; optional `bootstrap`, `autoRelay`, `maxConnections`,
`protocolId`, `healthHost`, `bus`.
- `Libp2pServerStartResult` — `{ peerId, multiaddrs, healthAddress,
healthPort }`.
## Implementation notes
Types-only module (excluded from coverage via `types.ts` filename). The
sibling `types.schemas.ts` carries the Zod boundary schemas; that file
is also excluded from coverage. Concrete enforcement (factory presence,
listen non-empty) lives in the constructor in `server/server.ts`.

View File

@@ -0,0 +1,22 @@
# @suaveplan/network-plugin-assignment - Mesh auto-assignment and load balancing: capacity tracker, assignment-scoring engine, rebalance planner, admission controller
## Summary
Mesh auto-assignment and load balancing: live `MeshCapacityTracker`, weighted-multi-criteria `AssignmentEngine`, imbalance-detecting `MeshBalancer` planner, and admission-gating `AdmissionController`.
<!-- begin:auto-docs -->
## Documentation tree
- [balancer](./src/balancer/mesh-balancer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [mesh-balancer](./src/balancer/mesh-balancer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [controller](./src/controller/admission-controller.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [admission-controller](./src/controller/admission-controller.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [engine](./src/engine/assignment-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [assignment-engine](./src/engine/assignment-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [tracker](./src/tracker/mesh-capacity-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [mesh-capacity-tracker](./src/tracker/mesh-capacity-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the network-plugin-assignment plugin. Three codes cover the surface: `NO_ELIGIBLE_ME…
<!-- end:auto-docs --> - [mesh-balancer](./src/balancer/mesh-balancer.md) — <!-- begin:auto-docs --> _(no submodules)_- [controller](./src/controller/admission-controller.md) — <!-- begin:auto-docs --> _(no submodules)_- [admission-controller](./src/controller/admission-controller.md) — <!-- begin:auto-docs --> _(no submodules)_- [engine](./src/engine/assignment-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [assignment-engine](./src/engine/assignment-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [tracker](./src/tracker/mesh-capacity-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_- [mesh-capacity-tracker](./src/tracker/mesh-capacity-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the network-plugin-assignment plugin. Three codes cover the surface: `NO_ELIGIBLE_ME…
- [mesh-balancer](./src/balancer/mesh-balancer.md) — <!-- begin:auto-docs --> _(no submodules)_- [controller](./src/controller/admission-controller.md) — <!-- begin:auto-docs --> _(no submodules)_- [admission-controller](./src/controller/admission-controller.md) — <!-- begin:auto-docs --> _(no submodules)_- [engine](./src/engine/assignment-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [assignment-engine](./src/engine/assignment-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [tracker](./src/tracker/mesh-capacity-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_- [mesh-capacity-tracker](./src/tracker/mesh-capacity-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the network-plugin-assignment plugin. Three codes cover the surface: `NO_ELIGIBLE_ME…
- [mesh-balancer](./src/balancer/mesh-balancer.md) — <!-- begin:auto-docs --> _(no submodules)_- [controller](./src/controller/admission-controller.md) — <!-- begin:auto-docs --> _(no submodules)_- [admission-controller](./src/controller/admission-controller.md) — <!-- begin:auto-docs --> _(no submodules)_- [engine](./src/engine/assignment-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [assignment-engine](./src/engine/assignment-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [tracker](./src/tracker/mesh-capacity-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_- [mesh-capacity-tracker](./src/tracker/mesh-capacity-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the network-plugin-assignment plugin. Three codes cover the surface: `NO_ELIGIBLE_ME…
- [mesh-balancer](./src/balancer/mesh-balancer.md) — <!-- begin:auto-docs --> _(no submodules)_- [controller](./src/controller/admission-controller.md) — <!-- begin:auto-docs --> _(no submodules)_ - [admission-controller](./src/controller/admission-controller.md) — <!-- begin:auto-docs --> _(no submodules)_- [engine](./src/engine/assignment-engine.md) — <!-- begin:auto-docs --> _(no submodules)_ - [assignment-engine](./src/engine/assignment-engine.md) — <!-- begin:auto-docs --> _(no submodules)_- [tracker](./src/tracker/mesh-capacity-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_ - [mesh-capacity-tracker](./src/tracker/mesh-capacity-tracker.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen registry of error codes for the network-plugin-assignment plugin. Three codes cover the surface: `NO_ELIGIBLE_ME…

View File

@@ -0,0 +1,78 @@
# MeshBalancer
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Detects mesh imbalances and proposes rebalance moves. The plan is advisory — the consumer is responsible for actually executing each move via `network-plugin-migration`. Imbalance is measured as the spread between the highest- and lowest-utilised meshes; below `imbalanceThreshold` the plan is empty.
## Features
- `computeImbalance()` returns the current `[0, 1]` spread between the busiest and least-busy meshes.
- `buildPlan(peerSelector)` returns a structured `RebalancePlan` with capped move count.
- Configurable `imbalanceThreshold` (default `0.3`) and `maxMovesPerPlan` (default `10`).
- Pluggable `peerSelector` — the consumer decides which peers can move from the overloaded mesh.
- Plan includes both pre and post-move imbalance estimates for observability.
## Usage: Basic Example
```ts
import {
MeshBalancer,
MeshCapacityTracker,
} from "@suaveplan/network-plugin-assignment";
const tracker = new MeshCapacityTracker();
const balancer = new MeshBalancer({ tracker });
const plan = balancer.buildPlan((overloadedMeshId) => [
"alice",
"bob",
]);
for (const move of plan.moves) {
await migrationCoordinator.initiateMigration({
peerId: move.peerId,
fromMeshId: move.fromMeshId,
toMeshId: move.toMeshId,
reason: move.reason,
priority: "graceful",
});
}
```
## Usage: Advanced Example
```ts
import {
MeshBalancer,
MeshCapacityTracker,
} from "@suaveplan/network-plugin-assignment";
const balancer = new MeshBalancer({
tracker,
imbalanceThreshold: 0.5,
maxMovesPerPlan: 3,
});
if (balancer.computeImbalance() >= 0.4) {
const plan = balancer.buildPlan((meshId) =>
registry.peersInMesh(meshId).map((p) => p.peerId)
);
metrics.gauge("mesh.imbalance.before", plan.imbalanceBefore);
metrics.gauge("mesh.imbalance.after", plan.imbalanceAfter);
}
```
## API Reference
- `interface RebalanceMove { peerId, fromMeshId, toMeshId, reason }`.
- `interface RebalancePlan { moves, imbalanceBefore, imbalanceAfter }`.
- `interface MeshBalancerConfig { tracker, imbalanceThreshold?, maxMovesPerPlan? }`.
- `computeImbalance(): number`.
- `buildPlan(peerSelector): RebalancePlan`.
## Implementation Notes
The balancer picks the single most-overloaded mesh and the single least-loaded mesh per plan rather than computing an N-way redistribution; this keeps each plan small enough to execute without congesting the migration plugin and lets repeated plan iterations converge on equilibrium across cycles. The peer selector is a closure rather than a tracked attribute because peer eligibility depends on mesh-membership data we deliberately do not duplicate inside the balancer — that lives in `network-plugin-registry`.

View File

@@ -0,0 +1,89 @@
# AdmissionController
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Gatekeeper that runs the `AssignmentEngine`, checks capacity against the `MeshCapacityTracker`, applies a pluggable `AdmissionPolicy`, and either records the placement or throws a typed error. Centralising admission here means there's one entry point for "place this peer somewhere" and the engine, balancer, and observability all see the same outcome.
## Features
- Pluggable `AdmissionPolicy` for non-capacity gates (capability mismatch, regional rules, operator quotas).
- Mesh enumeration via injected `meshes()` callback so the controller never has to keep a stale local copy.
- Three failure modes with distinct codes: `ADMISSION_DENIED` (policy), `NO_ELIGIBLE_MESH` (engine), `MESH_FULL` (tracker race).
- `release(peer, meshId)` for clean disconnect semantics.
- On success, records the join into the tracker so the next call sees the updated capacity.
## Usage: Basic Example
```ts
import {
AdmissionController,
AssignmentEngine,
MeshCapacityTracker,
} from "@suaveplan/network-plugin-assignment";
const tracker = new MeshCapacityTracker();
const engine = new AssignmentEngine({ tracker });
const controller = new AdmissionController({
engine,
tracker,
meshes: () => [
{ meshId: "mesh-A", tierFloor: 0 },
{ meshId: "mesh-B", tierFloor: 0 },
],
});
const result = controller.admit(peer);
```
## Usage: Advanced Example
```ts
import {
ASSIGN_CODES,
AdmissionController,
AssignError,
} from "@suaveplan/network-plugin-assignment";
const controller = new AdmissionController({
engine,
tracker,
meshes: () => fleetSnapshot,
policy: (peer, mesh) => {
if (peer.capabilities === "CONSTRAINED" && mesh.tierFloor < 2) {
return false;
}
return true;
},
});
try {
const placement = controller.admit(peer);
bus.emit("peer:placed", placement);
} catch (err) {
if (err instanceof AssignError) {
if (err.code === ASSIGN_CODES.MESH_FULL) {
await rebalanceAndRetry(peer);
return;
}
if (err.code === ASSIGN_CODES.ADMISSION_DENIED) {
return enterStandby(peer);
}
}
throw err;
}
```
## API Reference
- `type AdmissionPolicy = (peer, descriptor) => boolean`.
- `interface AdmissionControllerConfig { engine, tracker, meshes, policy? }`.
- `interface AdmissionResult { peerId, meshId, score }`.
- `admit(peer): AdmissionResult` — throws `ADMISSION_DENIED` / `NO_ELIGIBLE_MESH` / `MESH_FULL`.
- `release(peer, meshId)` — updates the tracker.
## Implementation Notes
`admit` does the policy check _before_ calling the engine so that policy violations are detected before we burn cycles scoring meshes the peer can't join anyway. The capacity check happens _after_ the engine picks a winner, because another concurrent admission could race the available-slots count down to zero between when the engine scored and when we record. Callers that need stricter ordering should serialize admissions through their own queue.

View File

@@ -0,0 +1,93 @@
# AssignmentEngine
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Scores candidate meshes for a placement decision along five axes (latency, capacity, load, region, tier-fit), combines them into a composite score, and returns the best mesh that still has room. Decoupling the scoring from the admission and balancer means consumers can run "what-if" scoring without committing to a placement, and the rebalancer can use the same scoring to evaluate moves.
## Features
- Five scoring axes, each normalised to `[0, 1]`.
- Configurable `AssignmentWeights` (defaults `0.35 / 0.25 / 0.2 / 0.1 / 0.1`).
- Configurable `minScoreThreshold` for "this mesh is good enough" decisions.
- Tier-fit pre-filter: peers below `tierFloor` are excluded from scoring entirely.
- Deterministic lex-tiebreak on `meshId` so independent observers reach the same winner.
- Throws `NO_ELIGIBLE_MESH` for empty candidate sets and below-threshold scores.
## Usage: Basic Example
```ts
import {
AssignmentEngine,
MeshCapacityTracker,
} from "@suaveplan/network-plugin-assignment";
const tracker = new MeshCapacityTracker();
tracker.recordMesh("mesh-A", 32);
tracker.recordMesh("mesh-B", 32);
const engine = new AssignmentEngine({ tracker });
const best = engine.bestFor(
{
peerId: "alice",
latencyMap: new Map([
["mesh-A", 50],
["mesh-B", 200],
]),
tierEligibility: 1,
capabilities: "STANDARD",
},
[
{ meshId: "mesh-A", tierFloor: 0 },
{ meshId: "mesh-B", tierFloor: 0 },
]
);
```
## Usage: Advanced Example
```ts
import {
AssignmentEngine,
MeshCapacityTracker,
} from "@suaveplan/network-plugin-assignment";
const engine = new AssignmentEngine({
tracker,
weights: {
latency: 0.5,
capacity: 0.3,
load: 0.1,
region: 0.1,
tierFit: 0,
},
minScoreThreshold: 0.4,
});
const scored = engine.score(peer, candidates);
for (const s of scored) {
console.log(s.meshId, s.score, s.factors);
}
try {
const best = engine.bestFor(peer, candidates);
} catch (err) {
// handle NO_ELIGIBLE_MESH
}
```
## API Reference
- `interface PeerInfo { peerId, latencyMap, region?, tierEligibility, capabilities }`.
- `interface MeshDescriptor { meshId, region?, tierFloor }`.
- `interface AssignmentWeights { latency, capacity, load, region, tierFit }`.
- `interface AssignmentScore { meshId, score, factors }`.
- `interface AssignmentEngineConfig { tracker, weights?, minScoreThreshold? }`.
- `score(peer, candidates): readonly AssignmentScore[]`.
- `bestFor(peer, candidates): AssignmentScore` — throws `NO_ELIGIBLE_MESH` on empty / below-threshold.
## Implementation Notes
The latency axis uses `1 / (1 + ms)` rather than min-max normalisation so a peer that has only one latency reading still produces a sensible score (no degenerate divide-by-zero). Capacity and load are deliberately separate axes — capacity is "how many free slots" while load is "how full"; they correlate but a tiny mesh and a huge mesh at the same utilisation should rank differently for placement, which the dual-axis lets us tune. Tier-fit is enforced as a _filter_ before scoring, then re-asserted as a binary axis, so we never score a peer against a mesh it cannot reach.

View File

@@ -0,0 +1,62 @@
# ASSIGN_CODES
## Purpose
Frozen registry of error codes for the network-plugin-assignment plugin. Three codes cover the surface: `NO_ELIGIBLE_MESH` when the engine cannot score any mesh above the threshold, `MESH_FULL` when the chosen mesh's capacity is exhausted at the moment of admission, and `ADMISSION_DENIED` when the admission controller refuses for a non-capacity reason (capability mismatch, regional policy, etc.). The companion `AssignError` extends `SuaveplanError<AssignErrorCode>` so consumers pattern-match on `code` rather than parse messages.
## Features
- Three frozen codes registered under the `NET_ASSIGN` namespace.
- `AssignError extends SuaveplanError<AssignErrorCode>`.
- `name: "AssignError"` on every instance — useful for log filters and dashboard alerts.
- `cause` and `context` propagation via `SuaveplanErrorOptions`.
- Codes are stable across versions; the registry only ever grows, never shrinks.
## Usage: Basic Example
```ts
import {
ASSIGN_CODES,
AssignError,
} from "@suaveplan/network-plugin-assignment";
throw new AssignError(
ASSIGN_CODES.NO_ELIGIBLE_MESH,
"assignment.no-eligible-mesh",
{ context: { peerId: "alice", knownMeshes: 4 } }
);
```
## Usage: Advanced Example
```ts
import {
ASSIGN_CODES,
AssignError,
} from "@suaveplan/network-plugin-assignment";
try {
const meshId = await admissionController.admit(peerInfo);
} catch (err) {
if (err instanceof AssignError) {
if (err.code === ASSIGN_CODES.MESH_FULL) {
await rebalanceAndRetry(peerInfo);
return;
}
if (err.code === ASSIGN_CODES.NO_ELIGIBLE_MESH) {
return enterStandbyMode(peerInfo);
}
}
throw err;
}
```
## API Reference
- `ASSIGN_CODES` — frozen `{ NO_ELIGIBLE_MESH, MESH_FULL, ADMISSION_DENIED }`.
- `type AssignErrorCode` — union of registered code values.
- `class AssignError extends SuaveplanError<AssignErrorCode>` — typed throwable.
## Implementation Notes
`MESH_FULL` is intentionally distinct from `NO_ELIGIBLE_MESH` because they imply different recovery profiles: the former says "the engine picked a mesh but admission lost the race — try again", while the latter says "no mesh in the fleet can host this peer at all — escalate". `ADMISSION_DENIED` covers everything else (capability mismatch, regional policy violation, operator quota cap) so callers don't have to multiplex unrelated codes onto the same handler. The package barrel side-effect-imports `./error-codes.js` so subpath consumers register the codes too.

View File

@@ -0,0 +1,59 @@
# MeshCapacityTracker
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Tracks `currentPeers / maxPeers` per mesh in real time. Push-driven: the consumer wires `peer:joined`/`peer:left` from `network-plugin-registry` into `recordJoin`/`recordLeave` and the tracker fires `mesh:full` / `mesh:available` listeners on the threshold transitions. Centralising the capacity view here means the assignment engine, balancer, and admission controller all read the same numbers.
## Features
- `recordMesh(meshId, maxPeers?)` upserts mesh capacity (default `defaultMaxPeers`, configurable; falls back to 64).
- `recordJoin(meshId, peerId)` and `recordLeave(meshId, peerId)` keep the live set per mesh.
- `getUtilization(meshId)` returns the `[0, 1]` ratio.
- `getAvailableSlots(meshId)` for hot-path scheduling.
- Listener-driven `mesh:full` / `mesh:available` transitions; unsubscribe handle returned from `on()`.
- `listMeshes()` and `getMaxPeers()` exposed for observability.
## Usage: Basic Example
```ts
import { MeshCapacityTracker } from "@suaveplan/network-plugin-assignment";
const tracker = new MeshCapacityTracker();
tracker.recordMesh("mesh-A", 32);
tracker.recordJoin("mesh-A", "alice");
tracker.recordJoin("mesh-A", "bob");
const u = tracker.getUtilization("mesh-A"); // 2 / 32 = 0.0625
```
## Usage: Advanced Example
```ts
import { MeshCapacityTracker } from "@suaveplan/network-plugin-assignment";
const tracker = new MeshCapacityTracker({ defaultMaxPeers: 16 });
const off = tracker.on((event, meshId) => {
if (event === "mesh:full") {
rebalancer.scheduleRebalance(meshId);
}
});
bus.on("peer:joined", (e) => tracker.recordJoin(e.meshId, e.peerId));
bus.on("peer:left", (e) => tracker.recordLeave(e.meshId, e.peerId));
```
## API Reference
- `interface MeshCapacityTrackerConfig { defaultMaxPeers? }`.
- `recordMesh(meshId, maxPeers?)` / `recordJoin(meshId, peerId)` / `recordLeave(meshId, peerId)`.
- `getUtilization(meshId): number`.
- `getAvailableSlots(meshId): number`.
- `listMeshes(): readonly string[]`.
- `getMaxPeers(meshId): number`.
- `on(listener) → () => void`.
## Implementation Notes
State is kept in two maps — the per-mesh peer set and a parallel `fullState` boolean — so `recheckFull` can observe a transition by comparing the previous and current values atomically. We only fire listeners on edge transitions (`true → false` or `false → true`), not on every record, to avoid storming subscribers when traffic is steady. `recordLeave` against an unknown mesh is a no-op rather than an error because clean-shutdown ordering can lose `recordMesh` events.

View File

@@ -0,0 +1,28 @@
# @suaveplan/network-plugin-audit-mesh - Tamper-evident, cryptographically signed, append-only audit log chain for the Suaveplan mesh: SignedLog, LogReplicator (R-copy with tier-aware target selection), LogRetriever (range queries + pagination), IntegrityChecker, ConflictResolver, LogCompaction
## Summary
Tamper-evident, cryptographically signed, append-only audit log chain for the Suaveplan mesh. `SignedLog` produces hash-chained `LogEntry` records keyed by monotonic sequence with caller-injected `SignFn` (Ed25519 typically) so each entry's signature covers the deterministic SHA-256 of the prior entry plus the current payload; `LogReplicator` ships entries to `R` sibling hosts (default 2) with tier-aware target selection and synchronous-for-CRITICAL / async-for-NORMAL semantics; `LogRetriever` answers paginated `getLogs(meshId, fromSeq, toSeq)` range queries; `IntegrityChecker` walks the chain, detecting gaps / forks / tampered entries and emitting `audit:corruption-detected`; `ConflictResolver` reconciles divergent chains via longest-chain-wins with deterministic lex tiebreak; `LogCompaction` snapshots state into signed checkpoints to reduce reconciliation volume after server reconnect. Wrapped in a single `AuditMeshPlugin` satisfying the `NetworkPlugin` lifecycle.
<!-- begin:auto-docs -->
## Documentation tree
- [compaction](./src/compaction/log-compaction.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [log-compaction](./src/compaction/log-compaction.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [conflict](./src/conflict/conflict-resolver.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [conflict-resolver](./src/conflict/conflict-resolver.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [integrity](./src/integrity/integrity-checker.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [integrity-checker](./src/integrity/integrity-checker.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [log](./src/log/signed-log.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [signed-log](./src/log/signed-log.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [plugin](./src/plugin/audit-mesh-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [audit-mesh-plugin](./src/plugin/audit-mesh-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [replicator](./src/replicator/log-replicator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [log-replicator](./src/replicator/log-replicator.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [retriever](./src/retriever/log-retriever.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [log-retriever](./src/retriever/log-retriever.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [error-codes](./src/error-codes.md) — Frozen `NET_AUDIT` error registry for `@suaveplan/network-plugin-audit-mesh`. Every failure path the audit subsystem ca…
<!-- end:auto-docs --> - [log-compaction](./src/compaction/log-compaction.md) — <!-- begin:auto-docs --> _(no submodules)_- [conflict](./src/conflict/conflict-resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [conflict-resolver](./src/conflict/conflict-resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [integrity](./src/integrity/integrity-checker.md) — <!-- begin:auto-docs --> _(no submodules)_- [integrity-checker](./src/integrity/integrity-checker.md) — <!-- begin:auto-docs --> _(no submodules)_- [log](./src/log/signed-log.md) — <!-- begin:auto-docs --> _(no submodules)_- [signed-log](./src/log/signed-log.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/audit-mesh-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [audit-mesh-plugin](./src/plugin/audit-mesh-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [replicator](./src/replicator/log-replicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [log-replicator](./src/replicator/log-replicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [retriever](./src/retriever/log-retriever.md) — <!-- begin:auto-docs --> _(no submodules)_- [log-retriever](./src/retriever/log-retriever.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen `NET_AUDIT` error registry for `@suaveplan/network-plugin-audit-mesh`. Every failure path the audit subsystem ca…
- [log-compaction](./src/compaction/log-compaction.md) — <!-- begin:auto-docs --> _(no submodules)_- [conflict](./src/conflict/conflict-resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [conflict-resolver](./src/conflict/conflict-resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [integrity](./src/integrity/integrity-checker.md) — <!-- begin:auto-docs --> _(no submodules)_- [integrity-checker](./src/integrity/integrity-checker.md) — <!-- begin:auto-docs --> _(no submodules)_- [log](./src/log/signed-log.md) — <!-- begin:auto-docs --> _(no submodules)_- [signed-log](./src/log/signed-log.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/audit-mesh-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [audit-mesh-plugin](./src/plugin/audit-mesh-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [replicator](./src/replicator/log-replicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [log-replicator](./src/replicator/log-replicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [retriever](./src/retriever/log-retriever.md) — <!-- begin:auto-docs --> _(no submodules)_- [log-retriever](./src/retriever/log-retriever.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen `NET_AUDIT` error registry for `@suaveplan/network-plugin-audit-mesh`. Every failure path the audit subsystem ca…
- [log-compaction](./src/compaction/log-compaction.md) — <!-- begin:auto-docs --> _(no submodules)_- [conflict](./src/conflict/conflict-resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [conflict-resolver](./src/conflict/conflict-resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [integrity](./src/integrity/integrity-checker.md) — <!-- begin:auto-docs --> _(no submodules)_- [integrity-checker](./src/integrity/integrity-checker.md) — <!-- begin:auto-docs --> _(no submodules)_- [log](./src/log/signed-log.md) — <!-- begin:auto-docs --> _(no submodules)_- [signed-log](./src/log/signed-log.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/audit-mesh-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [audit-mesh-plugin](./src/plugin/audit-mesh-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [replicator](./src/replicator/log-replicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [log-replicator](./src/replicator/log-replicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [retriever](./src/retriever/log-retriever.md) — <!-- begin:auto-docs --> _(no submodules)_- [log-retriever](./src/retriever/log-retriever.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen `NET_AUDIT` error registry for `@suaveplan/network-plugin-audit-mesh`. Every failure path the audit subsystem ca…
- [log-compaction](./src/compaction/log-compaction.md) — <!-- begin:auto-docs --> _(no submodules)_- [conflict](./src/conflict/conflict-resolver.md) — <!-- begin:auto-docs --> _(no submodules)_ - [conflict-resolver](./src/conflict/conflict-resolver.md) — <!-- begin:auto-docs --> _(no submodules)_- [integrity](./src/integrity/integrity-checker.md) — <!-- begin:auto-docs --> _(no submodules)_ - [integrity-checker](./src/integrity/integrity-checker.md) — <!-- begin:auto-docs --> _(no submodules)_- [log](./src/log/signed-log.md) — <!-- begin:auto-docs --> _(no submodules)_ - [signed-log](./src/log/signed-log.md) — <!-- begin:auto-docs --> _(no submodules)_- [plugin](./src/plugin/audit-mesh-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_ - [audit-mesh-plugin](./src/plugin/audit-mesh-plugin.md) — <!-- begin:auto-docs --> _(no submodules)_- [replicator](./src/replicator/log-replicator.md) — <!-- begin:auto-docs --> _(no submodules)_ - [log-replicator](./src/replicator/log-replicator.md) — <!-- begin:auto-docs --> _(no submodules)_- [retriever](./src/retriever/log-retriever.md) — <!-- begin:auto-docs --> _(no submodules)_ - [log-retriever](./src/retriever/log-retriever.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/error-codes.md) — Frozen `NET_AUDIT` error registry for `@suaveplan/network-plugin-audit-mesh`. Every failure path the audit subsystem ca…

View File

@@ -0,0 +1,75 @@
# LogCompaction
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Periodically compacts a long signed audit chain into a single signed `LogCheckpoint`, dramatically reducing the volume of entries the server has to replay after a long outage. A checkpoint is a snapshot of mesh/session state at a particular sequence number, signed by the same host (and the same key) that authored the surrounding chain — so consumers can verify the snapshot in isolation without walking back to the suaveplan entry.
The compaction module is deliberately stateless. It does not hold the chain or the snapshots; it just produces signed checkpoints from the inputs you give it and provides a `truncate` helper to discard already-checkpointed entries. This keeps the storage policy decoupled from the cryptographic policy.
## Features
- Builds a `LogCheckpoint` over `(seq, timestamp, meshId, hostId, stateHash, entryHashAtSeq)` and signs the canonical bytes.
- Two anchors per checkpoint — `entryHashAtSeq` ties the checkpoint to a specific entry in the chain (so the server can verify it against its retrieval), and `stateHash` ties it to the application-level snapshot.
- `verify(checkpoint, verifyFn)` round-trips the canonical bytes and asks the injected verifier whether the signature is valid.
- `truncate(entries, checkpoint)` returns the entries with `seq > checkpoint.seq` so callers can prune their hot log to just the post-checkpoint suffix.
- Empty entry list and missing-anchor errors raise `NET_AUDIT.CHECKPOINT_INVALID` with explanatory `error.context`.
## Usage: Basic Example
```ts
import { ed25519 } from "@suaveplan/crypto-curve";
import { LogCompaction } from "@suaveplan/network-plugin-audit-mesh";
const compactor = new LogCompaction({
meshId: "mesh-a",
hostId: "host-1",
sign: (bytes) => ed25519.sign(bytes, hostPrivateKey),
});
const checkpoint = await compactor.createCheckpoint({
atSeq: 1024,
entries: log.entries(),
stateSnapshot: { peers: snapshot.peers, sessions: snapshot.sessions },
});
const remaining = compactor.truncate(log.entries(), checkpoint);
console.log(remaining.length); // post-1024 only
```
## Usage: Advanced Example
```ts
import { ed25519 } from "@suaveplan/crypto-curve";
import { LogCompaction } from "@suaveplan/network-plugin-audit-mesh";
const compactor = new LogCompaction({
meshId: "mesh-a",
hostId: "host-1",
clock: () => 1_700_000_000_000,
sign: async (bytes) => await hardwareSigner.sign(bytes),
});
const checkpoint = await compactor.createCheckpoint({ atSeq, entries, stateSnapshot });
const valid = await compactor.verify(
checkpoint,
(bytes, sig, hostId) => ed25519.verify(sig, bytes, hostKeys.get(hostId)!)
);
if (!valid) throw new Error("checkpoint signature failed");
```
## API Reference
- `LogCompaction(config)` — `config: { meshId; hostId; sign: SignFn; clock?: () => number }`.
- `createCheckpoint(input)` — `input: { atSeq; entries; stateSnapshot }`. Returns `Promise<LogCheckpoint>`. Raises `NET_AUDIT.CHECKPOINT_INVALID` on empty entries or missing anchor.
- `verify(checkpoint, verify)` — `verify: CheckpointVerifyFn`. Returns `Promise<boolean>`.
- `truncate(entries, checkpoint)` — returns the entries whose `seq` is strictly greater than `checkpoint.seq`.
- `checkpointSigningBytes(value)` — exported helper that returns the canonical bytes a verifier should hash and signature-check. Useful when the caller already has a checkpoint and just wants to validate it without instantiating `LogCompaction`.
## Implementation Notes
- The canonical bytes for a checkpoint are the same sorted-key JSON encoding used for entries; we keep two helpers (`entryHashBytes` for entries, `checkpointSigningBytes` for checkpoints) so that one cannot be accidentally substituted for the other.
- `stateHash` is computed over the caller-supplied `stateSnapshot` value via the same canonicalize routine, so two snapshots with semantically equal contents but different key insertion order produce identical hashes.
- `truncate` returns a fresh array so the caller can keep using the original entries for archival.

View File

@@ -0,0 +1,63 @@
# ConflictResolver
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Picks the canonical chain when the server pulls audit logs from multiple Tier 0 hosts and finds disagreement. The rule is two-step and deterministic: (1) longest chain wins; (2) on equal length, the chain whose `lastHash` sorts lexicographically smallest wins. The deterministic tiebreak matters because the reconciler is itself replicated — two reconciler instances seeing the same divergent set must reach the same conclusion or the mesh keeps thrashing on every server reconnection.
The resolver is pure: input goes in, an outcome comes out, no side effects, no time, no randomness. That makes it the safest possible thing to call inside a property test or replay scenario.
## Features
- Longest-chain-wins primary rule.
- Lexicographic-tiebreak secondary rule on `lastHash`, with a final stable sort key on `hostId` so two chains that somehow share both length and `lastHash` still produce a deterministic answer.
- Returns the full winning chain, the winning host, the discriminating `reason` (`"longest-chain"` vs `"lex-tiebreak"`), and the list of `losers` so callers can rollback those hosts via the mesh.
- Empty input throws `NET_AUDIT.CONFLICT_UNRESOLVED` rather than returning a sentinel — there is no meaningful "no chains" winner.
## Usage: Basic Example
```ts
import { ConflictResolver } from "@suaveplan/network-plugin-audit-mesh";
const resolver = new ConflictResolver();
const result = resolver.resolve([
{ hostId: "host-1", entries: chain1, lastHash: "abc..." },
{ hostId: "host-2", entries: chain2, lastHash: "def..." },
]);
console.log(result.winningHostId, result.reason, result.losers);
```
## Usage: Advanced Example
```ts
import {
ConflictResolver,
type ConflictResolution,
} from "@suaveplan/network-plugin-audit-mesh";
function reconcile(
pulled: ReadonlyMap<string, LogEntry[]>
): ConflictResolution {
const chains = Array.from(pulled.entries()).map(([hostId, entries]) => ({
hostId,
entries,
lastHash: entries.length === 0
? ""
: entryDigestHex(entries[entries.length - 1]!),
}));
return new ConflictResolver().resolve(chains);
}
```
## API Reference
- `ConflictResolver` — stateless class; the no-arg constructor is intentional so all callers get the same rule set.
- `resolve(chains)` — `chains: readonly DivergentChain[]`. Returns `ConflictResolution`. Throws `NET_AUDIT.CONFLICT_UNRESOLVED` if `chains.length === 0`.
## Implementation Notes
- The "longest" reduce uses `>` rather than `>=` so the first chain wins on equal length; the `tied` filter then catches the equal-length case explicitly. This avoids a subtle bug where a strict-greater reduce would leak the iteration order into the result.
- `hostId` is the final sort key because two chains with identical content (and therefore identical `lastHash`) can in theory exist when the same content was independently produced by two hosts; this is rare but not impossible, and we want the resolver to be total.
- The losers list is produced by filtering the input by `winningHostId`, not by negating the tiebreak result, so a five-way conflict produces exactly four losers regardless of how deep the tiebreak chain went.

View File

@@ -0,0 +1,53 @@
# error-codes
## Purpose
Frozen `NET_AUDIT` error registry for `@suaveplan/network-plugin-audit-mesh`. Every failure path the audit subsystem can surface — a missing sequence number in the chain, a tampered entry, a checkpoint that fails signature verification, a server reconciliation that cannot decide between divergent chains — is mapped onto one of these stable codes. Codes are emitted by `defineCodes("NET_AUDIT", [...] as const)` so the registry is locked at module load time and consumers downstream (logger redaction maps, i18n catalogs, the OpenSpec conformance checker) can rely on the set never silently growing.
The registry exists in its own module so the `index.ts` barrel can do `import "./error-codes.js"` first to register the codes via side effect before any subsystem starts referencing them.
## Features
- Eight codes covering chain integrity (`LOG_GAP`, `CHAIN_TAMPERED`, `SIGNATURE_INVALID`), sequencing (`INVALID_SEQ`), replication failure (`REPLICATION_FAILED`), checkpoint failures (`CHECKPOINT_INVALID`), conflict resolution failures (`CONFLICT_UNRESOLVED`), and range-query input validation (`RANGE_INVALID`).
- Sibling `NetworkAuditError` extends `SuaveplanError<NetworkAuditErrorCode>` so consumers can write `instanceof NetworkAuditError` checks and access `error.code` with full type narrowing.
- The registry is frozen by `defineCodes`; attempting to mutate it throws at runtime, and TypeScript's `as const` narrowing prevents adding new keys without a code change.
## Usage: Basic Example
```ts
import {
NETWORK_AUDIT_CODES,
NetworkAuditError,
} from "@suaveplan/network-plugin-audit-mesh";
throw new NetworkAuditError(
NETWORK_AUDIT_CODES.LOG_GAP,
"audit.log_gap",
{ context: { meshId: "mesh-a", missingSeq: 7 } }
);
```
## Usage: Advanced Example
```ts
import {
NetworkAuditError,
type NetworkAuditErrorCode,
} from "@suaveplan/network-plugin-audit-mesh";
function classify(err: unknown): NetworkAuditErrorCode | null {
return err instanceof NetworkAuditError ? err.code : null;
}
```
## API Reference
- `NETWORK_AUDIT_CODES` — frozen object with eight keys: `LOG_GAP`, `CHAIN_TAMPERED`, `SIGNATURE_INVALID`, `REPLICATION_FAILED`, `CHECKPOINT_INVALID`, `CONFLICT_UNRESOLVED`, `INVALID_SEQ`, `RANGE_INVALID`. Values are `"NET_AUDIT.<KEY>"` strings.
- `NetworkAuditErrorCode` — string-literal union of the registry values.
- `NetworkAuditError` — error class extending `SuaveplanError<NetworkAuditErrorCode>`. Constructor: `(code, messageKey, options?)`.
## Implementation Notes
- The list grows only by adding to the array literal in `error-codes.ts`; there is no programmatic `register()` API by design — the `as const` plus `defineCodes` arrangement is the contract.
- Every code in the registry must be referenced from `src/` somewhere — typically as the argument to `new NetworkAuditError(...)` (thrown classes), but `LOG_GAP` / `CHAIN_TAMPERED` / `SIGNATURE_INVALID` are **report-only** codes carried on `CorruptionReport.code` rather than thrown. The `CORRUPTION_KIND_TO_CODE` map in `integrity-checker.ts` is the single source of truth wiring those three codes onto the integrity-report stream. The conformance gate enforces that the codes are referenced at least once so dead codes cannot accumulate.
- Code messages use dotted i18n keys (e.g. `audit.log_gap`) so the logger's i18n adapter can resolve them at format time without coupling this package to a specific locale catalog.

View File

@@ -0,0 +1,74 @@
# IntegrityChecker
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Walks a sequence of `LogEntry` records and decides whether the chain is intact: every `seq` is present (no gaps), every `prevHash` matches the SHA-256 of the previous entry's canonical bytes (no tamper, no fork), and — when an injected `verifySignature` is supplied — every `hostSignature` is a valid Ed25519 signature over those same bytes (no tamper of fields under signature). Any failure produces a `CorruptionReport` and emits `audit:corruption-detected` on the optional bus, so observability adapters can dashboard corruption rates.
The checker is the only module in audit-mesh that owns the canonical chain-walk algorithm. Both the integrity self-check on the originating host and the server-side cross-host validation step in `ConflictResolver` go through this module — keeping the rules in one place keeps the system honest.
## Features
- Detects **gaps** — when `entry.seq !== expectedSeq` (skipped sequences in a delivered chunk). Reports with `code: NETWORK_AUDIT_CODES.LOG_GAP`.
- Detects **forks** — when an entry's `prevHash` is empty but the chain has continued (a re-suaveplan attempt) or differs from what the chain so far implies. Reports with `code: NETWORK_AUDIT_CODES.CHAIN_TAMPERED`.
- Detects **tampered prev-hash** — when an entry's `prevHash` doesn't match the actual SHA-256 of the previous entry. Reports with `code: NETWORK_AUDIT_CODES.CHAIN_TAMPERED`.
- Detects **tampered signatures** — when the injected verifier returns false, with the exact `seq` and `hostId` in the report. Reports with `code: NETWORK_AUDIT_CODES.SIGNATURE_INVALID`.
- Each `CorruptionReport` carries both a granular `kind` (string discriminant for debugging) and a stable `code` (registry-backed identifier from `NETWORK_AUDIT_CODES`) so downstream consumers (logger, alerting, server reconciler) can match on the registry code without coupling to the human-readable string.
- Optional `verifySignature` (skip if signing keys aren't available — e.g. integrity check on a single-host log) keeps the checker reusable in test contexts.
- Optional `bus` emits `audit:corruption-detected` per finding for fan-out to alerting/observability.
## Usage: Basic Example
```ts
import {
SignedLog,
IntegrityChecker,
} from "@suaveplan/network-plugin-audit-mesh";
const checker = new IntegrityChecker();
const report = await checker.check(log.entries());
if (!report.ok) console.warn(report.issues);
```
## Usage: Advanced Example
```ts
import { ed25519 } from "@suaveplan/crypto-curve";
import { IntegrityChecker } from "@suaveplan/network-plugin-audit-mesh";
const hostKeys = new Map<string, Uint8Array>([
["host-1", host1Public],
["host-2", host2Public],
]);
const checker = new IntegrityChecker({
verifySignature: (bytes, signature, hostId) => {
const pub = hostKeys.get(hostId);
if (pub === undefined) return false;
return ed25519.verify(signature, bytes, pub);
},
bus: { emit: (topic, payload) => observability.publish(topic, payload) },
});
const report = await checker.check(retriever.getLogs(query).entries);
metrics.record("audit.integrity.issues", report.issues.length);
```
## API Reference
- `IntegrityChecker(config?)` — `config: { verifySignature?: SigVerifyFn; bus?: IntegrityBus }`.
- `check(entries)` — returns `Promise<IntegrityReport>`. Walks entries in iteration order; the first entry's expected `prevHash` is `""`.
- `CorruptionKind` — `"gap" | "fork" | "tampered-prev-hash" | "tampered-signature"`.
- `CorruptionReport` — `{ kind; code: NetworkAuditErrorCode; seq; hostId; detail? }`.
- `CORRUPTION_KIND_TO_CODE` — frozen `Record<CorruptionKind, NetworkAuditErrorCode>` mapping each kind to its registry code; the integrity checker uses this so the kind→code mapping has a single source of truth.
- `IntegrityReport` — `{ ok; inspected; issues }`.
- `SigVerifyFn` — `(bytes, signature, hostId) => boolean | Promise<boolean>`.
- `IntegrityBus` — `emit("audit:corruption-detected", report)` shape.
## Implementation Notes
- The checker continues past a discovered issue rather than throwing on first error — a single retrieval pass surfaces every problem at once, so the operator does not need to re-run after each fix.
- "Fork at first entry" (a re-suaveplan when the chain already has history) is a special case of `prevHash` mismatch and is reported as `kind: "fork"` to distinguish it from a mid-chain tamper. All other prevHash mismatches are `kind: "tampered-prev-hash"`.
- Signature verification is async-aware; even though `ed25519.verify` is synchronous, the verifier signature accepts `Promise<boolean>` so a hardware-backed verifier can plug in without forcing the caller to wrap.

View File

@@ -0,0 +1,86 @@
# SignedLog
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Append-only, hash-chained, cryptographically signed audit log per `(meshId, hostId)` pair. Every entry's `prevHash` is the hex-encoded SHA-256 of the previous entry's canonical bytes, and every entry's `hostSignature` covers those same canonical bytes — so flipping any field (including `seq`, `timestamp`, or `prevHash`) breaks both the chain and the signature.
The class deliberately decouples the signing primitive: callers inject a `SignFn` (typically `(bytes) => ed25519.sign(bytes, hostPrivateKey)`) so the log itself depends only on `@suaveplan/crypto-hash`. This keeps audit-mesh independent of any specific signature engine and makes property-based tests trivial — substitute a deterministic test signer and the chain becomes byte-stable.
## Features
- Monotonic sequence (`seq` starts at 0 and increments per append, never resets except via a fresh `SignedLog` instance).
- Hash-chained — first entry has `prevHash = ""`; subsequent entries have `prevHash = hex(sha256(canonicalBytes(prev)))`.
- Canonical serialization uses sorted-key JSON over the entry-without-signature; non-finite numbers serialize as `null` to keep bytes deterministic.
- Injectable `SignFn` may return either a `Uint8Array` synchronously or a `Promise<Uint8Array>` — `append` awaits the result, so async signers (e.g. hardware-backed Ed25519) work transparently.
- Injectable clock (defaults to `Date.now`) keeps tests deterministic.
- `getBySeq` raises `NET_AUDIT.INVALID_SEQ` on out-of-range lookups.
## Usage: Basic Example
```ts
import { ed25519 } from "@suaveplan/crypto-curve";
import { SignedLog } from "@suaveplan/network-plugin-audit-mesh";
const privateKey = ed25519.randomPrivateKey();
const log = new SignedLog({
meshId: "mesh-a",
hostId: "host-1",
sign: (bytes) => ed25519.sign(bytes, privateKey),
});
const entry = await log.append({
eventType: "host-elected",
sessionId: null,
severity: "critical",
payload: { electedHost: "host-1" },
});
console.log(entry.seq, entry.prevHash, entry.hostSignature.length);
```
## Usage: Advanced Example
```ts
import {
SignedLog,
entryDigestHex,
} from "@suaveplan/network-plugin-audit-mesh";
const log = new SignedLog({
meshId: "mesh-a",
hostId: "host-1",
clock: () => 1_700_000_000_000,
sign: async (bytes) => await hardwareSigner.sign(bytes),
});
await log.append({
eventType: "session-created",
sessionId: "sess-1",
severity: "critical",
payload: { peers: ["peer-a", "peer-b"] },
});
const digest = entryDigestHex(log.entries()[0]!);
console.log(`tip digest = ${digest}; chain size = ${log.size()}`);
```
## API Reference
- `SignedLog(config)` — constructs an empty log. `config: { meshId; hostId; sign: SignFn; clock?: () => number }`.
- `append(input)` — appends one entry. `input: { eventType; sessionId; severity; payload }`. Returns the persisted `LogEntry` after signing.
- `entries()` — returns a defensive shallow copy of all entries in append order.
- `size()` — number of entries appended.
- `tipHash()` — hex SHA-256 of the most recent entry, or `""` for an empty log.
- `getBySeq(seq)` — returns the entry at sequence `seq` or throws `NET_AUDIT.INVALID_SEQ`.
- `entryHashBytes(entry)` — exported helper that returns the canonical bytes a verifier should hash and signature-check.
- `entryDigestHex(entry)` — exported helper that returns the hex SHA-256 of those bytes (handy for debug/inspection).
- `bytesToHex(bytes)` — exported lowercase-hex utility used by both this module and `IntegrityChecker`.
## Implementation Notes
- `prevHash` is a hex string rather than `Uint8Array` so chain entries are inspectable in JSON dumps and equality comparisons fall back on string compare without a length-prefix dance.
- `canonicalize` walks the entry-without-signature in sorted-key order; the sort is stable across all V8/JSC versions so two correct implementations produce byte-identical output.
- The `Omit<LogEntry, "hostSignature">` shape is the implicit "thing under signature" — any future field added to `LogEntry` is included automatically because canonicalization is structural.

View File

@@ -0,0 +1,88 @@
# AuditMeshPlugin
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Single `NetworkPlugin` facade that wires `SignedLog`, `LogReplicator`, `LogRetriever`, `IntegrityChecker`, `ConflictResolver`, and `LogCompaction` into a cohesive subsystem and registers them with a `NetworkNode` via the standard plugin lifecycle (`install` / `start` / `stop` / `dispose`). Consumers register exactly one of these per host; everything else is reachable via the public properties on the plugin instance.
The plugin keeps its own state minimal — installation just remembers the node, and the start/stop pair is a flag — because the subsystems it composes are independently useful and testable. The plugin is the wire-up; the modules are the work.
## Features
- One-shot construction wires every subsystem from a single config object.
- Public read-only access to `log`, `replicator`, `retriever`, `integrity`, `conflicts`, and `compaction` so consumers can drop directly into any of them.
- Lifecycle hooks are idempotent and side-effect-light; `start` only flips the started flag (replication is per-append, not background).
- `dispose` clears the node reference so the plugin can be re-installed later if the host's `NetworkNode` is rebuilt.
## Usage: Basic Example
```ts
import { ed25519 } from "@suaveplan/crypto-curve";
import { NetworkNode } from "@suaveplan/network";
import { AuditMeshPlugin } from "@suaveplan/network-plugin-audit-mesh";
const privateKey = ed25519.randomPrivateKey();
const node = new NetworkNode({ id: "host-1" });
const audit = new AuditMeshPlugin({
meshId: "mesh-a",
hostId: "host-1",
sign: (bytes) => ed25519.sign(bytes, privateKey),
replication: {
replicationFactor: 2,
localTier: 0,
localMeshId: "mesh-a",
localHostId: "host-1",
},
send: async (target, entry) => network.send(target.hostId, entry),
});
await node.use(audit).start();
await audit.log.append({
eventType: "host-elected",
sessionId: null,
severity: "critical",
payload: { electedHost: "host-1" },
});
```
## Usage: Advanced Example
```ts
import { ed25519 } from "@suaveplan/crypto-curve";
import { AuditMeshPlugin } from "@suaveplan/network-plugin-audit-mesh";
const audit = new AuditMeshPlugin({
meshId: "mesh-a",
hostId: "host-1",
sign: hardwareSigner.sign,
replication: { replicationFactor: 3, localTier: 1, localMeshId: "mesh-a", localHostId: "subhost-c" },
send: transportManager.relay,
candidates: peerRegistry.snapshot(),
verifySignature: (bytes, sig, hostId) =>
ed25519.verify(sig, bytes, hostKeys.get(hostId)!),
bus: { emit: (topic, payload) => observability.publish(topic, payload) },
defaultPageSize: 250,
maxPageSize: 5000,
});
const entry = await audit.log.append({ ... });
await audit.replicator.replicate(entry);
const report = await audit.integrity.check(audit.log.entries());
```
## API Reference
- `AuditMeshPlugin(config)` — `config: AuditMeshPluginConfig`. See type definition for the full surface.
- `name` — `"audit-mesh"` (constant).
- Subsystem properties: `log`, `replicator`, `retriever`, `integrity`, `conflicts`, `compaction`.
- `install(node)` / `start()` / `stop()` / `dispose()` — `NetworkPlugin` lifecycle.
- `host()` — returns the installed `NetworkNode` or `null`.
- `isStarted()` — boolean lifecycle inspector.
## Implementation Notes
- The plugin uses spread-with-conditional-key tricks (`...(config.x === undefined ? {} : { x: config.x })`) to honour `exactOptionalPropertyTypes` — passing `undefined` to a sub-component that expects an _absent_ key would type-error at construction time, so we elide the key entirely instead.
- The `bus` property is reused as both `ReplicatorBus` and `IntegrityBus` (their `emit` shapes are compatible in practice); if a consumer ever needs to route the two streams separately they can construct the subsystems individually rather than going through the plugin facade.
- Replication is intentionally not awaited inside `log.append`; the caller decides whether to `await replicator.replicate(entry)` immediately (sync semantics) or fire-and-forget (async semantics is also achieved by passing a `severity: "normal"` entry).

Some files were not shown because too many files have changed in this diff Show More