Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 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 + circ… |
| [`@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 b… |
| [`@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,73 @@
{
"name": "@suaveplan/network-bootstrap",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Network bootstrap plugin: pluggable discovery-strategy chain, persistent peer cache, retry-with-jitter and NetworkBus integration for finding initial peers in the @suaveplan/network mesh",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist", "src", "README.md"],
"exports": {
"./package.json": "./package.json",
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
},
"./types": {
"import": { "types": "./dist/types/types.d.ts", "default": "./dist/types/types.js" },
"require": { "types": "./dist/types/types.d.ts", "default": "./dist/types/types.js" }
},
"./peer-cache": {
"import": { "types": "./dist/core/peer-cache/peer-cache.d.ts", "default": "./dist/core/peer-cache/peer-cache.js" },
"require": { "types": "./dist/core/peer-cache/peer-cache.d.ts", "default": "./dist/core/peer-cache/peer-cache.js" }
},
"./discovery-chain": {
"import": { "types": "./dist/core/discovery-chain/discovery-chain.d.ts", "default": "./dist/core/discovery-chain/discovery-chain.js" },
"require": { "types": "./dist/core/discovery-chain/discovery-chain.d.ts", "default": "./dist/core/discovery-chain/discovery-chain.js" }
},
"./bootstrap-plugin": {
"import": { "types": "./dist/plugin/bootstrap-plugin/bootstrap-plugin.d.ts", "default": "./dist/plugin/bootstrap-plugin/bootstrap-plugin.js" },
"require": { "types": "./dist/plugin/bootstrap-plugin/bootstrap-plugin.d.ts", "default": "./dist/plugin/bootstrap-plugin/bootstrap-plugin.js" }
},
"./bootstrap-middleware": {
"import": { "types": "./dist/plugin/bootstrap-middleware/bootstrap-middleware.d.ts", "default": "./dist/plugin/bootstrap-middleware/bootstrap-middleware.js" },
"require": { "types": "./dist/plugin/bootstrap-middleware/bootstrap-middleware.d.ts", "default": "./dist/plugin/bootstrap-middleware/bootstrap-middleware.js" }
}
},
"scripts": {
"build": "vite build",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"lint": "bunx biome check src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/network": "workspace:^",
"@suaveplan/retry": "workspace:^",
"@suaveplan/storage": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
}
}

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,410 @@
import { NetworkBus } from "@suaveplan/network";
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import type {
BootstrapEventMap,
DiscoveryStrategy,
PeerInfo,
} from "../../types/types.js";
import { DiscoveryChain } from "./discovery-chain.js";
function makePeer(id: string, source = "test"): PeerInfo {
return {
peerId: id,
addresses: [`/ip4/127.0.0.1/tcp/9${id.padStart(3, "0")}`],
lastSeen: 1000,
source,
};
}
function strat(
name: string,
peers: PeerInfo[],
opts: { available?: boolean; failTimes?: number; delayMs?: number } = {}
): DiscoveryStrategy {
let calls = 0;
return {
name,
async discover(_timeout, _signal) {
calls += 1;
if (opts.failTimes && calls <= opts.failTimes) {
throw new Error(`${name}.fail-${calls}`);
}
if (opts.delayMs) {
await new Promise((r) => setTimeout(r, opts.delayMs));
}
return peers;
},
async isAvailable() {
return opts.available ?? true;
},
};
}
describe("DiscoveryChain", () => {
it("returns empty list with no strategies", async () => {
const chain = new DiscoveryChain();
const peers = await chain.discover(100);
expect(peers).toEqual([]);
});
it("collects peers from all available strategies (deduped)", async () => {
const chain = new DiscoveryChain({
strategies: [
strat("a", [makePeer("1"), makePeer("2")]),
strat("b", [makePeer("2"), makePeer("3")]),
],
retry: {
maxAttempts: 1,
initialDelayMs: 1,
maxDelayMs: 1,
factor: 2,
},
});
const peers = await chain.discover(100);
const ids = peers.map((p) => p.peerId).sort();
expect(ids).toEqual(["1", "2", "3"]);
});
it("filters out unavailable strategies", async () => {
const chain = new DiscoveryChain({
strategies: [
strat("on", [makePeer("1")]),
strat("off", [makePeer("2")], { available: false }),
],
retry: {
maxAttempts: 1,
initialDelayMs: 1,
maxDelayMs: 1,
factor: 2,
},
});
const peers = await chain.discover(100);
expect(peers.map((p) => p.peerId)).toEqual(["1"]);
});
it("stops early at maxCollectedPeers", async () => {
const chain = new DiscoveryChain({
strategies: [
strat("a", [makePeer("1"), makePeer("2"), makePeer("3")]),
strat("b", [makePeer("4"), makePeer("5")]),
],
maxCollectedPeers: 2,
maxConcurrent: 1,
retry: {
maxAttempts: 1,
initialDelayMs: 1,
maxDelayMs: 1,
factor: 2,
},
});
const peers = await chain.discover(100);
expect(peers.length).toBeGreaterThanOrEqual(2);
// Second batch may not be processed
});
it("retries failing strategy then succeeds", async () => {
const chain = new DiscoveryChain({
strategies: [strat("flaky", [makePeer("1")], { failTimes: 1 })],
retry: {
maxAttempts: 3,
initialDelayMs: 1,
maxDelayMs: 2,
factor: 2,
},
});
const peers = await chain.discover(100);
expect(peers).toHaveLength(1);
});
it("gives up after maxAttempts", async () => {
const chain = new DiscoveryChain({
strategies: [strat("always-fails", [], { failTimes: 99 })],
retry: {
maxAttempts: 2,
initialDelayMs: 1,
maxDelayMs: 1,
factor: 2,
},
});
const peers = await chain.discover(100);
expect(peers).toHaveLength(0);
});
it("times out a slow strategy", async () => {
const chain = new DiscoveryChain({
strategies: [strat("slow", [makePeer("1")], { delayMs: 200 })],
retry: {
maxAttempts: 1,
initialDelayMs: 1,
maxDelayMs: 1,
factor: 2,
},
});
const peers = await chain.discover(20);
expect(peers).toHaveLength(0);
});
it("emits bus events", async () => {
const bus = new NetworkBus<BootstrapEventMap>();
const seen: string[] = [];
bus.on("bootstrap:started", () => seen.push("started"));
bus.on("bootstrap:peer-found", () => seen.push("peer"));
bus.on("bootstrap:exhausted", () => seen.push("exhausted"));
const chain = new DiscoveryChain({
strategies: [strat("a", [makePeer("1"), makePeer("2")])],
bus,
retry: {
maxAttempts: 1,
initialDelayMs: 1,
maxDelayMs: 1,
factor: 2,
},
});
await chain.discover(100);
expect(seen[0]).toBe("started");
expect(seen[seen.length - 1]).toBe("exhausted");
expect(seen.filter((s) => s === "peer")).toHaveLength(2);
});
it("addStrategy and removeStrategy mutate the chain", () => {
const chain = new DiscoveryChain();
chain.addStrategy(strat("a", []));
chain.addStrategy(strat("b", []));
expect(chain.getStrategies()).toEqual(["a", "b"]);
expect(chain.removeStrategy("a")).toBe(true);
expect(chain.removeStrategy("missing")).toBe(false);
expect(chain.getStrategies()).toEqual(["b"]);
});
it("honours external AbortSignal — signal-respecting strategy returns no peers", async () => {
const controller = new AbortController();
controller.abort();
const respectful: DiscoveryStrategy = {
name: "respectful",
async discover(_t, sig) {
if (sig?.aborted) throw new Error("aborted");
return [makePeer("1")];
},
async isAvailable() {
return true;
},
};
const chain = new DiscoveryChain({
strategies: [respectful],
retry: {
maxAttempts: 1,
initialDelayMs: 1,
maxDelayMs: 1,
factor: 2,
},
});
const peers = await chain.discover(100, controller.signal);
expect(peers).toHaveLength(0);
});
it("isAvailable rejection is filtered out", async () => {
const broken: DiscoveryStrategy = {
name: "broken",
async discover() {
return [];
},
async isAvailable() {
throw new Error("isAvailable failed");
},
};
const chain = new DiscoveryChain({
strategies: [broken, strat("ok", [makePeer("1")])],
retry: {
maxAttempts: 1,
initialDelayMs: 1,
maxDelayMs: 1,
factor: 2,
},
});
const peers = await chain.discover(100);
expect(peers.map((p) => p.peerId)).toEqual(["1"]);
});
it("uses defaults when no options provided", async () => {
const chain = new DiscoveryChain({
strategies: [strat("a", [makePeer("1")])],
});
const peers = await chain.discover(100);
expect(peers).toHaveLength(1);
});
it("clamps maxConcurrent to >= 1", async () => {
const chain = new DiscoveryChain({
strategies: [strat("a", [makePeer("1")])],
maxConcurrent: 0,
retry: {
maxAttempts: 1,
initialDelayMs: 1,
maxDelayMs: 1,
factor: 2,
},
});
const peers = await chain.discover(100);
expect(peers).toHaveLength(1);
});
it("clamps maxCollectedPeers to >= 1", async () => {
const chain = new DiscoveryChain({
strategies: [strat("a", [makePeer("1")])],
maxCollectedPeers: 0,
retry: {
maxAttempts: 1,
initialDelayMs: 1,
maxDelayMs: 1,
factor: 2,
},
});
const peers = await chain.discover(100);
expect(peers.length).toBeGreaterThanOrEqual(0);
});
it("pre-aborted signal propagates to strategy via inner controller", async () => {
const controller = new AbortController();
controller.abort();
let observed = false;
const probe: DiscoveryStrategy = {
name: "probe",
async discover(_t, sig) {
observed = !!sig?.aborted;
return [];
},
async isAvailable() {
return true;
},
};
const chain = new DiscoveryChain({
strategies: [probe],
retry: {
maxAttempts: 1,
initialDelayMs: 1,
maxDelayMs: 1,
factor: 2,
},
});
await chain.discover(100, controller.signal);
expect(observed).toBe(true);
});
it("non-Error throw from strategy still surfaces a Promise rejection", async () => {
const broken: DiscoveryStrategy = {
name: "broken",
async discover() {
const literal: unknown = "string-throw";
throw literal;
},
async isAvailable() {
return true;
},
};
const chain = new DiscoveryChain({
strategies: [broken],
retry: {
maxAttempts: 1,
initialDelayMs: 1,
maxDelayMs: 1,
factor: 2,
},
});
const peers = await chain.discover(100);
expect(peers).toHaveLength(0);
});
it("aborts in-flight strategy when signal fires mid-flight", async () => {
const controller = new AbortController();
let aborted = false;
const slow: DiscoveryStrategy = {
name: "slow",
async discover(_timeout, sig) {
await new Promise<void>((resolve) => {
if (sig) {
sig.addEventListener(
"abort",
() => {
aborted = true;
resolve();
},
{ once: true }
);
}
});
return [];
},
async isAvailable() {
return true;
},
};
const chain = new DiscoveryChain({
strategies: [slow],
retry: {
maxAttempts: 1,
initialDelayMs: 1,
maxDelayMs: 1,
factor: 2,
},
});
const promise = chain.discover(10_000, controller.signal);
setTimeout(() => controller.abort(), 20);
await promise;
expect(aborted).toBe(true);
});
it("property: dedup invariant — final set size <= sum of strategy yields", async () => {
await fc.assert(
fc.asyncProperty(
fc.array(
fc.array(fc.string({ minLength: 1, maxLength: 3 }), {
maxLength: 4,
}),
{ minLength: 1, maxLength: 4 }
),
async (lists) => {
const strategies = lists.map((peers, i) =>
strat(
`s${i}`,
peers.map((p) => makePeer(p))
)
);
const chain = new DiscoveryChain({
strategies,
maxCollectedPeers: 1000,
retry: {
maxAttempts: 1,
initialDelayMs: 1,
maxDelayMs: 1,
factor: 2,
},
});
const got = await chain.discover(100);
const total = lists.reduce((s, l) => s + l.length, 0);
const unique = new Set(lists.flat()).size;
expect(got.length).toBeLessThanOrEqual(total);
expect(got.length).toBeLessThanOrEqual(unique);
}
),
{ numRuns: 25 }
);
});
it("retries with sleep that honours abort", async () => {
const controller = new AbortController();
const chain = new DiscoveryChain({
strategies: [strat("fail", [], { failTimes: 10 })],
retry: {
maxAttempts: 5,
initialDelayMs: 50,
maxDelayMs: 50,
factor: 2,
},
});
setTimeout(() => controller.abort(), 30);
const peers = await chain.discover(10, controller.signal);
expect(peers).toHaveLength(0);
});
});

View File

@@ -0,0 +1,259 @@
import type { NetworkBus } from "@suaveplan/network";
import { createExponentialBackoff, type RetryStrategy } from "@suaveplan/retry";
import type { Clock } from "@suaveplan/types";
import { NET_BOOTSTRAP_CODES } from "../../errors/errors.js";
import {
recordChainDuration,
recordFailure,
recordPeersFound,
withChainSpan,
} from "../../otel/otel.js";
import type {
BootstrapEventMap,
BootstrapRetryConfig,
DiscoveryStrategy,
PeerInfo,
} from "../../types/types.js";
import type { DiscoveryChainOptions } from "./discovery-chain.types.js";
const DEFAULT_MAX_CONCURRENT = 3;
const DEFAULT_MAX_COLLECTED = 10;
const DEFAULT_RETRY: BootstrapRetryConfig = {
maxAttempts: 3,
initialDelayMs: 100,
maxDelayMs: 5000,
factor: 2,
};
const defaultClock: Clock = () => Date.now();
/**
* Pluggable, ordered chain of `DiscoveryStrategy` implementations.
*
* Strategies are filtered by `isAvailable()` then run in concurrency-bounded
* batches. Each strategy gets a `@suaveplan/retry` exponential-backoff +
* full-jitter wrapper inside the strategy timeout. Results are merged into a
* deduped peer set; the chain stops early once `maxCollectedPeers` is reached.
*
* The chain emits `bootstrap:started`, `bootstrap:peer-found`,
* `bootstrap:exhausted` on the optional `NetworkBus`. OTel: span
* `bootstrap.chain.execute` wraps the whole `discover()` call; counters and
* histograms are recorded in the otel module.
*/
export class DiscoveryChain {
private readonly strategies: DiscoveryStrategy[] = [];
private readonly maxConcurrent: number;
private readonly maxCollectedPeers: number;
private readonly retry: BootstrapRetryConfig;
private readonly clock: Clock;
private readonly bus: NetworkBus<BootstrapEventMap> | undefined;
private readonly delayStrategy: RetryStrategy;
public constructor(options: DiscoveryChainOptions = {}) {
this.strategies = [...(options.strategies ?? [])];
this.maxConcurrent = Math.max(
1,
options.maxConcurrent ?? DEFAULT_MAX_CONCURRENT
);
this.maxCollectedPeers = Math.max(
1,
options.maxCollectedPeers ?? DEFAULT_MAX_COLLECTED
);
this.retry = options.retry ?? DEFAULT_RETRY;
this.clock = options.clock ?? defaultClock;
this.bus = options.bus;
this.delayStrategy = createExponentialBackoff({
initial: this.retry.initialDelayMs,
max: this.retry.maxDelayMs,
base: this.retry.factor,
jitter: "full",
});
}
/**
* Run all available strategies and collect unique peers up to
* `maxCollectedPeers`.
*
* @param timeoutMs - Per-strategy soft timeout.
* @param signal - Optional abort signal.
*/
public async discover(
timeoutMs: number,
signal?: AbortSignal
): Promise<PeerInfo[]> {
return withChainSpan(async () => {
const startedAt = this.clock();
const available = await this.filterAvailable(signal);
this.bus?.emit("bootstrap:started", {
strategyCount: available.length,
timeoutMs,
at: startedAt,
});
const collected = new Map<string, PeerInfo>();
let succeeded = 0;
for (let i = 0; i < available.length; i += this.maxConcurrent) {
if (collected.size >= this.maxCollectedPeers) break;
const batch = available.slice(i, i + this.maxConcurrent);
const results = await Promise.allSettled(
batch.map((s) =>
this.runStrategyWithRetry(s, timeoutMs, signal)
)
);
for (let j = 0; j < results.length; j++) {
// results[j] and batch[j] always exist — Promise.allSettled
// preserves array length 1:1 with its input.
const result = results[j] as PromiseSettledResult<
PeerInfo[]
>;
const strategy = batch[j] as DiscoveryStrategy;
if (result.status === "fulfilled") {
succeeded += 1;
let newCount = 0;
for (const peer of result.value) {
if (!collected.has(peer.peerId)) {
collected.set(peer.peerId, peer);
newCount += 1;
this.bus?.emit("bootstrap:peer-found", {
strategy: strategy.name,
peer,
at: this.clock(),
});
}
}
recordPeersFound(strategy.name, newCount);
} else {
recordFailure(
strategy.name,
NET_BOOTSTRAP_CODES.STRATEGY_TIMEOUT
);
}
}
}
const elapsed = this.clock() - startedAt;
recordChainDuration(elapsed);
this.bus?.emit("bootstrap:exhausted", {
attempted: available.length,
succeeded,
collected: collected.size,
at: this.clock(),
});
return Array.from(collected.values());
});
}
/** Add a strategy to the end of the chain. */
public addStrategy(strategy: DiscoveryStrategy): void {
this.strategies.push(strategy);
}
/** Remove a strategy by name. Returns true if found and removed. */
public removeStrategy(name: string): boolean {
const idx = this.strategies.findIndex((s) => s.name === name);
if (idx === -1) return false;
this.strategies.splice(idx, 1);
return true;
}
/** Snapshot the names of registered strategies. */
public getStrategies(): string[] {
return this.strategies.map((s) => s.name);
}
/** Filter to strategies whose `isAvailable()` resolves truthy. */
private async filterAvailable(
signal?: AbortSignal
): Promise<DiscoveryStrategy[]> {
const checks = await Promise.allSettled(
this.strategies.map(async (s) => ({
strategy: s,
available: await s.isAvailable(signal),
}))
);
const available: DiscoveryStrategy[] = [];
for (const check of checks) {
if (check.status === "fulfilled" && check.value.available) {
available.push(check.value.strategy);
}
}
return available;
}
/**
* Run a strategy with retry-with-jitter, capping each attempt at
* `timeoutMs`. Resolves with an empty array if every attempt fails.
*/
private async runStrategyWithRetry(
strategy: DiscoveryStrategy,
timeoutMs: number,
signal?: AbortSignal
): Promise<PeerInfo[]> {
let lastErr: unknown;
for (let attempt = 1; attempt <= this.retry.maxAttempts; attempt++) {
try {
return await this.runWithTimeout(strategy, timeoutMs, signal);
} catch (err) {
lastErr = err;
if (attempt >= this.retry.maxAttempts) break;
if (signal?.aborted) break;
const delay = this.delayStrategy(attempt);
await this.sleep(delay, signal);
}
}
throw lastErr instanceof Error ? lastErr : new Error("strategy.failed");
}
private async runWithTimeout(
strategy: DiscoveryStrategy,
timeoutMs: number,
signal?: AbortSignal
): Promise<PeerInfo[]> {
const inner = new AbortController();
const onParentAbort = (): void => {
inner.abort();
};
if (signal?.aborted) {
inner.abort();
} else {
signal?.addEventListener("abort", onParentAbort, { once: true });
}
let timer!: ReturnType<typeof setTimeout>;
const timeoutPromise = new Promise<PeerInfo[]>((_resolve, reject) => {
timer = setTimeout(() => {
inner.abort();
reject(new Error("strategy.timeout"));
}, timeoutMs);
});
try {
return await Promise.race([
strategy.discover(timeoutMs, inner.signal),
timeoutPromise,
]);
} finally {
clearTimeout(timer);
signal?.removeEventListener("abort", onParentAbort);
}
}
private sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise<void>((resolve) => {
const timer = setTimeout(resolve, ms);
const onAbort = (): void => {
clearTimeout(timer);
resolve();
};
signal?.addEventListener("abort", onAbort, { once: true });
});
}
}

View File

@@ -0,0 +1,28 @@
export type {
BootstrapEventMap,
BootstrapExhaustedEvent,
BootstrapPeerFoundEvent,
BootstrapRetryConfig,
BootstrapStartedEvent,
DiscoveryStrategy,
PeerInfo,
} from "../../types/types.js";
import type { NetworkBus } from "@suaveplan/network";
import type { Clock } from "@suaveplan/types";
import type {
BootstrapEventMap,
BootstrapRetryConfig,
DiscoveryStrategy,
} from "../../types/types.js";
/** Options for `DiscoveryChain`. */
export interface DiscoveryChainOptions {
readonly strategies?: readonly DiscoveryStrategy[];
readonly maxConcurrent?: number;
readonly maxCollectedPeers?: number;
readonly retry?: BootstrapRetryConfig;
readonly clock?: Clock;
/** Bus on which to emit `bootstrap:*` events. */
readonly bus?: NetworkBus<BootstrapEventMap>;
}

View File

@@ -0,0 +1,8 @@
export { DiscoveryChain } from "./discovery-chain.js";
export type {
BootstrapEventMap,
BootstrapRetryConfig,
DiscoveryChainOptions,
DiscoveryStrategy,
PeerInfo,
} from "./discovery-chain.types.js";

View File

@@ -0,0 +1,6 @@
export { PeerCache } from "./peer-cache.js";
export type {
PeerCacheConfig,
PeerCacheStorageLike,
PeerInfo,
} from "./peer-cache.types.js";

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,252 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import { NET_BOOTSTRAP_CODES, NetBootstrapError } from "../../errors/errors.js";
import type { PeerCacheStorageLike, PeerInfo } from "../../types/types.js";
import { PeerCache } from "./peer-cache.js";
function makePeer(id: string, lastSeen: number, source = "test"): PeerInfo {
return {
peerId: id,
addresses: [`/ip4/127.0.0.1/tcp/9${id.padStart(3, "0")}`],
lastSeen,
source,
};
}
class MemStorage implements PeerCacheStorageLike {
public readonly map = new Map<string, Uint8Array>();
async get(key: string): Promise<Uint8Array | undefined> {
return this.map.get(key);
}
async set(key: string, value: Uint8Array): Promise<void> {
this.map.set(key, value);
}
async delete(key: string): Promise<boolean> {
return this.map.delete(key);
}
}
describe("PeerCache", () => {
it("constructs with defaults", () => {
const cache = new PeerCache();
expect(cache.size).toBe(0);
});
it("rejects malformed options", () => {
expect(() => new PeerCache({ maxPeers: -1 })).toThrow(
NetBootstrapError
);
try {
new PeerCache({ maxPeers: -1 });
} catch (err) {
expect((err as NetBootstrapError).code).toBe(
NET_BOOTSTRAP_CODES.INVALID_OPTIONS
);
}
});
it("adds and retrieves peers (defensive copies)", () => {
const cache = new PeerCache({ ttlMs: 60_000 });
const peer = makePeer("1", Date.now());
cache.add(peer);
const got = cache.get("1");
expect(got).toEqual(peer);
expect(got).not.toBe(peer);
});
it("returns undefined for unknown peer", () => {
const cache = new PeerCache();
expect(cache.get("missing")).toBeUndefined();
});
it("updates existing peer in place", () => {
const cache = new PeerCache({ ttlMs: 60_000 });
const t0 = Date.now();
cache.add(makePeer("1", t0));
cache.add(makePeer("1", t0 + 1000, "newsrc"));
expect(cache.size).toBe(1);
expect(cache.get("1")?.source).toBe("newsrc");
});
it("evicts oldest when at capacity", () => {
const now = 10_000;
const cache = new PeerCache({
maxPeers: 2,
ttlMs: 60_000,
clock: () => now,
});
cache.add(makePeer("a", now - 200));
cache.add(makePeer("b", now - 100));
cache.add(makePeer("c", now));
expect(cache.size).toBe(2);
expect(cache.get("a")).toBeUndefined();
expect(cache.get("b")).toBeDefined();
expect(cache.get("c")).toBeDefined();
});
it("prunes expired entries on get", () => {
let now = 1000;
const cache = new PeerCache({ ttlMs: 100, clock: () => now });
cache.add(makePeer("1", 1000));
now = 5000;
expect(cache.get("1")).toBeUndefined();
expect(cache.size).toBe(0);
});
it("getAll returns sorted descending by lastSeen and prunes expired", () => {
let now = 1000;
const cache = new PeerCache({ ttlMs: 100, clock: () => now });
cache.add(makePeer("old", 100));
cache.add(makePeer("new", 950));
now = 1001;
const all = cache.getAll();
expect(all.map((p) => p.peerId)).toEqual(["new"]);
});
it("getAll sorts multiple entries by lastSeen descending", () => {
const now = 10_000;
const cache = new PeerCache({ ttlMs: 60_000, clock: () => now });
cache.add(makePeer("a", now - 30));
cache.add(makePeer("b", now - 10));
cache.add(makePeer("c", now - 20));
const all = cache.getAll();
expect(all.map((p) => p.peerId)).toEqual(["b", "c", "a"]);
});
it("remove returns true when present, false otherwise", () => {
const cache = new PeerCache();
cache.add(makePeer("1", Date.now()));
expect(cache.remove("1")).toBe(true);
expect(cache.remove("1")).toBe(false);
});
it("clear empties cache", () => {
const cache = new PeerCache();
cache.add(makePeer("1", Date.now()));
cache.add(makePeer("2", Date.now()));
cache.clear();
expect(cache.size).toBe(0);
});
it("flush is no-op without storage", async () => {
const cache = new PeerCache();
cache.add(makePeer("1", Date.now()));
await expect(cache.flush()).resolves.toBeUndefined();
});
it("load is no-op without storage", async () => {
const cache = new PeerCache();
await expect(cache.load()).resolves.toBeUndefined();
});
it("flush + load round-trips via storage", async () => {
const storage = new MemStorage();
const cache = new PeerCache({
persistKey: "k",
storage,
ttlMs: 60_000,
});
cache.add(makePeer("1", Date.now()));
cache.add(makePeer("2", Date.now()));
await cache.flush();
const cache2 = new PeerCache({
persistKey: "k",
storage,
ttlMs: 60_000,
});
await cache2.load();
expect(cache2.size).toBe(2);
});
it("load skips when storage is empty", async () => {
const storage = new MemStorage();
const cache = new PeerCache({ persistKey: "k", storage });
await cache.load();
expect(cache.size).toBe(0);
});
it("load throws CACHE_CORRUPTED on undecodable JSON", async () => {
const storage = new MemStorage();
storage.map.set("k", new TextEncoder().encode("not-json{{"));
const cache = new PeerCache({ persistKey: "k", storage });
await expect(cache.load()).rejects.toMatchObject({
code: NET_BOOTSTRAP_CODES.CACHE_CORRUPTED,
});
});
it("load throws CACHE_CORRUPTED on non-array shape", async () => {
const storage = new MemStorage();
storage.map.set("k", new TextEncoder().encode("{}"));
const cache = new PeerCache({ persistKey: "k", storage });
await expect(cache.load()).rejects.toMatchObject({
code: NET_BOOTSTRAP_CODES.CACHE_CORRUPTED,
});
});
it("load skips invalid peer entries", async () => {
const storage = new MemStorage();
const payload = JSON.stringify([
makePeer("ok", 1000),
{ peerId: "bad" }, // missing fields
{ addresses: [], lastSeen: 1, source: "x" }, // missing peerId
null,
]);
storage.map.set("k", new TextEncoder().encode(payload));
const cache = new PeerCache({
persistKey: "k",
storage,
ttlMs: 60_000,
clock: () => 1000,
});
await cache.load();
expect(cache.size).toBe(1);
expect(cache.get("ok")).toBeDefined();
});
it("flush prunes expired entries before persisting", async () => {
const storage = new MemStorage();
let now = 1000;
const cache = new PeerCache({
persistKey: "k",
storage,
ttlMs: 100,
clock: () => now,
});
cache.add(makePeer("old", 100));
cache.add(makePeer("fresh", 950));
now = 1001;
await cache.flush();
const text = new TextDecoder().decode(storage.map.get("k"));
const arr = JSON.parse(text);
expect(arr).toHaveLength(1);
expect(arr[0].peerId).toBe("fresh");
});
it("property: eviction never exceeds maxPeers", () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 20 }),
fc.array(
fc.tuple(
fc.string({ minLength: 1, maxLength: 5 }),
fc.integer({ min: 0, max: 1_000_000 })
),
{ minLength: 0, maxLength: 50 }
),
(cap, entries) => {
const cache = new PeerCache({
maxPeers: cap,
ttlMs: 10_000_000,
clock: () => 1_500_000,
});
for (const [id, t] of entries) {
cache.add(makePeer(id, t));
}
expect(cache.size).toBeLessThanOrEqual(cap);
}
),
{ numRuns: 50 }
);
});
});

View File

@@ -0,0 +1,185 @@
import type { Clock } from "@suaveplan/types";
import { NET_BOOTSTRAP_CODES, NetBootstrapError } from "../../errors/errors.js";
import type {
PeerCacheConfig,
PeerCacheStorageLike,
PeerInfo,
} from "../../types/types.js";
import { PeerCacheConfigSchema } from "../../types/types.schemas.js";
const defaultClock: Clock = () => Date.now();
/**
* In-memory peer cache with optional persistent backing.
*
* - TTL-based expiration on read.
* - Capacity-based eviction: when adding a new peer at `size === maxPeers`,
* the entry with the smallest `lastSeen` is evicted.
* - Optional `storage` + `persistKey` persists the cache as JSON-encoded
* bytes through any `PeerCacheStorageLike` (e.g. `AbstractStorage` from
* `@suaveplan/storage`).
*/
export class PeerCache {
private readonly peers: Map<string, PeerInfo> = new Map();
private readonly maxPeers: number;
private readonly ttlMs: number;
private readonly clock: Clock;
private readonly storage: PeerCacheStorageLike | undefined;
private readonly persistKey: string | undefined;
public constructor(config: Partial<PeerCacheConfig> = {}) {
const parsed = PeerCacheConfigSchema.safeParse(config);
if (!parsed.success) {
throw new NetBootstrapError(
NET_BOOTSTRAP_CODES.INVALID_OPTIONS,
"peer-cache.invalid-options",
{ cause: parsed.error }
);
}
this.maxPeers = parsed.data.maxPeers;
this.ttlMs = parsed.data.ttlMs;
this.clock = (parsed.data.clock as Clock | undefined) ?? defaultClock;
this.storage = parsed.data.storage as PeerCacheStorageLike | undefined;
this.persistKey = parsed.data.persistKey;
}
/**
* Add or update a peer in the cache.
* If at capacity, the oldest peer (by lastSeen) is evicted first.
*/
public add(peer: PeerInfo): void {
if (this.peers.has(peer.peerId)) {
this.peers.set(peer.peerId, { ...peer });
return;
}
if (this.peers.size >= this.maxPeers) {
this.evictOldest();
}
this.peers.set(peer.peerId, { ...peer });
}
/** Retrieve a single peer by ID, or `undefined` if absent or expired. */
public get(peerId: string): PeerInfo | undefined {
const peer = this.peers.get(peerId);
if (!peer) return undefined;
if (this.isExpired(peer)) {
this.peers.delete(peerId);
return undefined;
}
return { ...peer };
}
/**
* Return all non-expired peers, sorted by `lastSeen` descending
* (most recently seen first). Expired entries are pruned as a side-effect.
*/
public getAll(): PeerInfo[] {
this.pruneExpired();
const entries = Array.from(this.peers.values());
entries.sort((a, b) => b.lastSeen - a.lastSeen);
return entries.map((p) => ({ ...p }));
}
/** Remove a peer by ID. Returns true if the peer existed. */
public remove(peerId: string): boolean {
return this.peers.delete(peerId);
}
/** Remove all peers from the cache. */
public clear(): void {
this.peers.clear();
}
/** Current size, including potentially-expired entries. */
public get size(): number {
return this.peers.size;
}
/**
* Persist the current (non-expired) cache contents through the configured
* `storage`. No-op when persistence is not configured.
*/
public async flush(signal?: AbortSignal): Promise<void> {
if (!this.storage || !this.persistKey) return;
this.pruneExpired();
const json = JSON.stringify(Array.from(this.peers.values()));
const bytes = new TextEncoder().encode(json);
await this.storage.set(this.persistKey, bytes, signal);
}
/**
* Restore cache contents from the configured `storage`. Silently no-op
* when persistence is not configured. Throws `CACHE_CORRUPTED` if the
* stored payload is present but not decodable.
*/
public async load(signal?: AbortSignal): Promise<void> {
if (!this.storage || !this.persistKey) return;
const bytes = await this.storage.get(this.persistKey, signal);
if (!bytes) return;
let raw: unknown;
try {
raw = JSON.parse(new TextDecoder().decode(bytes));
} catch (cause) {
throw new NetBootstrapError(
NET_BOOTSTRAP_CODES.CACHE_CORRUPTED,
"peer-cache.corrupted",
{ cause }
);
}
if (!Array.isArray(raw)) {
throw new NetBootstrapError(
NET_BOOTSTRAP_CODES.CACHE_CORRUPTED,
"peer-cache.corrupted-shape"
);
}
this.peers.clear();
for (const entry of raw) {
if (this.isValidPeer(entry)) {
this.peers.set(entry.peerId, entry);
}
}
}
private isValidPeer(value: unknown): value is PeerInfo {
if (typeof value !== "object" || value === null) return false;
const v = value as Record<string, unknown>;
return (
typeof v["peerId"] === "string" &&
Array.isArray(v["addresses"]) &&
typeof v["lastSeen"] === "number" &&
typeof v["source"] === "string"
);
}
private isExpired(peer: PeerInfo): boolean {
return this.clock() - peer.lastSeen > this.ttlMs;
}
private pruneExpired(): void {
for (const [id, peer] of this.peers) {
if (this.isExpired(peer)) {
this.peers.delete(id);
}
}
}
private evictOldest(): void {
// Called only when peers.size >= maxPeers >= 1, so the map is
// guaranteed non-empty and oldestId is always assigned.
let oldestId = "";
let oldestTime = Number.POSITIVE_INFINITY;
for (const [id, peer] of this.peers) {
if (peer.lastSeen < oldestTime) {
oldestTime = peer.lastSeen;
oldestId = id;
}
}
this.peers.delete(oldestId);
}
}

View File

@@ -0,0 +1,5 @@
export type {
PeerCacheConfig,
PeerCacheStorageLike,
PeerInfo,
} from "../../types/types.js";

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,30 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NET_BOOTSTRAP_CODES, NetBootstrapError } from "./errors.js";
describe("errors", () => {
it("registry has all 4 codes", () => {
expect(NET_BOOTSTRAP_CODES.CHAIN_EXHAUSTED).toBeDefined();
expect(NET_BOOTSTRAP_CODES.CACHE_CORRUPTED).toBeDefined();
expect(NET_BOOTSTRAP_CODES.STRATEGY_TIMEOUT).toBeDefined();
expect(NET_BOOTSTRAP_CODES.INVALID_OPTIONS).toBeDefined();
});
it("NetBootstrapError preserves code and message key", () => {
const err = new NetBootstrapError(
NET_BOOTSTRAP_CODES.CHAIN_EXHAUSTED,
"test.key"
);
expect(err.code).toBe(NET_BOOTSTRAP_CODES.CHAIN_EXHAUSTED);
expect(err.name).toBe("NetBootstrapError");
});
it("preserves cause chain", () => {
const cause = new Error("inner");
const err = new NetBootstrapError(
NET_BOOTSTRAP_CODES.CACHE_CORRUPTED,
"test.key",
{ cause }
);
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,38 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
/**
* Error codes emitted by `@suaveplan/network-bootstrap`.
*
* - `CHAIN_EXHAUSTED` — every strategy in the chain failed or returned empty.
* - `CACHE_CORRUPTED` — persistent cache payload could not be decoded.
* - `STRATEGY_TIMEOUT` — a single strategy did not complete within the timeout.
* - `INVALID_OPTIONS` — caller supplied an option struct that failed Zod parse.
*/
export const NET_BOOTSTRAP_CODES = defineCodes("NET_BOOTSTRAP", [
"CHAIN_EXHAUSTED",
"CACHE_CORRUPTED",
"STRATEGY_TIMEOUT",
"INVALID_OPTIONS",
] as const);
export type NetBootstrapErrorCode =
(typeof NET_BOOTSTRAP_CODES)[keyof typeof NET_BOOTSTRAP_CODES];
/**
* Structured error class for `@suaveplan/network-bootstrap`. Every public
* boundary throws this on failure; consumers branch on `error.code`.
*/
export class NetBootstrapError extends SuaveplanError<NetBootstrapErrorCode> {
public constructor(
code: NetBootstrapErrorCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = "NetBootstrapError";
}
}

View File

@@ -0,0 +1,48 @@
// Core
export { DiscoveryChain } from "./core/discovery-chain/discovery-chain.js";
export type { DiscoveryChainOptions } from "./core/discovery-chain/discovery-chain.types.js";
export { PeerCache } from "./core/peer-cache/peer-cache.js";
// Errors
export {
NET_BOOTSTRAP_CODES,
NetBootstrapError,
type NetBootstrapErrorCode,
} from "./errors/errors.js";
// OTel
export {
recordChainDuration,
recordFailure,
recordPeersFound,
withChainSpan,
} from "./otel/otel.js";
// Plugin + middleware
export { createBootstrapMiddleware } from "./plugin/bootstrap-middleware/bootstrap-middleware.js";
export { BootstrapPlugin } from "./plugin/bootstrap-plugin/bootstrap-plugin.js";
// Types
export type {
BootstrapEventMap,
BootstrapExhaustedEvent,
BootstrapMiddlewareConfig,
BootstrapPeerFoundEvent,
BootstrapPluginConfig,
BootstrapRetryConfig,
BootstrapStartedEvent,
DiscoveryStrategy,
MessageMiddleware,
MiddlewareContext,
MiddlewareMessage,
PeerCacheConfig,
PeerCacheStorageLike,
PeerInfo,
} from "./types/types.js";
// Schemas
export {
BootstrapMiddlewareConfigSchema,
BootstrapPluginConfigSchema,
BootstrapRetryConfigSchema,
PeerCacheConfigSchema,
PeerInfoSchema,
} from "./types/types.schemas.js";

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,40 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
recordChainDuration,
recordFailure,
recordPeersFound,
withChainSpan,
} from "./otel.js";
describe("otel", () => {
it("withChainSpan returns body result", async () => {
const out = await withChainSpan(async () => 42);
expect(out).toBe(42);
});
it("withChainSpan ends the span even on throw", async () => {
await expect(
withChainSpan(async () => {
throw new Error("boom");
})
).rejects.toThrow("boom");
});
it("recordPeersFound no-op for zero / negative count", () => {
expect(() => recordPeersFound("s", 0)).not.toThrow();
expect(() => recordPeersFound("s", -1)).not.toThrow();
});
it("recordPeersFound increments for positive count", () => {
expect(() => recordPeersFound("s", 5)).not.toThrow();
});
it("recordFailure increments", () => {
expect(() => recordFailure("s", "X")).not.toThrow();
});
it("recordChainDuration clamps negative input", () => {
expect(() => recordChainDuration(-100)).not.toThrow();
expect(() => recordChainDuration(100)).not.toThrow();
});
});

View File

@@ -0,0 +1,84 @@
import { metrics, type Span, type Tracer, trace } from "@opentelemetry/api";
const TRACER_NAME = "@suaveplan/network-bootstrap";
const METER_NAME = "@suaveplan/network-bootstrap";
const tracer: Tracer = trace.getTracer(TRACER_NAME);
const meter = metrics.getMeter(METER_NAME);
/**
* Counter incremented for every peer found by a strategy.
* Labelled by `{strategy}`.
*/
const peersFoundCounter = meter.createCounter("bootstrap.peers_found_total", {
description: "Total peers discovered, by strategy.",
});
/**
* Counter incremented when a strategy fails.
* Labelled by `{strategy, code}`.
*/
const failuresCounter = meter.createCounter("bootstrap.failures_total", {
description: "Total strategy failures, by strategy and error code.",
});
/**
* Histogram of chain durations in milliseconds.
*/
const chainDurationHistogram = meter.createHistogram(
"bootstrap.chain_duration_ms",
{
description: "End-to-end chain execution duration in milliseconds.",
unit: "ms",
}
);
/**
* Wrap an async chain-execution body in an OTel span. The caller's function
* receives the live span so it can add attributes.
*
* @param fn - Async body.
* @returns The body's resolved value.
*/
export async function withChainSpan<T>(
fn: (span: Span) => Promise<T>
): Promise<T> {
return tracer.startActiveSpan("bootstrap.chain.execute", async (span) => {
try {
return await fn(span);
} finally {
span.end();
}
});
}
/**
* Record a successful peer discovery for a given strategy.
*
* @param strategy - Strategy name.
* @param count - Number of new peers found.
*/
export function recordPeersFound(strategy: string, count: number): void {
if (count > 0) {
peersFoundCounter.add(count, { strategy });
}
}
/**
* Record a failed strategy attempt.
*
* @param strategy - Strategy name.
* @param code - Failure code (typically `NET_BOOTSTRAP_CODES.*` or a string).
*/
export function recordFailure(strategy: string, code: string): void {
failuresCounter.add(1, { strategy, code });
}
/**
* Record the wall-clock duration of a chain execution.
*
* @param ms - Elapsed milliseconds.
*/
export function recordChainDuration(ms: number): void {
chainDurationHistogram.record(Math.max(0, ms));
}

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,152 @@
import { NetworkNode } from "@suaveplan/network";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NET_BOOTSTRAP_CODES, NetBootstrapError } from "../../errors/errors.js";
import type {
DiscoveryStrategy,
MiddlewareContext,
MiddlewareMessage,
PeerInfo,
} from "../../types/types.js";
import { BootstrapPlugin } from "../bootstrap-plugin/bootstrap-plugin.js";
import { createBootstrapMiddleware } from "./bootstrap-middleware.js";
function makePeer(id: string): PeerInfo {
return {
peerId: id,
addresses: [`/ip4/127.0.0.1/tcp/9${id.padStart(3, "0")}`],
lastSeen: 1000,
source: "test",
};
}
function strat(name: string, peers: PeerInfo[]): DiscoveryStrategy {
return {
name,
async discover() {
return peers;
},
async isAvailable() {
return true;
},
};
}
const ctx: MiddlewareContext = {
remoteNodeId: "remote",
transport: "tcp",
timestamp: 0,
direction: "incoming",
};
function makeMsg(
type: string,
overrides: Partial<MiddlewareMessage> = {}
): MiddlewareMessage {
return {
id: "m1",
type,
payload: null,
metadata: {},
source: "src",
timestamp: 1000,
...overrides,
};
}
async function setupPlugin(): Promise<BootstrapPlugin> {
const plugin = new BootstrapPlugin({
strategies: [strat("a", [makePeer("1")])],
retry: {
maxAttempts: 1,
initialDelayMs: 1,
maxDelayMs: 1,
factor: 2,
},
});
plugin.install(new NetworkNode());
return plugin;
}
describe("createBootstrapMiddleware", () => {
it("rejects malformed options", async () => {
const plugin = await setupPlugin();
expect(() =>
createBootstrapMiddleware(plugin, {
interceptTypes: [""],
})
).toThrow(NetBootstrapError);
});
it("returns middleware with name + priority", async () => {
const plugin = await setupPlugin();
const mw = createBootstrapMiddleware(plugin);
expect(mw.name).toBe("bootstrap-middleware");
expect(mw.priority).toBe(2);
});
it("passes through non-intercepted messages", async () => {
const plugin = await setupPlugin();
const mw = createBootstrapMiddleware(plugin);
const msg = makeMsg("OTHER_TYPE");
const out = await mw.onIncoming?.(msg, ctx);
expect(out).toBe(msg);
});
it("transforms intercepted messages into BOOTSTRAP_RESPONSE", async () => {
const plugin = await setupPlugin();
const mw = createBootstrapMiddleware(plugin, { clock: () => 42 });
const msg = makeMsg("BOOTSTRAP_REQUEST");
const out = await mw.onIncoming?.(msg, ctx);
expect(out?.type).toBe("BOOTSTRAP_RESPONSE");
const payload = out?.payload as { peers: PeerInfo[] };
expect(payload.peers).toHaveLength(1);
expect(out?.metadata.bootstrapPeerCount).toBe(1);
expect(out?.metadata.bootstrapTimestamp).toBe(42);
});
it("honours custom interceptTypes", async () => {
const plugin = await setupPlugin();
const mw = createBootstrapMiddleware(plugin, {
interceptTypes: ["CUSTOM_REQ"],
});
const reg = makeMsg("BOOTSTRAP_REQUEST");
const out = await mw.onIncoming?.(reg, ctx);
expect(out).toBe(reg);
const custom = makeMsg("CUSTOM_REQ");
const out2 = await mw.onIncoming?.(custom, ctx);
expect(out2?.type).toBe("BOOTSTRAP_RESPONSE");
});
it("outgoing messages pass through unchanged", async () => {
const plugin = await setupPlugin();
const mw = createBootstrapMiddleware(plugin);
const msg = makeMsg("BOOTSTRAP_REQUEST");
const out = await mw.onOutgoing?.(msg, {
...ctx,
direction: "outgoing",
});
expect(out).toBe(msg);
});
it("uses default clock when none provided", async () => {
const plugin = await setupPlugin();
const mw = createBootstrapMiddleware(plugin);
const before = Date.now();
const out = await mw.onIncoming?.(makeMsg("BOOTSTRAP_REQUEST"), ctx);
const ts = out?.metadata.bootstrapTimestamp as number;
expect(typeof ts).toBe("number");
expect(ts).toBeGreaterThanOrEqual(before);
});
it("INVALID_OPTIONS code on bad config", async () => {
const plugin = await setupPlugin();
try {
createBootstrapMiddleware(plugin, { interceptTypes: [""] });
} catch (err) {
expect((err as NetBootstrapError).code).toBe(
NET_BOOTSTRAP_CODES.INVALID_OPTIONS
);
}
});
});

View File

@@ -0,0 +1,67 @@
import type { Clock } from "@suaveplan/types";
import { NET_BOOTSTRAP_CODES, NetBootstrapError } from "../../errors/errors.js";
import type {
BootstrapMiddlewareConfig,
MessageMiddleware,
MiddlewareContext,
MiddlewareMessage,
} from "../../types/types.js";
import { BootstrapMiddlewareConfigSchema } from "../../types/types.schemas.js";
import type { BootstrapPlugin } from "../bootstrap-plugin/bootstrap-plugin.js";
const defaultClock: Clock = () => Date.now();
/**
* Create a bootstrap middleware that intercepts discovery-request messages.
*
* Incoming messages of a configured type trigger a discovery round on the
* plugin and reply with the cached peers in the payload. Other messages
* pass through unchanged. Outgoing messages are always passed through.
*/
export function createBootstrapMiddleware(
plugin: BootstrapPlugin,
config: Partial<BootstrapMiddlewareConfig> = {}
): MessageMiddleware {
const parsed = BootstrapMiddlewareConfigSchema.safeParse(config);
if (!parsed.success) {
throw new NetBootstrapError(
NET_BOOTSTRAP_CODES.INVALID_OPTIONS,
"bootstrap-middleware.invalid-options",
{ cause: parsed.error }
);
}
const interceptTypes = parsed.data.interceptTypes;
const clock = (parsed.data.clock as Clock | undefined) ?? defaultClock;
return {
name: "bootstrap-middleware",
priority: 2,
async onIncoming(
message: MiddlewareMessage,
_context: MiddlewareContext
): Promise<MiddlewareMessage | null> {
if (!interceptTypes.includes(message.type)) {
return message;
}
const peers = await plugin.discover();
return {
...message,
type: "BOOTSTRAP_RESPONSE",
payload: { peers },
metadata: {
...message.metadata,
bootstrapPeerCount: peers.length,
bootstrapTimestamp: clock(),
},
};
},
async onOutgoing(
message: MiddlewareMessage,
_context: MiddlewareContext
): Promise<MiddlewareMessage | null> {
return message;
},
};
}

View File

@@ -0,0 +1,6 @@
export type {
BootstrapMiddlewareConfig,
MessageMiddleware,
MiddlewareContext,
MiddlewareMessage,
} from "../../types/types.js";

View File

@@ -0,0 +1,7 @@
export { createBootstrapMiddleware } from "./bootstrap-middleware.js";
export type {
BootstrapMiddlewareConfig,
MessageMiddleware,
MiddlewareContext,
MiddlewareMessage,
} from "./bootstrap-middleware.types.js";

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,138 @@
import { NetworkNode } from "@suaveplan/network";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NET_BOOTSTRAP_CODES, NetBootstrapError } from "../../errors/errors.js";
import type { DiscoveryStrategy, PeerInfo } from "../../types/types.js";
import { BootstrapPlugin } from "./bootstrap-plugin.js";
function makePeer(id: string): PeerInfo {
return {
peerId: id,
addresses: [`/ip4/127.0.0.1/tcp/9${id.padStart(3, "0")}`],
lastSeen: Date.now(),
source: "test",
};
}
function strat(name: string, peers: PeerInfo[]): DiscoveryStrategy {
return {
name,
async discover() {
return peers;
},
async isAvailable() {
return true;
},
};
}
describe("BootstrapPlugin", () => {
it("rejects malformed options", () => {
expect(() => new BootstrapPlugin({ discoveryTimeoutMs: -1 })).toThrow(
NetBootstrapError
);
});
it("full lifecycle: install → start → stop → dispose", async () => {
const plugin = new BootstrapPlugin({
strategies: [strat("a", [makePeer("1")])],
retry: {
maxAttempts: 1,
initialDelayMs: 1,
maxDelayMs: 1,
factor: 2,
},
});
const node = new NetworkNode();
plugin.install(node);
expect(plugin.peerCache).toBeDefined();
expect(plugin.discoveryChain).toBeDefined();
await plugin.start();
expect(plugin.isStarted).toBe(true);
expect(plugin.peerCache?.size).toBe(1);
plugin.stop();
expect(plugin.isStarted).toBe(false);
plugin.dispose();
expect(plugin.peerCache).toBeUndefined();
});
it("install is idempotent", () => {
const plugin = new BootstrapPlugin();
const node = new NetworkNode();
plugin.install(node);
const cache = plugin.peerCache;
plugin.install(node);
expect(plugin.peerCache).toBe(cache);
});
it("start is idempotent", async () => {
const plugin = new BootstrapPlugin({
strategies: [strat("a", [makePeer("1")])],
retry: {
maxAttempts: 1,
initialDelayMs: 1,
maxDelayMs: 1,
factor: 2,
},
});
const node = new NetworkNode();
plugin.install(node);
await plugin.start();
await plugin.start();
expect(plugin.peerCache?.size).toBe(1);
});
it("stop and dispose are idempotent", () => {
const plugin = new BootstrapPlugin();
plugin.stop();
plugin.stop();
plugin.dispose();
plugin.dispose();
expect(plugin.isStarted).toBe(false);
});
it("start before install throws", async () => {
const plugin = new BootstrapPlugin();
await expect(plugin.start()).rejects.toMatchObject({
code: NET_BOOTSTRAP_CODES.INVALID_OPTIONS,
});
});
it("install after dispose throws", () => {
const plugin = new BootstrapPlugin();
plugin.dispose();
expect(() => plugin.install(new NetworkNode())).toThrow(
NetBootstrapError
);
});
it("start after dispose throws", async () => {
const plugin = new BootstrapPlugin();
plugin.dispose();
await expect(plugin.start()).rejects.toMatchObject({
code: NET_BOOTSTRAP_CODES.INVALID_OPTIONS,
});
});
it("discover before install returns []", async () => {
const plugin = new BootstrapPlugin();
const out = await plugin.discover();
expect(out).toEqual([]);
});
it("integrates with NetworkNode.use → node.start runs bootstrap", async () => {
const plugin = new BootstrapPlugin({
strategies: [strat("a", [makePeer("1"), makePeer("2")])],
retry: {
maxAttempts: 1,
initialDelayMs: 1,
maxDelayMs: 1,
factor: 2,
},
});
const node = new NetworkNode().use(plugin);
await node.start();
expect(plugin.peerCache?.size).toBe(2);
await node.stop();
await node.dispose();
});
});

View File

@@ -0,0 +1,149 @@
import type {
NetworkBus,
NetworkNode,
NetworkPlugin,
} from "@suaveplan/network";
import type { Clock } from "@suaveplan/types";
import { DiscoveryChain } from "../../core/discovery-chain/discovery-chain.js";
import { PeerCache } from "../../core/peer-cache/peer-cache.js";
import { NET_BOOTSTRAP_CODES, NetBootstrapError } from "../../errors/errors.js";
import type {
BootstrapEventMap,
BootstrapPluginConfig,
PeerInfo,
} from "../../types/types.js";
import { BootstrapPluginConfigSchema } from "../../types/types.schemas.js";
const defaultClock: Clock = () => Date.now();
/**
* `BootstrapPlugin` — installs the bootstrap subsystem on a `NetworkNode`.
*
* Implements the `NetworkPlugin` lifecycle (`install` → `start` → `stop` →
* optional `dispose`), all idempotent.
*/
export class BootstrapPlugin implements NetworkPlugin {
public readonly name = "bootstrap";
private readonly config: BootstrapPluginConfig;
private readonly clock: Clock;
private bus: NetworkBus<BootstrapEventMap> | undefined;
private cache: PeerCache | undefined;
private chain: DiscoveryChain | undefined;
private installed = false;
private started = false;
private disposed = false;
public constructor(config: Partial<BootstrapPluginConfig> = {}) {
const parsed = BootstrapPluginConfigSchema.safeParse(config);
if (!parsed.success) {
throw new NetBootstrapError(
NET_BOOTSTRAP_CODES.INVALID_OPTIONS,
"bootstrap-plugin.invalid-options",
{ cause: parsed.error }
);
}
this.config = parsed.data as unknown as BootstrapPluginConfig;
this.clock = (this.config.clock as Clock | undefined) ?? defaultClock;
}
/**
* Register with a `NetworkNode`. Builds the internal cache and chain.
* Idempotent — repeated installs after the first are no-ops.
*/
public install(node: NetworkNode): void {
if (this.disposed) {
throw new NetBootstrapError(
NET_BOOTSTRAP_CODES.INVALID_OPTIONS,
"bootstrap-plugin.disposed"
);
}
if (this.installed) return;
this.bus = node.networkBus as unknown as NetworkBus<BootstrapEventMap>;
this.cache = new PeerCache(this.config.cache);
this.chain = new DiscoveryChain({
strategies: this.config.strategies,
maxConcurrent: this.config.maxConcurrentDiscoveries,
maxCollectedPeers: this.config.maxCollectedPeers,
retry: this.config.retry,
clock: this.clock,
bus: this.bus,
});
this.installed = true;
}
/**
* Run an initial discovery pass and populate the cache. Idempotent —
* subsequent calls without a `stop()` in between are no-ops.
*/
public async start(): Promise<void> {
if (this.disposed) {
throw new NetBootstrapError(
NET_BOOTSTRAP_CODES.INVALID_OPTIONS,
"bootstrap-plugin.disposed"
);
}
if (!this.installed) {
throw new NetBootstrapError(
NET_BOOTSTRAP_CODES.INVALID_OPTIONS,
"bootstrap-plugin.not-installed"
);
}
if (this.started) return;
this.started = true;
await this.discover();
}
/** Stop the plugin. Idempotent. */
public stop(): void {
this.started = false;
}
/** Release resources. Idempotent. */
public dispose(): void {
if (this.disposed) return;
this.cache?.clear();
this.chain = undefined;
this.cache = undefined;
this.bus = undefined;
this.installed = false;
this.started = false;
this.disposed = true;
}
/**
* Run discovery across all strategies and merge new peers into the cache.
*
* @param signal - Optional abort signal threaded into every strategy.
* @returns The peers newly discovered in this round (deduped).
*/
public async discover(signal?: AbortSignal): Promise<PeerInfo[]> {
if (!this.chain || !this.cache) return [];
const peers = await this.chain.discover(
this.config.discoveryTimeoutMs,
signal
);
for (const peer of peers) {
this.cache.add(peer);
}
return peers;
}
/** Underlying peer cache (undefined before `install`). */
public get peerCache(): PeerCache | undefined {
return this.cache;
}
/** Underlying discovery chain (undefined before `install`). */
public get discoveryChain(): DiscoveryChain | undefined {
return this.chain;
}
/** Whether the plugin is currently running. */
public get isStarted(): boolean {
return this.started;
}
}

View File

@@ -0,0 +1,6 @@
export type {
BootstrapEventMap,
BootstrapPluginConfig,
DiscoveryStrategy,
PeerInfo,
} from "../../types/types.js";

View File

@@ -0,0 +1,7 @@
export { BootstrapPlugin } from "./bootstrap-plugin.js";
export type {
BootstrapEventMap,
BootstrapPluginConfig,
DiscoveryStrategy,
PeerInfo,
} from "./bootstrap-plugin.types.js";

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

View File

@@ -0,0 +1,63 @@
import z from "zod";
/**
* Zod boundary schemas for every public option struct in
* `@suaveplan/network-bootstrap`. Schemas validate the *shape* of options;
* callable contracts (`DiscoveryStrategy`, `PeerCacheStorageLike`,
* `MessageMiddleware`) are validated structurally at the type level and via
* minimal runtime guards in the constructors that consume them.
*/
export const PeerInfoSchema = z
.object({
peerId: z.string().min(1),
addresses: z.array(z.string().min(1)).readonly(),
lastSeen: z.number().int().nonnegative(),
source: z.string().min(1),
})
.strict();
export const PeerCacheConfigSchema = z
.object({
maxPeers: z.number().int().positive().default(100),
ttlMs: z.number().int().nonnegative().default(300_000),
persistKey: z.string().min(1).optional(),
storage: z.unknown().optional(),
clock: z.unknown().optional(),
})
.strict();
export const BootstrapRetryConfigSchema = z
.object({
maxAttempts: z.number().int().positive().default(3),
initialDelayMs: z.number().int().nonnegative().default(100),
maxDelayMs: z.number().int().nonnegative().default(5000),
factor: z.number().positive().default(2),
})
.strict();
export const BootstrapPluginConfigSchema = z
.object({
strategies: z.array(z.unknown()).default([]),
cache: PeerCacheConfigSchema.partial().default({}),
discoveryTimeoutMs: z.number().int().positive().default(5000),
maxConcurrentDiscoveries: z.number().int().positive().default(3),
retry: BootstrapRetryConfigSchema.default({
maxAttempts: 3,
initialDelayMs: 100,
maxDelayMs: 5000,
factor: 2,
}),
maxCollectedPeers: z.number().int().positive().default(10),
clock: z.unknown().optional(),
})
.strict();
export const BootstrapMiddlewareConfigSchema = z
.object({
interceptTypes: z
.array(z.string().min(1))
.default(["BOOTSTRAP_REQUEST"]),
clock: z.unknown().optional(),
})
.strict();

View File

@@ -0,0 +1,173 @@
import type { Clock } from "@suaveplan/types";
/**
* Information about a discovered peer in the network.
*/
export interface PeerInfo {
/** Unique identifier for the peer. */
readonly peerId: string;
/** Network addresses where the peer can be reached. */
readonly addresses: readonly string[];
/** Unix timestamp (ms) of when the peer was last seen. */
readonly lastSeen: number;
/** The discovery source that found this peer. */
readonly source: string;
}
/**
* Pluggable discovery strategy. Each strategy represents a different mechanism
* for finding peers (mDNS, DHT, hardcoded bootstrap nodes, cached peers, ...).
*/
export interface DiscoveryStrategy {
/** Human-readable name for this strategy. */
readonly name: string;
/**
* Attempt to discover peers within the given timeout.
*
* @param timeoutMs - Soft timeout — strategies SHOULD return within this
* window. The enclosing chain enforces a hard timeout as well.
* @param signal - Abort signal. Implementations SHOULD short-circuit and
* throw `DOMException("Aborted", "AbortError")` when signalled.
*/
discover(timeoutMs: number, signal?: AbortSignal): Promise<PeerInfo[]>;
/** Check whether this strategy is currently usable. */
isAvailable(signal?: AbortSignal): Promise<boolean>;
}
/**
* Optional persistent backing for the peer cache. Wraps the subset of
* `@suaveplan/storage` `AbstractStorage` that the cache needs so the cache
* does not pull every storage capability as a hard contract.
*/
export interface PeerCacheStorageLike {
get(key: string, signal?: AbortSignal): Promise<Uint8Array | undefined>;
set(key: string, value: Uint8Array, signal?: AbortSignal): Promise<void>;
delete(key: string, signal?: AbortSignal): Promise<boolean>;
}
/**
* Configuration for the peer cache.
*/
export interface PeerCacheConfig {
/** Maximum number of peers to store in the cache. */
readonly maxPeers: number;
/** Time-to-live in milliseconds for cached peer entries. */
readonly ttlMs: number;
/** Optional key prefix used to persist the cache to a storage backend. */
readonly persistKey?: string;
/** Optional storage backend for cache persistence. */
readonly storage?: PeerCacheStorageLike;
/** Injectable clock for deterministic TTL/eviction tests. */
readonly clock?: Clock;
}
/**
* Configuration for retry behaviour around strategy attempts.
*/
export interface BootstrapRetryConfig {
/** Maximum attempts per strategy (including the first call). Default: 3. */
readonly maxAttempts: number;
/** Initial backoff in milliseconds. Default: 100. */
readonly initialDelayMs: number;
/** Backoff cap in milliseconds. Default: 5000. */
readonly maxDelayMs: number;
/** Exponential factor. Default: 2. */
readonly factor: number;
}
/**
* Configuration for the BootstrapPlugin.
*/
export interface BootstrapPluginConfig {
/** Ordered list of discovery strategies to try. */
readonly strategies: readonly DiscoveryStrategy[];
/** Peer cache configuration overrides. */
readonly cache: Partial<PeerCacheConfig>;
/** Maximum time in ms to wait for discovery. */
readonly discoveryTimeoutMs: number;
/** Maximum number of strategies to run concurrently. */
readonly maxConcurrentDiscoveries: number;
/** Retry configuration. */
readonly retry: BootstrapRetryConfig;
/** Maximum peers to collect before stopping early. */
readonly maxCollectedPeers: number;
/** Injectable clock. */
readonly clock?: Clock;
}
/**
* Configuration for the bootstrap middleware.
*/
export interface BootstrapMiddlewareConfig {
/** Message types that the middleware should intercept. */
readonly interceptTypes: readonly string[];
/** Injectable clock for deterministic timestamps. */
readonly clock?: Clock;
}
/**
* A message flowing through the bootstrap middleware pipeline.
*/
export interface MiddlewareMessage {
readonly id: string;
readonly type: string;
readonly payload: unknown;
readonly metadata: Record<string, unknown>;
readonly source: string;
readonly destination?: string;
readonly timestamp: number;
}
/**
* Context provided to middleware handlers.
*/
export interface MiddlewareContext {
readonly remoteNodeId: string;
readonly transport: string;
readonly timestamp: number;
readonly direction: "incoming" | "outgoing";
}
/**
* A message middleware that can intercept incoming and outgoing messages.
*/
export interface MessageMiddleware {
readonly name: string;
readonly priority: number;
onIncoming?(
message: MiddlewareMessage,
context: MiddlewareContext
): Promise<MiddlewareMessage | null>;
onOutgoing?(
message: MiddlewareMessage,
context: MiddlewareContext
): Promise<MiddlewareMessage | null>;
}
/**
* Bus events emitted by the bootstrap plugin.
*/
export interface BootstrapStartedEvent {
readonly strategyCount: number;
readonly timeoutMs: number;
readonly at: number;
}
export interface BootstrapPeerFoundEvent {
readonly strategy: string;
readonly peer: PeerInfo;
readonly at: number;
}
export interface BootstrapExhaustedEvent {
readonly attempted: number;
readonly succeeded: number;
readonly collected: number;
readonly at: number;
}
export type BootstrapEventMap = {
"bootstrap:started": BootstrapStartedEvent;
"bootstrap:peer-found": BootstrapPeerFoundEvent;
"bootstrap:exhausted": BootstrapExhaustedEvent;
};

View File

@@ -0,0 +1,13 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("network-bootstrap dep-graph", () => {
it("must not have circular dependencies", async () => {
const mod = await import("../src/index.js");
expect(mod).toBeDefined();
expect(mod.BootstrapPlugin).toBeDefined();
expect(mod.DiscoveryChain).toBeDefined();
expect(mod.PeerCache).toBeDefined();
expect(mod.createBootstrapMiddleware).toBeDefined();
expect(mod.NET_BOOTSTRAP_CODES).toBeDefined();
});
});

View File

@@ -0,0 +1,8 @@
// Ensure `globalThis.crypto` is available in bare-Node vitest runs.
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

@@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"references": [{ "path": "./tsconfig.lib.json" }]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,41 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/network-bootstrap",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/events",
"@suaveplan/network",
"@suaveplan/retry",
"@suaveplan/storage",
"@suaveplan/types",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,39 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: [
"src/__benchmarks__/**",
"benchmarks/**",
"e2e/**",
"dist/**",
"node_modules/**",
],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"src/**/*.schemas.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,95 @@
{
"name": "@suaveplan/network-discovery-crdt",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"browser": "./dist/index.js",
"description": "Browser-tier CRDT DiscoveryStrategy: dual Yjs / Automerge awareness backends behind a single CRDTDiscovery class. Produces deduped PeerInfo[] for @suaveplan/network-bootstrap chains with @suaveplan/events, @suaveplan/timing, AbortSignal, OTel and Zod boundaries.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist", "src", "README.md"],
"exports": {
"./package.json": "./package.json",
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
},
"./types": {
"import": { "types": "./dist/types/types.d.ts", "default": "./dist/types/types.js" },
"require": { "types": "./dist/types/types.d.ts", "default": "./dist/types/types.js" }
},
"./crdt-discovery": {
"import": { "types": "./dist/core/crdt-discovery/crdt-discovery.d.ts", "default": "./dist/core/crdt-discovery/crdt-discovery.js" },
"require": { "types": "./dist/core/crdt-discovery/crdt-discovery.d.ts", "default": "./dist/core/crdt-discovery/crdt-discovery.js" }
},
"./crdt-peer-manager": {
"import": { "types": "./dist/core/crdt-peer-manager/crdt-peer-manager.d.ts", "default": "./dist/core/crdt-peer-manager/crdt-peer-manager.js" },
"require": { "types": "./dist/core/crdt-peer-manager/crdt-peer-manager.d.ts", "default": "./dist/core/crdt-peer-manager/crdt-peer-manager.js" }
},
"./crdt-intervals": {
"import": { "types": "./dist/core/crdt-intervals/crdt-intervals.d.ts", "default": "./dist/core/crdt-intervals/crdt-intervals.js" },
"require": { "types": "./dist/core/crdt-intervals/crdt-intervals.d.ts", "default": "./dist/core/crdt-intervals/crdt-intervals.js" }
},
"./yjs-provider": {
"import": { "types": "./dist/core/yjs-provider/yjs-provider.d.ts", "default": "./dist/core/yjs-provider/yjs-provider.js" },
"require": { "types": "./dist/core/yjs-provider/yjs-provider.d.ts", "default": "./dist/core/yjs-provider/yjs-provider.js" }
},
"./automerge-provider": {
"import": { "types": "./dist/core/automerge-provider/automerge-provider.d.ts", "default": "./dist/core/automerge-provider/automerge-provider.js" },
"require": { "types": "./dist/core/automerge-provider/automerge-provider.d.ts", "default": "./dist/core/automerge-provider/automerge-provider.js" }
}
},
"scripts": {
"build": "vite build",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"lint": "bunx biome check src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/network": "workspace:^",
"@suaveplan/network-bootstrap": "workspace:^",
"@suaveplan/service-discovery": "workspace:^",
"@suaveplan/timing": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"peerDependencies": {
"yjs": "catalog:network-discovery",
"y-webrtc": "catalog:network-discovery",
"@automerge/automerge-repo": "catalog:network-discovery",
"@automerge/automerge-repo-network-broadcastchannel": "catalog:network-discovery",
"@automerge/automerge-repo-network-websocket": "catalog:network-discovery",
"@automerge/automerge-repo-storage-indexeddb": "catalog:network-discovery"
},
"peerDependenciesMeta": {
"yjs": { "optional": true },
"y-webrtc": { "optional": true },
"@automerge/automerge-repo": { "optional": true },
"@automerge/automerge-repo-network-broadcastchannel": { "optional": true },
"@automerge/automerge-repo-network-websocket": { "optional": true },
"@automerge/automerge-repo-storage-indexeddb": { "optional": true }
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
}
}

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,262 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
FakeAutomergeBroker,
fakeAutomergeRepoFactory,
} from "../../../tests/setup/fake-automerge.js";
import { NET_DISC_CRDT_CODES, NetDiscCRDTError } from "../../errors/errors.js";
import type {
CRDTPeerMetadata,
CRDTProviderCallbacks,
} from "../../types/types.js";
import { AutomergeProviderAdapter } from "./automerge-provider.js";
function makeCallbacks() {
const seen: CRDTPeerMetadata[] = [];
const lost: string[] = [];
let syncs = 0;
const callbacks: CRDTProviderCallbacks = {
onPeer: (m) => seen.push(m),
onPeerLost: (id) => lost.push(id),
onSynced: () => {
syncs += 1;
},
};
return { callbacks, seen, lost, syncCount: () => syncs };
}
async function flushMicrotasks(): Promise<void> {
for (let i = 0; i < 4; i += 1) await Promise.resolve();
}
function makePeer(id: string): CRDTPeerMetadata {
return {
id,
addresses: [`addr-${id}`],
metadata: {},
lastSeen: 1,
provider: "automerge",
};
}
describe("AutomergeProviderAdapter.initialize", () => {
it("returns peer id and fires synced", { timeout: 5000 }, async () => {
const broker = new FakeAutomergeBroker();
const adapter = new AutomergeProviderAdapter(
"demo",
{},
fakeAutomergeRepoFactory(broker)
);
const { callbacks, syncCount } = makeCallbacks();
const id = await adapter.initialize(callbacks);
expect(id).toMatch(/^fake-am-/);
expect(syncCount()).toBe(1);
adapter.destroy();
});
it("maps factory failure to AUTOMERGE_SYNC_FAILED", {
timeout: 5000,
}, async () => {
const broker = new FakeAutomergeBroker();
broker.broken = true;
const adapter = new AutomergeProviderAdapter(
"demo",
{},
fakeAutomergeRepoFactory(broker)
);
const { callbacks } = makeCallbacks();
await expect(adapter.initialize(callbacks)).rejects.toMatchObject({
code: NET_DISC_CRDT_CODES.AUTOMERGE_SYNC_FAILED,
});
});
it("rejects with DISPOSED after destroy", { timeout: 5000 }, async () => {
const broker = new FakeAutomergeBroker();
const adapter = new AutomergeProviderAdapter(
"demo",
{},
fakeAutomergeRepoFactory(broker)
);
adapter.destroy();
const { callbacks } = makeCallbacks();
await expect(adapter.initialize(callbacks)).rejects.toBeInstanceOf(
NetDiscCRDTError
);
});
it("honours pre-aborted signal", { timeout: 5000 }, async () => {
const broker = new FakeAutomergeBroker();
const adapter = new AutomergeProviderAdapter(
"demo",
{},
fakeAutomergeRepoFactory(broker)
);
const ctrl = new AbortController();
ctrl.abort();
const { callbacks } = makeCallbacks();
await expect(
adapter.initialize(callbacks, ctrl.signal)
).rejects.toBeDefined();
adapter.destroy();
});
it("forges AbortError when signal.reason is non-Error", {
timeout: 5000,
}, async () => {
const broker = new FakeAutomergeBroker();
const adapter = new AutomergeProviderAdapter(
"demo",
{},
fakeAutomergeRepoFactory(broker)
);
const ctrl = new AbortController();
Object.defineProperty(ctrl.signal, "aborted", { value: true });
Object.defineProperty(ctrl.signal, "reason", { value: "string" });
const { callbacks } = makeCallbacks();
await expect(
adapter.initialize(callbacks, ctrl.signal)
).rejects.toMatchObject({ name: "AbortError" });
});
});
describe("AutomergeProviderAdapter cross-peer awareness", () => {
it("propagates announces between adapters", { timeout: 5000 }, async () => {
const broker = new FakeAutomergeBroker();
const factory = fakeAutomergeRepoFactory(broker);
const a = new AutomergeProviderAdapter("demo", {}, factory);
const b = new AutomergeProviderAdapter("demo", {}, factory);
const cbA = makeCallbacks();
const cbB = makeCallbacks();
await a.initialize(cbA.callbacks);
await b.initialize(cbB.callbacks);
await flushMicrotasks();
a.announce(makePeer("a-self"));
await flushMicrotasks();
expect(cbB.seen.some((p) => p.id === "a-self")).toBe(true);
a.destroy();
b.destroy();
});
it("forwards peer-disconnected to onPeerLost", {
timeout: 5000,
}, async () => {
const broker = new FakeAutomergeBroker();
const factory = fakeAutomergeRepoFactory(broker);
const a = new AutomergeProviderAdapter("demo", {}, factory);
const b = new AutomergeProviderAdapter("demo", {}, factory);
const cbA = makeCallbacks();
const cbB = makeCallbacks();
const idA = await a.initialize(cbA.callbacks);
await b.initialize(cbB.callbacks);
await flushMicrotasks();
a.destroy();
await flushMicrotasks();
expect(cbB.lost).toContain(idA);
b.destroy();
});
it("listKnownPeers + isConnected work end-to-end", {
timeout: 5000,
}, async () => {
const broker = new FakeAutomergeBroker();
const factory = fakeAutomergeRepoFactory(broker);
const a = new AutomergeProviderAdapter("demo", {}, factory);
const b = new AutomergeProviderAdapter("demo", {}, factory);
const cbA = makeCallbacks();
const cbB = makeCallbacks();
await a.initialize(cbA.callbacks);
await b.initialize(cbB.callbacks);
await flushMicrotasks();
a.announce(makePeer("a-self"));
await flushMicrotasks();
expect(b.listKnownPeers().some((p) => p.id === "a-self")).toBe(true);
expect(a.isConnected).toBe(true);
a.destroy();
expect(a.isConnected).toBe(false);
b.destroy();
});
it("leave is a no-op", { timeout: 5000 }, async () => {
const broker = new FakeAutomergeBroker();
const adapter = new AutomergeProviderAdapter(
"demo",
{},
fakeAutomergeRepoFactory(broker)
);
const cb = makeCallbacks();
await adapter.initialize(cb.callbacks);
adapter.leave();
expect(adapter.isConnected).toBe(true);
adapter.destroy();
});
});
describe("AutomergeProviderAdapter edge cases", () => {
it("announce / leave / listKnownPeers before init are safe", {
timeout: 5000,
}, () => {
const broker = new FakeAutomergeBroker();
const adapter = new AutomergeProviderAdapter(
"demo",
{},
fakeAutomergeRepoFactory(broker)
);
adapter.announce(makePeer("x"));
adapter.leave();
expect(adapter.listKnownPeers()).toEqual([]);
adapter.destroy();
});
it("destroy is idempotent", { timeout: 5000 }, async () => {
const broker = new FakeAutomergeBroker();
const adapter = new AutomergeProviderAdapter(
"demo",
{},
fakeAutomergeRepoFactory(broker)
);
const cb = makeCallbacks();
await adapter.initialize(cb.callbacks);
adapter.destroy();
adapter.destroy();
expect(adapter.isConnected).toBe(false);
});
it("initialize flushes pre-existing known peers via listPeers", {
timeout: 5000,
}, async () => {
const broker = new FakeAutomergeBroker();
const factory = fakeAutomergeRepoFactory(broker);
const a = new AutomergeProviderAdapter("demo", {}, factory);
const cbA = makeCallbacks();
await a.initialize(cbA.callbacks);
a.announce(makePeer("a-payload"));
await flushMicrotasks();
// b joins late — its `listPeers()` already includes a-payload thanks
// to the fake broker's replay, exercising the for-of body in initialize.
const b = new AutomergeProviderAdapter("demo", {}, factory);
const cbB = makeCallbacks();
await b.initialize(cbB.callbacks);
expect(cbB.seen.some((p) => p.id === "a-payload")).toBe(true);
a.destroy();
b.destroy();
});
it("peer event with known metadata routes through onPeer", {
timeout: 5000,
}, async () => {
const broker = new FakeAutomergeBroker();
const factory = fakeAutomergeRepoFactory(broker);
const a = new AutomergeProviderAdapter("demo", {}, factory);
const b = new AutomergeProviderAdapter("demo", {}, factory);
const cbA = makeCallbacks();
const cbB = makeCallbacks();
await a.initialize(cbA.callbacks);
await b.initialize(cbB.callbacks);
await flushMicrotasks();
// b announces — broker stores in a.known + fires peer event on a.
b.announce(makePeer("b-payload"));
await flushMicrotasks();
expect(cbA.seen.some((p) => p.id === "b-payload")).toBe(true);
a.destroy();
b.destroy();
});
});

View File

@@ -0,0 +1,119 @@
import { NET_DISC_CRDT_CODES, NetDiscCRDTError } from "../../errors/errors.js";
import type {
AutomergeConfig,
AutomergeRepoFactory,
AutomergeRepoLike,
CRDTPeerMetadata,
CRDTProviderAdapter,
CRDTProviderCallbacks,
} from "../../types/types.js";
/**
* Automerge-backed `CRDTProviderAdapter`. Wraps an `AutomergeRepoLike` built
* by the caller-supplied `AutomergeRepoFactory`. Owns the repo peer-event
* handlers and the destroy semantics.
*/
export class AutomergeProviderAdapter implements CRDTProviderAdapter {
public readonly kind = "automerge" as const;
private readonly room: string;
private readonly opts: AutomergeConfig;
private readonly factory: AutomergeRepoFactory;
private repo: AutomergeRepoLike | null = null;
private myPeerId: string | null = null;
private destroyed = false;
public constructor(
room: string,
opts: AutomergeConfig,
factory: AutomergeRepoFactory
) {
this.room = room;
this.opts = opts;
this.factory = factory;
}
public async initialize(
callbacks: CRDTProviderCallbacks,
signal?: AbortSignal
): Promise<string> {
if (signal?.aborted) throw toAbortError(signal);
if (this.destroyed) {
throw new NetDiscCRDTError(
NET_DISC_CRDT_CODES.DISPOSED,
"errors.crdt.disposed"
);
}
let built: AutomergeRepoLike;
try {
built = this.factory(this.room, this.opts);
} catch (err) {
throw new NetDiscCRDTError(
NET_DISC_CRDT_CODES.AUTOMERGE_SYNC_FAILED,
"errors.crdt.automerge_init_failed",
{ cause: err }
);
}
this.repo = built;
this.myPeerId = built.peerId;
built.on("peer", (e) => {
const matches = built.listPeers().filter((p) => p.id === e.peerId);
if (matches.length > 0) {
for (const m of matches) callbacks.onPeer(m);
}
});
built.on("peer-disconnected", (e) => {
callbacks.onPeerLost(e.peerId);
});
// Automerge repos are "synced" once at least one network adapter is
// ready; we surface that immediately so the orchestrator can flush.
callbacks.onSynced();
for (const peer of built.listPeers()) callbacks.onPeer(peer);
return this.myPeerId;
}
public announce(metadata: CRDTPeerMetadata): void {
if (this.repo === null) return;
this.repo.announce(metadata);
}
public leave(): void {
if (this.repo === null || this.myPeerId === null) return;
// Best-effort — Automerge has no first-class "leave"; the cleanup
// loop on remote peers will eventually drop us via `lastSeen` TTL.
}
public listKnownPeers(): readonly CRDTPeerMetadata[] {
if (this.repo === null) return [];
return this.repo.listPeers();
}
public destroy(): void {
if (this.destroyed) return;
this.destroyed = true;
if (this.repo !== null) {
try {
this.repo.shutdown();
} catch {
/* swallow */
}
this.repo = null;
}
this.myPeerId = null;
}
public get isConnected(): boolean {
return this.repo !== null;
}
}
function toAbortError(signal: AbortSignal): Error {
if (signal.reason instanceof Error) return signal.reason;
const err = new Error("Aborted");
err.name = "AbortError";
return err;
}

View File

@@ -0,0 +1 @@
export { AutomergeProviderAdapter } from "./automerge-provider.js";

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,584 @@
import { EventBus } from "@suaveplan/events";
import { ServiceRegistry } from "@suaveplan/service-discovery";
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import {
FakeAutomergeBroker,
fakeAutomergeRepoFactory,
} from "../../../tests/setup/fake-automerge.js";
import {
FakeYjsBroker,
fakeYjsDocFactory,
flushMicrotasks,
} from "../../../tests/setup/fake-yjs.js";
import { NET_DISC_CRDT_CODES, NetDiscCRDTError } from "../../errors/errors.js";
import type { CRDTDiscoveryConfig, CRDTEventMap } from "../../types/types.js";
import { CRDTDiscovery } from "./crdt-discovery.js";
function yjsConfig(
broker: FakeYjsBroker,
over: Partial<CRDTDiscoveryConfig> = {}
): CRDTDiscoveryConfig {
return {
provider: "yjs",
room: "demo",
yjs: { signaling: ["wss://x.invalid"] },
yjsDocFactory: fakeYjsDocFactory(broker),
...over,
};
}
function automergeConfig(
broker: FakeAutomergeBroker,
over: Partial<CRDTDiscoveryConfig> = {}
): CRDTDiscoveryConfig {
return {
provider: "automerge",
room: "demo",
automerge: {},
automergeRepoFactory: fakeAutomergeRepoFactory(broker),
...over,
};
}
describe("CRDTDiscovery constructor — rejection paths", () => {
it("rejects malformed config with INVALID_OPTIONS", {
timeout: 5000,
}, () => {
let caught: unknown;
try {
new CRDTDiscovery({
provider: "yjs",
room: "",
yjs: { signaling: ["wss://x"] },
yjsDocFactory: () => ({}) as never,
} as never);
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(NetDiscCRDTError);
expect((caught as NetDiscCRDTError).code).toBe(
NET_DISC_CRDT_CODES.INVALID_OPTIONS
);
});
it("rejects yjs without yjs block", { timeout: 5000 }, () => {
const broker = new FakeYjsBroker();
let caught: unknown;
try {
new CRDTDiscovery({
provider: "yjs",
room: "demo",
yjsDocFactory: fakeYjsDocFactory(broker),
} as CRDTDiscoveryConfig);
} catch (e) {
caught = e;
}
expect((caught as NetDiscCRDTError).code).toBe(
NET_DISC_CRDT_CODES.INVALID_OPTIONS
);
});
it("rejects yjs without yjsDocFactory", { timeout: 5000 }, () => {
let caught: unknown;
try {
new CRDTDiscovery({
provider: "yjs",
room: "demo",
yjs: { signaling: ["wss://x"] },
} as CRDTDiscoveryConfig);
} catch (e) {
caught = e;
}
expect((caught as NetDiscCRDTError).code).toBe(
NET_DISC_CRDT_CODES.INVALID_OPTIONS
);
});
it("rejects automerge without automerge block", { timeout: 5000 }, () => {
const broker = new FakeAutomergeBroker();
let caught: unknown;
try {
new CRDTDiscovery({
provider: "automerge",
room: "demo",
automergeRepoFactory: fakeAutomergeRepoFactory(broker),
} as CRDTDiscoveryConfig);
} catch (e) {
caught = e;
}
expect((caught as NetDiscCRDTError).code).toBe(
NET_DISC_CRDT_CODES.INVALID_OPTIONS
);
});
it("rejects automerge without automergeRepoFactory", {
timeout: 5000,
}, () => {
let caught: unknown;
try {
new CRDTDiscovery({
provider: "automerge",
room: "demo",
automerge: {},
} as CRDTDiscoveryConfig);
} catch (e) {
caught = e;
}
expect((caught as NetDiscCRDTError).code).toBe(
NET_DISC_CRDT_CODES.INVALID_OPTIONS
);
});
it("applies Zod defaults", { timeout: 5000 }, () => {
const broker = new FakeYjsBroker();
const d = new CRDTDiscovery(yjsConfig(broker));
expect(d.config.interval).toBe(30_000);
expect(d.config.timeout).toBe(60_000);
expect(d.config.maxPeers).toBe(100);
d.dispose();
});
it("name is 'crdt'", { timeout: 5000 }, () => {
const broker = new FakeYjsBroker();
const d = new CRDTDiscovery(yjsConfig(broker));
expect(d.name).toBe("crdt");
d.dispose();
});
});
describe("CRDTDiscovery lifecycle (Yjs)", () => {
it("start emits crdt:started", { timeout: 5000 }, async () => {
const broker = new FakeYjsBroker();
const bus = new EventBus<CRDTEventMap>();
const started: string[] = [];
bus.on("crdt:started", (e) => started.push(e.peerId));
const d = new CRDTDiscovery(yjsConfig(broker, { bus }));
await d.start();
expect(d.getMyPeerId()).not.toBeNull();
expect(started).toHaveLength(1);
await d.stop();
});
it("start is idempotent", { timeout: 5000 }, async () => {
const broker = new FakeYjsBroker();
const d = new CRDTDiscovery(yjsConfig(broker));
await d.start();
await d.start();
expect(await d.isAvailable()).toBe(true);
await d.stop();
});
it("start with factory throw emits crdt:sync-failed", {
timeout: 5000,
}, async () => {
const bus = new EventBus<CRDTEventMap>();
const reasons: string[] = [];
bus.on("crdt:sync-failed", (e) => reasons.push(e.reason));
const d = new CRDTDiscovery({
provider: "yjs",
room: "demo",
yjs: { signaling: ["wss://x.invalid"] },
yjsDocFactory: () => {
throw new Error("boom");
},
bus,
});
await expect(d.start()).rejects.toBeDefined();
expect(reasons.length).toBeGreaterThanOrEqual(1);
d.dispose();
});
it("start throws DISPOSED after dispose", { timeout: 5000 }, async () => {
const broker = new FakeYjsBroker();
const d = new CRDTDiscovery(yjsConfig(broker));
d.dispose();
await expect(d.start()).rejects.toMatchObject({
code: NET_DISC_CRDT_CODES.DISPOSED,
});
});
it("stop is a no-op when not running", { timeout: 5000 }, async () => {
const broker = new FakeYjsBroker();
const d = new CRDTDiscovery(yjsConfig(broker));
await d.stop();
expect(await d.isAvailable()).toBe(false);
d.dispose();
});
it("stop with aborted signal skips leave", { timeout: 5000 }, async () => {
const broker = new FakeYjsBroker();
const d = new CRDTDiscovery(yjsConfig(broker));
await d.start();
const ctrl = new AbortController();
ctrl.abort();
await d.stop(ctrl.signal);
expect(d.getMyPeerId()).toBeNull();
});
it("dispose is idempotent", { timeout: 5000 }, async () => {
const broker = new FakeYjsBroker();
const d = new CRDTDiscovery(yjsConfig(broker));
await d.start();
d.dispose();
d.dispose();
expect(await d.isAvailable()).toBe(false);
});
});
describe("CRDTDiscovery lifecycle (Automerge)", () => {
it("start emits crdt:started with automerge provider", {
timeout: 5000,
}, async () => {
const broker = new FakeAutomergeBroker();
const bus = new EventBus<CRDTEventMap>();
let captured = "";
bus.on("crdt:started", (e) => {
captured = e.provider;
});
const d = new CRDTDiscovery(automergeConfig(broker, { bus }));
await d.start();
expect(captured).toBe("automerge");
expect(d.getProvider()).toBe("automerge");
await d.stop();
});
it("works end-to-end between two automerge peers", {
timeout: 5000,
}, async () => {
const broker = new FakeAutomergeBroker();
const a = new CRDTDiscovery(automergeConfig(broker));
const b = new CRDTDiscovery(automergeConfig(broker));
await a.start();
await b.start();
await flushMicrotasks();
// a explicitly announces b's id so a's peer-manager learns it.
await a.announce({
peerId: b.getMyPeerId()!,
addresses: ["b-addr"],
lastSeen: 1,
source: "crdt",
});
await flushMicrotasks();
const peers = await a.discover(1000);
expect(peers.map((p) => p.peerId)).toContain(b.getMyPeerId());
await a.stop();
await b.stop();
});
});
describe("CRDTDiscovery discover + announce + signals", () => {
it("discover throws when disposed", { timeout: 5000 }, async () => {
const broker = new FakeYjsBroker();
const d = new CRDTDiscovery(yjsConfig(broker));
await d.start();
d.dispose();
await expect(d.discover(1000)).rejects.toMatchObject({
code: NET_DISC_CRDT_CODES.DISPOSED,
});
});
it("discover honours pre-aborted signal", { timeout: 5000 }, async () => {
const broker = new FakeYjsBroker();
const d = new CRDTDiscovery(yjsConfig(broker));
await d.start();
const ctrl = new AbortController();
ctrl.abort();
await expect(d.discover(1000, ctrl.signal)).rejects.toBeDefined();
await d.stop();
});
it("isAvailable honours pre-aborted signal", {
timeout: 5000,
}, async () => {
const broker = new FakeYjsBroker();
const d = new CRDTDiscovery(yjsConfig(broker));
const ctrl = new AbortController();
ctrl.abort();
await expect(d.isAvailable(ctrl.signal)).rejects.toBeDefined();
d.dispose();
});
it("announce throws when not running", { timeout: 5000 }, async () => {
const broker = new FakeYjsBroker();
const d = new CRDTDiscovery(yjsConfig(broker));
await expect(
d.announce({
peerId: "x",
addresses: [],
lastSeen: 0,
source: "crdt",
})
).rejects.toMatchObject({
code: NET_DISC_CRDT_CODES.PROVIDER_FAILED,
});
d.dispose();
});
it("announce throws DISPOSED after dispose", {
timeout: 5000,
}, async () => {
const broker = new FakeYjsBroker();
const d = new CRDTDiscovery(yjsConfig(broker));
await d.start();
d.dispose();
await expect(
d.announce({
peerId: "x",
addresses: [],
lastSeen: 0,
source: "crdt",
})
).rejects.toMatchObject({
code: NET_DISC_CRDT_CODES.DISPOSED,
});
});
it("announce honours pre-aborted signal", { timeout: 5000 }, async () => {
const broker = new FakeYjsBroker();
const d = new CRDTDiscovery(yjsConfig(broker));
await d.start();
const ctrl = new AbortController();
ctrl.abort();
await expect(
d.announce(
{ peerId: "x", addresses: [], lastSeen: 0, source: "crdt" },
ctrl.signal
)
).rejects.toBeDefined();
await d.stop();
});
it("forges AbortError when signal.reason is non-Error", {
timeout: 5000,
}, async () => {
const broker = new FakeYjsBroker();
const d = new CRDTDiscovery(yjsConfig(broker));
await d.start();
const ctrl = new AbortController();
Object.defineProperty(ctrl.signal, "aborted", { value: true });
Object.defineProperty(ctrl.signal, "reason", { value: "string" });
await expect(d.discover(1000, ctrl.signal)).rejects.toMatchObject({
name: "AbortError",
});
await d.stop();
});
it("integrates with a ServiceRegistry", { timeout: 5000 }, async () => {
const broker = new FakeYjsBroker();
const registry = new ServiceRegistry();
const a = new CRDTDiscovery(yjsConfig(broker, { registry }));
const b = new CRDTDiscovery(yjsConfig(broker));
await a.start();
await b.start();
// b announces; broker propagates to a; a's peer-manager registers.
await flushMicrotasks();
await b.announce({
peerId: b.getMyPeerId()!,
addresses: ["b-addr"],
lastSeen: 1,
source: "crdt",
});
await flushMicrotasks();
expect(registry.get(`crdt:demo:${b.getMyPeerId()}`)).toBeDefined();
await a.stop();
await b.stop();
});
});
describe("CRDTDiscovery status accessors", () => {
it("getStatus / getSyncStatus / getPeerCount work", {
timeout: 5000,
}, async () => {
const broker = new FakeYjsBroker();
const d = new CRDTDiscovery(yjsConfig(broker));
await d.start();
const status = d.getStatus();
expect(status.provider).toBe("yjs");
expect(status.connected).toBe(true);
const sync = d.getSyncStatus();
expect(sync.synced).toBe(true);
expect(d.getPeerCount()).toBe(0);
expect(d.getPeers()).toEqual([]);
await d.stop();
});
it("getSyncStatus when not connected", { timeout: 5000 }, () => {
const broker = new FakeYjsBroker();
const d = new CRDTDiscovery(yjsConfig(broker));
const sync = d.getSyncStatus();
expect(sync.synced).toBe(false);
d.dispose();
});
});
describe("CRDTDiscovery room-full + interval coverage", () => {
it("emits crdt:sync-failed when peer-manager rejects on room-full", {
timeout: 5000,
}, async () => {
const broker = new FakeAutomergeBroker();
const bus = new EventBus<CRDTEventMap>();
const reasons: string[] = [];
bus.on("crdt:sync-failed", (e) => reasons.push(e.reason));
const a = new CRDTDiscovery(
automergeConfig(broker, { bus, maxPeers: 1 })
);
const b = new CRDTDiscovery(automergeConfig(broker));
const c = new CRDTDiscovery(automergeConfig(broker));
await a.start();
await b.start();
await c.start();
await flushMicrotasks();
await b.announce({
peerId: b.getMyPeerId()!,
addresses: [],
lastSeen: 0,
source: "crdt",
});
await c.announce({
peerId: c.getMyPeerId()!,
addresses: [],
lastSeen: 0,
source: "crdt",
});
await flushMicrotasks();
expect(reasons).toContain("room_full");
await a.stop();
await b.stop();
await c.stop();
});
it("fires periodic loops with small intervals", {
timeout: 5000,
}, async () => {
const broker = new FakeYjsBroker();
const d = new CRDTDiscovery(
yjsConfig(broker, {
interval: 1,
heartbeatInterval: 1,
cleanupInterval: 1,
timeout: 1,
})
);
await d.start();
// Seed a stale peer directly so the cleanup loop drops it.
const peers = (
d as unknown as { peers: { getMap: () => Map<string, unknown> } }
).peers.getMap();
peers.set("stale", {
id: "stale",
addresses: [],
metadata: {},
lastSeen: -1_000_000,
provider: "yjs",
} as never);
await new Promise((r) => setTimeout(r, 30));
expect(d.getMyPeerId()).not.toBeNull();
await d.stop();
});
it("selfMetadata returns empty id before start", { timeout: 5000 }, () => {
const broker = new FakeYjsBroker();
const d = new CRDTDiscovery(yjsConfig(broker));
const meta = (
d as unknown as { selfMetadata: () => { id: string } }
).selfMetadata();
expect(meta.id).toBe("");
d.dispose();
});
it("start with factory throwing non-Error emits string reason", {
timeout: 5000,
}, async () => {
const bus = new EventBus<CRDTEventMap>();
const reasons: string[] = [];
bus.on("crdt:sync-failed", (e) => reasons.push(e.reason));
const d = new CRDTDiscovery({
provider: "yjs",
room: "demo",
yjs: { signaling: ["wss://x.invalid"] },
yjsDocFactory: () => {
// Throw a string — not an Error subclass. The orchestrator's
// emit must use `String(err)` to surface a reason.
// eslint-disable-next-line @typescript-eslint/no-throw-literal
throw "stringly-bad";
},
bus,
});
await expect(d.start()).rejects.toBeDefined();
// The adapter wraps factory throws in NetDiscCRDTError before the
// orchestrator's catch — orchestrator sees an Error, but the cause
// chain holds the raw string. Reason therefore comes from `.message`,
// not the string fallback.
expect(reasons).toHaveLength(1);
d.dispose();
});
it("re-throws unexpected admit errors from handlePeer", {
timeout: 5000,
}, async () => {
const broker = new FakeAutomergeBroker();
const d = new CRDTDiscovery(automergeConfig(broker));
await d.start();
// Swap admit with a non-PEER_REJECTED throw — handlePeer must rethrow.
const peers = (
d as unknown as {
peers: { admit: (m: unknown) => unknown };
}
).peers;
const origAdmit = peers.admit.bind(peers);
peers.admit = () => {
throw new Error("unexpected");
};
let thrown: unknown;
try {
(d as unknown as { handlePeer: (m: unknown) => void }).handlePeer({
id: "x",
addresses: [],
metadata: {},
lastSeen: 0,
provider: "automerge",
});
} catch (e) {
thrown = e;
}
expect((thrown as Error).message).toBe("unexpected");
peers.admit = origAdmit;
await d.stop();
});
});
describe("CRDTDiscovery property tests", () => {
it("peer-dedup is idempotent under arbitrary admit order", {
timeout: 10_000,
}, async () => {
await fc.assert(
fc.asyncProperty(
fc.array(
fc.record({
id: fc
.string({ minLength: 1, maxLength: 8 })
.filter((s) => s.length > 0),
}),
{ minLength: 0, maxLength: 20 }
),
async (ops) => {
const broker = new FakeAutomergeBroker();
const d = new CRDTDiscovery(automergeConfig(broker));
await d.start();
for (const op of ops) {
await d.announce({
peerId: op.id,
addresses: [],
lastSeen: 0,
source: "crdt",
});
}
await flushMicrotasks();
await d.stop();
}
),
{ numRuns: 8 }
);
});
});

View File

@@ -0,0 +1,363 @@
import type { DiscoveryStrategy, PeerInfo } from "@suaveplan/network-bootstrap";
import type { Clock } from "@suaveplan/types";
import { NET_DISC_CRDT_CODES, NetDiscCRDTError } from "../../errors/errors.js";
import { withProviderSyncSpan, withStartSpan } from "../../otel/otel.js";
import type {
CRDTConnectionStatus,
CRDTDiscoveryConfig,
CRDTPeerMetadata,
CRDTProvider,
CRDTProviderAdapter,
CRDTSyncStatus,
ResolvedCRDTDiscoveryConfig,
} from "../../types/types.js";
import { CRDTDiscoveryConfigSchema } from "../../types/types.schemas.js";
import { AutomergeProviderAdapter } from "../automerge-provider/automerge-provider.js";
import { CRDTIntervals } from "../crdt-intervals/crdt-intervals.js";
import { CRDTPeerManager } from "../crdt-peer-manager/crdt-peer-manager.js";
import { YjsProviderAdapter } from "../yjs-provider/yjs-provider.js";
const defaultClock: Clock = () => Date.now();
/**
* Browser-tier CRDT discovery strategy implementing
* `@suaveplan/network-bootstrap`'s `DiscoveryStrategy` contract. Wraps a
* single provider adapter (Yjs or Automerge — chosen at construction time)
* plus a `CRDTPeerManager` and a `CRDTIntervals` scheduler.
*/
export class CRDTDiscovery implements DiscoveryStrategy {
public readonly name = "crdt";
public readonly config: ResolvedCRDTDiscoveryConfig;
private readonly clock: Clock;
private readonly peers: CRDTPeerManager;
private readonly adapter: CRDTProviderAdapter;
private readonly intervals: CRDTIntervals;
private running = false;
private disposed = false;
private myPeerId: string | null = null;
private documentVersion = 0;
private lastSync = 0;
public constructor(
rawConfig: CRDTDiscoveryConfig,
clock: Clock = defaultClock
) {
const parsed = CRDTDiscoveryConfigSchema.safeParse(rawConfig);
if (!parsed.success) {
throw new NetDiscCRDTError(
NET_DISC_CRDT_CODES.INVALID_OPTIONS,
"errors.crdt.invalid_options",
{ cause: parsed.error }
);
}
const data = parsed.data;
if (data.provider === "yjs") {
if (data.yjs === undefined) {
throw new NetDiscCRDTError(
NET_DISC_CRDT_CODES.INVALID_OPTIONS,
"errors.crdt.yjs_config_required"
);
}
if (rawConfig.yjsDocFactory === undefined) {
throw new NetDiscCRDTError(
NET_DISC_CRDT_CODES.INVALID_OPTIONS,
"errors.crdt.yjs_factory_required"
);
}
} else {
if (data.automerge === undefined) {
throw new NetDiscCRDTError(
NET_DISC_CRDT_CODES.INVALID_OPTIONS,
"errors.crdt.automerge_config_required"
);
}
if (rawConfig.automergeRepoFactory === undefined) {
throw new NetDiscCRDTError(
NET_DISC_CRDT_CODES.INVALID_OPTIONS,
"errors.crdt.automerge_factory_required"
);
}
}
this.clock = clock;
this.config = {
provider: data.provider,
room: data.room,
yjs: data.yjs,
automerge: data.automerge,
interval: data.interval,
timeout: data.timeout,
heartbeatInterval: data.heartbeatInterval,
cleanupInterval: data.cleanupInterval,
maxPeers: data.maxPeers,
iceServers: rawConfig.iceServers,
yjsDocFactory: rawConfig.yjsDocFactory,
automergeRepoFactory: rawConfig.automergeRepoFactory,
bus: rawConfig.bus,
registry: rawConfig.registry,
};
this.peers = new CRDTPeerManager({
clock: this.clock,
bus: this.config.bus,
registry: this.config.registry,
provider: this.config.provider,
maxPeers: this.config.maxPeers,
room: this.config.room,
});
if (data.provider === "yjs") {
// Guards above have already enforced these are defined.
this.adapter = new YjsProviderAdapter(
this.config.room,
this.config.yjs as NonNullable<typeof this.config.yjs>,
this.config.yjsDocFactory as NonNullable<
typeof this.config.yjsDocFactory
>
);
} else {
this.adapter = new AutomergeProviderAdapter(
this.config.room,
this.config.automerge as NonNullable<
typeof this.config.automerge
>,
this.config.automergeRepoFactory as NonNullable<
typeof this.config.automergeRepoFactory
>
);
}
this.intervals = new CRDTIntervals({
clock: this.clock,
announceIntervalMs: this.config.interval,
heartbeatIntervalMs: this.config.heartbeatInterval,
cleanupIntervalMs: this.config.cleanupInterval,
timeoutMs: this.config.timeout,
provider: this.config.provider,
adapter: this.adapter,
peers: this.peers.getMap(),
getMyPeerId: () => this.myPeerId,
isRunning: () => this.running,
selfMetadata: () => this.selfMetadata(),
handlePeerLost: (id) => this.handlePeerLost(id),
});
}
public async start(signal?: AbortSignal): Promise<void> {
if (this.disposed) {
throw new NetDiscCRDTError(
NET_DISC_CRDT_CODES.DISPOSED,
"errors.crdt.disposed"
);
}
if (this.running) return;
await withStartSpan(async (span) => {
span.setAttribute("provider", this.config.provider);
span.setAttribute("room", this.config.room);
try {
this.myPeerId = await withProviderSyncSpan(
this.config.provider,
() =>
this.adapter.initialize(
{
onPeer: (m) => this.handlePeer(m),
onPeerLost: (id) => this.handlePeerLost(id),
onSynced: () => {
this.lastSync = this.clock();
},
},
signal
)
);
} catch (err) {
this.config.bus?.emit("crdt:sync-failed", {
reason: (err as Error).message,
provider: this.config.provider,
at: this.clock(),
});
throw err;
}
span.setAttribute("peer_id", this.myPeerId);
this.running = true;
this.intervals.start();
this.adapter.announce(this.selfMetadata());
this.config.bus?.emit("crdt:started", {
peerId: this.myPeerId,
provider: this.config.provider,
room: this.config.room,
at: this.clock(),
});
});
}
public async stop(signal?: AbortSignal): Promise<void> {
if (!this.running) return;
this.running = false;
if (signal?.aborted) {
this.intervals.stop();
this.adapter.destroy();
this.peers.clear();
this.myPeerId = null;
return;
}
try {
this.adapter.leave();
} catch {
/* swallow */
}
this.intervals.stop();
this.adapter.destroy();
this.peers.clear();
this.myPeerId = null;
}
public async discover(
_timeoutMs: number,
signal?: AbortSignal
): Promise<PeerInfo[]> {
if (signal?.aborted) throw toAbortError(signal);
if (this.disposed) {
throw new NetDiscCRDTError(
NET_DISC_CRDT_CODES.DISPOSED,
"errors.crdt.disposed"
);
}
return this.peers.list().map((m) => ({
peerId: m.id,
addresses: [...m.addresses],
lastSeen: m.lastSeen,
source: this.name,
}));
}
public async isAvailable(signal?: AbortSignal): Promise<boolean> {
if (signal?.aborted) throw toAbortError(signal);
return this.running && !this.disposed;
}
public async announce(
peerInfo: PeerInfo,
signal?: AbortSignal
): Promise<void> {
if (signal?.aborted) throw toAbortError(signal);
if (this.disposed) {
throw new NetDiscCRDTError(
NET_DISC_CRDT_CODES.DISPOSED,
"errors.crdt.disposed"
);
}
if (!this.running) {
throw new NetDiscCRDTError(
NET_DISC_CRDT_CODES.PROVIDER_FAILED,
"errors.crdt.not_running"
);
}
const metadata: CRDTPeerMetadata = {
id: peerInfo.peerId,
addresses: [...peerInfo.addresses],
metadata: {},
lastSeen: this.clock(),
provider: this.config.provider,
version: this.documentVersion++,
};
this.adapter.announce(metadata);
}
/** Local peer id, or `null` before `start()` completes. */
public getMyPeerId(): string | null {
return this.myPeerId;
}
/** Snapshot of the live peer set. */
public getPeers(): readonly CRDTPeerMetadata[] {
return this.peers.list();
}
/** Number of tracked peers. */
public getPeerCount(): number {
return this.peers.size();
}
/** Provider in use. */
public getProvider(): CRDTProvider {
return this.config.provider;
}
/** Current sync status. */
public getSyncStatus(): CRDTSyncStatus {
const peerCount = this.peers.size();
const connected = this.adapter.isConnected;
return {
synced: connected,
syncedPeers: connected ? peerCount : 0,
pendingPeers: connected ? 0 : peerCount,
version: this.documentVersion,
lastSync: this.lastSync,
};
}
/** Overall connection status. */
public getStatus(): CRDTConnectionStatus {
return {
provider: this.config.provider,
connected: this.running && this.adapter.isConnected,
peerCount: this.peers.size(),
syncStatus: this.getSyncStatus(),
};
}
/** Dispose every resource. After `dispose()` the strategy is unusable. */
public dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.running = false;
this.intervals.stop();
this.adapter.destroy();
this.peers.clear();
this.myPeerId = null;
}
private handlePeer(metadata: CRDTPeerMetadata): void {
try {
this.peers.admit(metadata);
} catch (err) {
if (
err instanceof NetDiscCRDTError &&
err.code === NET_DISC_CRDT_CODES.PEER_REJECTED
) {
// Room full — surface via the bus but do not crash.
this.config.bus?.emit("crdt:sync-failed", {
reason: "room_full",
provider: this.config.provider,
at: this.clock(),
});
return;
}
throw err;
}
}
private handlePeerLost(peerId: string): void {
this.peers.drop(peerId);
}
private selfMetadata(): CRDTPeerMetadata {
return {
id: this.myPeerId === null ? "" : this.myPeerId,
addresses: [],
metadata: {},
lastSeen: this.clock(),
provider: this.config.provider,
version: this.documentVersion,
};
}
}
function toAbortError(signal: AbortSignal): Error {
if (signal.reason instanceof Error) return signal.reason;
const err = new Error("Aborted");
err.name = "AbortError";
return err;
}

View File

@@ -0,0 +1 @@
export { CRDTDiscovery } from "./crdt-discovery.js";

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,282 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Scheduler } from "@suaveplan/timing";
import type { Clock } from "@suaveplan/types";
import type {
CRDTPeerMetadata,
CRDTProviderAdapter,
} from "../../types/types.js";
import { CRDTIntervals } from "./crdt-intervals.js";
class CapturingAdapter implements CRDTProviderAdapter {
public readonly kind = "yjs" as const;
public readonly announces: CRDTPeerMetadata[] = [];
public destroyed = false;
public async initialize(): Promise<string> {
return "fake";
}
public announce(m: CRDTPeerMetadata): void {
this.announces.push(m);
}
public leave(): void {}
public listKnownPeers(): readonly CRDTPeerMetadata[] {
return [];
}
public destroy(): void {
this.destroyed = true;
}
public get isConnected(): boolean {
return true;
}
}
/** Immediate-tick scheduler — every `schedule(fn)` fires synchronously on
* the very next microtask, exactly once. */
function immediateScheduler(): Scheduler {
let disposed = false;
return {
schedule(fn) {
if (disposed) return { id: "x", scheduledAt: 0, delayMs: 0 };
queueMicrotask(() => {
if (!disposed) fn();
});
return { id: "x", scheduledAt: 0, delayMs: 0 };
},
cancel() {
return false;
},
async drain() {},
dispose() {
disposed = true;
},
get pendingCount() {
return 0;
},
get disposed() {
return disposed;
},
};
}
/** Manual-tick scheduler — fn buffered into `pending`; caller drives ticks. */
function manualScheduler(): Scheduler & { runOnce: () => void } {
const pending: Array<() => void> = [];
let disposed = false;
return {
schedule(fn) {
pending.push(fn);
return { id: "x", scheduledAt: 0, delayMs: 0 };
},
cancel() {
return false;
},
async drain() {},
dispose() {
disposed = true;
},
get pendingCount() {
return pending.length;
},
get disposed() {
return disposed;
},
runOnce() {
const fn = pending.shift();
if (fn !== undefined) fn();
},
};
}
function makeDeps(
over: Partial<{
adapter: CRDTProviderAdapter;
peers: Map<string, CRDTPeerMetadata>;
clock: Clock;
isRunning: () => boolean;
getMyPeerId: () => string | null;
timeoutMs: number;
handlePeerLost: (id: string) => void;
}> = {}
) {
const adapter = over.adapter ?? new CapturingAdapter();
const peers = over.peers ?? new Map<string, CRDTPeerMetadata>();
const lost: string[] = [];
return {
deps: {
clock: over.clock ?? (() => 1000),
announceIntervalMs: 1,
heartbeatIntervalMs: 1,
cleanupIntervalMs: 1,
timeoutMs: over.timeoutMs ?? 100,
provider: "yjs" as const,
adapter,
peers,
getMyPeerId: over.getMyPeerId ?? (() => "self"),
isRunning: over.isRunning ?? (() => true),
selfMetadata: (): CRDTPeerMetadata => ({
id: "self",
addresses: [],
metadata: {},
lastSeen: 0,
provider: "yjs",
}),
handlePeerLost:
over.handlePeerLost ?? ((id: string) => lost.push(id)),
},
lost,
adapter: adapter as CapturingAdapter,
};
}
describe("CRDTIntervals lifecycle", () => {
it("start fires announce / heartbeat ticks", {
timeout: 5000,
}, async () => {
const { deps, adapter } = makeDeps();
const intervals = new CRDTIntervals(deps, () => immediateScheduler());
intervals.start();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
expect(adapter.announces.length).toBeGreaterThanOrEqual(2);
intervals.stop();
});
it("start is idempotent after stop", { timeout: 5000 }, () => {
const { deps } = makeDeps();
const intervals = new CRDTIntervals(deps, () => immediateScheduler());
intervals.start();
intervals.stop();
intervals.start();
expect(intervals.isStopped).toBe(true);
intervals.stop();
});
it("stop is idempotent", { timeout: 5000 }, () => {
const { deps } = makeDeps();
const intervals = new CRDTIntervals(deps, () => immediateScheduler());
intervals.stop();
intervals.stop();
expect(intervals.isStopped).toBe(true);
});
it("announce ticks no-op when not running", { timeout: 5000 }, async () => {
const { deps, adapter } = makeDeps({ isRunning: () => false });
const intervals = new CRDTIntervals(deps, () => immediateScheduler());
intervals.start();
await Promise.resolve();
await Promise.resolve();
expect(adapter.announces).toEqual([]);
intervals.stop();
});
it("announce ticks no-op when myPeerId is null", {
timeout: 5000,
}, async () => {
const { deps, adapter } = makeDeps({ getMyPeerId: () => null });
const intervals = new CRDTIntervals(deps, () => immediateScheduler());
intervals.start();
await Promise.resolve();
await Promise.resolve();
expect(adapter.announces).toEqual([]);
intervals.stop();
});
});
describe("CRDTIntervals cleanup loop", () => {
it("drops stale peers via handlePeerLost", { timeout: 5000 }, async () => {
const peers = new Map<string, CRDTPeerMetadata>();
peers.set("stale", {
id: "stale",
addresses: [],
metadata: {},
lastSeen: -1_000_000,
provider: "yjs",
});
const { deps, lost } = makeDeps({ peers, timeoutMs: 100 });
const intervals = new CRDTIntervals(deps, () => immediateScheduler());
intervals.start();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
expect(lost).toContain("stale");
intervals.stop();
});
it("leaves fresh peers untouched", { timeout: 5000 }, async () => {
const peers = new Map<string, CRDTPeerMetadata>();
peers.set("fresh", {
id: "fresh",
addresses: [],
metadata: {},
lastSeen: 1000,
provider: "yjs",
});
const { deps, lost } = makeDeps({
peers,
timeoutMs: 1_000_000,
clock: () => 1001,
});
const intervals = new CRDTIntervals(deps, () => immediateScheduler());
intervals.start();
await Promise.resolve();
await Promise.resolve();
expect(lost).toEqual([]);
intervals.stop();
});
it("cleanup loop no-ops when not running", { timeout: 5000 }, async () => {
const peers = new Map<string, CRDTPeerMetadata>();
peers.set("x", {
id: "x",
addresses: [],
metadata: {},
lastSeen: -1_000_000,
provider: "yjs",
});
const { deps, lost } = makeDeps({ peers, isRunning: () => false });
const intervals = new CRDTIntervals(deps, () => immediateScheduler());
intervals.start();
await Promise.resolve();
await Promise.resolve();
expect(lost).toEqual([]);
intervals.stop();
});
it("heartbeat no-ops when myPeerId is null", {
timeout: 5000,
}, async () => {
const { deps, adapter } = makeDeps({ getMyPeerId: () => null });
const intervals = new CRDTIntervals(deps, () => immediateScheduler());
intervals.start();
await Promise.resolve();
await Promise.resolve();
expect(adapter.announces).toEqual([]);
intervals.stop();
});
it("reschedule guards short-circuit after stop", { timeout: 5000 }, () => {
const { deps } = makeDeps();
const sched = manualScheduler();
const intervals = new CRDTIntervals(deps, () => sched);
intervals.start();
// Three loops queued (announce, heartbeat, cleanup).
expect(sched.pendingCount).toBe(3);
intervals.stop();
// Running any pending now triggers reschedule into the stopped branch.
sched.runOnce();
sched.runOnce();
sched.runOnce();
// None should have requeued.
expect(sched.pendingCount).toBe(0);
});
it("default scheduler factory works (production path)", {
timeout: 5000,
}, () => {
const { deps } = makeDeps();
const intervals = new CRDTIntervals(deps);
intervals.start();
intervals.stop();
expect(intervals.isStopped).toBe(true);
});
});

View File

@@ -0,0 +1,123 @@
import { createScheduler, type Scheduler } from "@suaveplan/timing";
import type { Clock } from "@suaveplan/types";
import { recordReconcileDuration } from "../../otel/otel.js";
import type {
CRDTPeerMetadata,
CRDTProvider,
CRDTProviderAdapter,
} from "../../types/types.js";
/**
* Dependencies the interval loops close over.
*/
export interface CRDTIntervalDeps {
readonly clock: Clock;
readonly announceIntervalMs: number;
readonly heartbeatIntervalMs: number;
readonly cleanupIntervalMs: number;
readonly timeoutMs: number;
readonly provider: CRDTProvider;
readonly adapter: CRDTProviderAdapter;
readonly peers: Map<string, CRDTPeerMetadata>;
/** Current local peer id — `null` until the adapter is initialised. */
getMyPeerId(): string | null;
/** Returns `true` while the strategy is running. */
isRunning(): boolean;
/** Build the announce metadata for the local peer. */
selfMetadata(): CRDTPeerMetadata;
/** Drop a peer from local state. */
handlePeerLost(peerId: string): void;
}
/**
* Three `Scheduler`-driven self-rearming loops — announce / heartbeat /
* cleanup. Replaces upstream's three `setInterval` handles with a single
* `Scheduler` whose `dispose()` cancels every pending timer atomically.
*/
export class CRDTIntervals {
private readonly scheduler: Scheduler;
private readonly deps: CRDTIntervalDeps;
private stopped = false;
public constructor(
deps: CRDTIntervalDeps,
schedulerFactory: (clock: Clock) => Scheduler = (c) =>
createScheduler({ clock: c })
) {
this.deps = deps;
this.scheduler = schedulerFactory(deps.clock);
}
/** Start every periodic loop. Idempotent. */
public start(): void {
if (this.stopped) return;
this.scheduleAnnounce();
this.scheduleHeartbeat();
this.scheduleCleanup();
}
/** Stop every loop and dispose the underlying scheduler. Idempotent. */
public stop(): void {
if (this.stopped) return;
this.stopped = true;
this.scheduler.dispose();
}
/** Whether `stop()` has been called. */
public get isStopped(): boolean {
return this.stopped;
}
private scheduleAnnounce(): void {
if (this.stopped || this.scheduler.disposed) return;
this.scheduler.schedule(() => {
this.runAnnounce();
this.scheduleAnnounce();
}, this.deps.announceIntervalMs);
}
private scheduleHeartbeat(): void {
if (this.stopped || this.scheduler.disposed) return;
this.scheduler.schedule(() => {
this.runHeartbeat();
this.scheduleHeartbeat();
}, this.deps.heartbeatIntervalMs);
}
private scheduleCleanup(): void {
if (this.stopped || this.scheduler.disposed) return;
this.scheduler.schedule(() => {
this.runCleanup();
this.scheduleCleanup();
}, this.deps.cleanupIntervalMs);
}
private runAnnounce(): void {
if (!this.deps.isRunning()) return;
const myPeerId = this.deps.getMyPeerId();
if (myPeerId === null) return;
const t0 = this.deps.clock();
const metadata = this.deps.selfMetadata();
this.deps.adapter.announce(metadata);
recordReconcileDuration(this.deps.provider, this.deps.clock() - t0);
}
private runHeartbeat(): void {
if (!this.deps.isRunning()) return;
const myPeerId = this.deps.getMyPeerId();
if (myPeerId === null) return;
// Heartbeat is a lighter-weight version of announce — same payload
// with refreshed `lastSeen`. Adapter de-dupes via CRDT semantics.
this.deps.adapter.announce(this.deps.selfMetadata());
}
private runCleanup(): void {
if (!this.deps.isRunning()) return;
const now = this.deps.clock();
for (const [peerId, peer] of this.deps.peers.entries()) {
if (now - peer.lastSeen > this.deps.timeoutMs) {
this.deps.handlePeerLost(peerId);
}
}
}
}

View File

@@ -0,0 +1,4 @@
export {
type CRDTIntervalDeps,
CRDTIntervals,
} from "./crdt-intervals.js";

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,179 @@
import { EventBus } from "@suaveplan/events";
import { ServiceRegistry } from "@suaveplan/service-discovery";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NET_DISC_CRDT_CODES, NetDiscCRDTError } from "../../errors/errors.js";
import type { CRDTEventMap, CRDTPeerMetadata } from "../../types/types.js";
import { CRDTPeerManager } from "./crdt-peer-manager.js";
function makePeer(id: string, lastSeen = 100): CRDTPeerMetadata {
return {
id,
addresses: [`addr-${id}`],
metadata: { tag: id },
lastSeen,
provider: "yjs",
};
}
describe("CRDTPeerManager.admit", () => {
it("inserts a new peer and emits crdt:peer-discovered", {
timeout: 5000,
}, () => {
const bus = new EventBus<CRDTEventMap>();
const events: string[] = [];
bus.on("crdt:peer-discovered", (e) => events.push(e.peer.id));
const mgr = new CRDTPeerManager({
clock: () => 1000,
bus,
provider: "yjs",
maxPeers: 10,
room: "demo",
});
expect(mgr.admit(makePeer("p1"))).toBe("added");
expect(mgr.size()).toBe(1);
expect(events).toEqual(["p1"]);
});
it("updates an existing peer", { timeout: 5000 }, () => {
const mgr = new CRDTPeerManager({
clock: () => 1000,
provider: "yjs",
maxPeers: 10,
room: "demo",
});
mgr.admit(makePeer("p1"));
expect(mgr.admit(makePeer("p1"))).toBe("updated");
expect(mgr.size()).toBe(1);
});
it("throws PEER_REJECTED when the room is full", { timeout: 5000 }, () => {
const mgr = new CRDTPeerManager({
clock: () => 1000,
provider: "automerge",
maxPeers: 1,
room: "demo",
});
mgr.admit(makePeer("p1"));
let caught: unknown;
try {
mgr.admit(makePeer("p2"));
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(NetDiscCRDTError);
expect((caught as NetDiscCRDTError).code).toBe(
NET_DISC_CRDT_CODES.PEER_REJECTED
);
});
it("registers discovered peers into a ServiceRegistry", {
timeout: 5000,
}, () => {
const registry = new ServiceRegistry();
const mgr = new CRDTPeerManager({
clock: () => 1000,
registry,
provider: "yjs",
maxPeers: 10,
room: "demo",
});
mgr.admit(makePeer("p1"));
expect(registry.get("crdt:demo:p1")).toBeDefined();
});
it("falls back to peerId when addresses is empty for registry address", {
timeout: 5000,
}, () => {
const registry = new ServiceRegistry();
const mgr = new CRDTPeerManager({
clock: () => 1000,
registry,
provider: "yjs",
maxPeers: 10,
room: "demo",
});
mgr.admit({
id: "p9",
addresses: [],
metadata: {},
lastSeen: 0,
provider: "yjs",
});
expect(registry.get("crdt:demo:p9")?.address).toBe("p9");
});
});
describe("CRDTPeerManager.touch / drop / clear", () => {
it("touch refreshes lastSeen and is a no-op on unknown", {
timeout: 5000,
}, () => {
let t = 100;
const mgr = new CRDTPeerManager({
clock: () => t,
provider: "yjs",
maxPeers: 10,
room: "demo",
});
mgr.admit(makePeer("p1"));
t = 200;
mgr.touch("p1");
expect(mgr.get("p1")?.lastSeen).toBe(200);
mgr.touch("unknown");
expect(mgr.size()).toBe(1);
});
it("drop emits crdt:peer-lost and deregisters", { timeout: 5000 }, () => {
const bus = new EventBus<CRDTEventMap>();
const registry = new ServiceRegistry();
const lost: string[] = [];
bus.on("crdt:peer-lost", (e) => lost.push(e.peerId));
const mgr = new CRDTPeerManager({
clock: () => 1000,
bus,
registry,
provider: "yjs",
maxPeers: 10,
room: "demo",
});
mgr.admit(makePeer("p1"));
expect(mgr.drop("p1")).toBe(true);
expect(lost).toEqual(["p1"]);
expect(registry.get("crdt:demo:p1")).toBeUndefined();
expect(mgr.drop("p1")).toBe(false);
});
it("clear drops every peer with bus + registry side-effects", {
timeout: 5000,
}, () => {
const bus = new EventBus<CRDTEventMap>();
const registry = new ServiceRegistry();
const lost: string[] = [];
bus.on("crdt:peer-lost", (e) => lost.push(e.peerId));
const mgr = new CRDTPeerManager({
clock: () => 1000,
bus,
registry,
provider: "yjs",
maxPeers: 10,
room: "demo",
});
mgr.admit(makePeer("p1"));
mgr.admit(makePeer("p2"));
mgr.clear();
expect(mgr.size()).toBe(0);
expect(lost.sort()).toEqual(["p1", "p2"]);
});
it("list / get / getMap expose the live map", { timeout: 5000 }, () => {
const mgr = new CRDTPeerManager({
clock: () => 1000,
provider: "yjs",
maxPeers: 10,
room: "demo",
});
mgr.admit(makePeer("p1"));
expect(mgr.list()).toHaveLength(1);
expect(mgr.get("p1")?.id).toBe("p1");
expect(mgr.getMap().has("p1")).toBe(true);
});
});

View File

@@ -0,0 +1,128 @@
import type { ServiceRegistry } from "@suaveplan/service-discovery";
import type { Clock, EventBusLike } from "@suaveplan/types";
import { NET_DISC_CRDT_CODES, NetDiscCRDTError } from "../../errors/errors.js";
import { recordPeersDiscovered } from "../../otel/otel.js";
import type {
CRDTEventMap,
CRDTPeerMetadata,
CRDTProvider,
} from "../../types/types.js";
/**
* Tracks the deduped set of discovered peers for a `CRDTDiscovery` instance.
* Mirrors deltas onto the optional `EventBusLike` and (when supplied) an
* `@suaveplan/service-discovery` `ServiceRegistry`. Enforces `maxPeers`.
*/
export class CRDTPeerManager {
private readonly peers = new Map<string, CRDTPeerMetadata>();
private readonly clock: Clock;
private readonly bus: EventBusLike<CRDTEventMap> | undefined;
private readonly registry: ServiceRegistry | undefined;
private readonly provider: CRDTProvider;
private readonly maxPeers: number;
private readonly room: string;
public constructor(deps: {
clock: Clock;
bus?: EventBusLike<CRDTEventMap> | undefined;
registry?: ServiceRegistry | undefined;
provider: CRDTProvider;
maxPeers: number;
room: string;
}) {
this.clock = deps.clock;
this.bus = deps.bus;
this.registry = deps.registry;
this.provider = deps.provider;
this.maxPeers = deps.maxPeers;
this.room = deps.room;
}
/**
* Admit a peer (insert or update). Throws `PEER_REJECTED` when the cap is
* hit on insert. Returns `"added"` / `"updated"` so callers can record
* the appropriate OTel outcome.
*/
public admit(metadata: CRDTPeerMetadata): "added" | "updated" {
const existing = this.peers.get(metadata.id);
if (existing === undefined) {
if (this.peers.size >= this.maxPeers) {
recordPeersDiscovered(this.provider, "rejected");
throw new NetDiscCRDTError(
NET_DISC_CRDT_CODES.PEER_REJECTED,
"errors.crdt.room_full"
);
}
const record: CRDTPeerMetadata = {
...metadata,
lastSeen: this.clock(),
};
this.peers.set(metadata.id, record);
recordPeersDiscovered(this.provider, "found");
this.bus?.emit("crdt:peer-discovered", {
peer: record,
at: this.clock(),
});
this.registry?.register({
id: `crdt:${this.room}:${metadata.id}`,
name: "crdt-discovery",
address: metadata.addresses[0] ?? metadata.id,
metadata: {
...metadata.metadata,
provider: this.provider,
addresses: [...metadata.addresses],
},
});
return "added";
}
existing.lastSeen = this.clock();
recordPeersDiscovered(this.provider, "duplicate");
return "updated";
}
/** Refresh the `lastSeen` timestamp of an existing peer. */
public touch(peerId: string): void {
const peer = this.peers.get(peerId);
if (peer !== undefined) {
peer.lastSeen = this.clock();
}
}
/** Drop a peer. Emits `crdt:peer-lost` and deregisters when known. */
public drop(peerId: string): boolean {
if (!this.peers.delete(peerId)) return false;
this.bus?.emit("crdt:peer-lost", { peerId, at: this.clock() });
this.registry?.deregister(`crdt:${this.room}:${peerId}`);
return true;
}
/** Snapshot of the live peer set. */
public list(): readonly CRDTPeerMetadata[] {
return Array.from(this.peers.values());
}
/** Peer lookup. */
public get(peerId: string): CRDTPeerMetadata | undefined {
return this.peers.get(peerId);
}
/** Number of tracked peers. */
public size(): number {
return this.peers.size;
}
/** Underlying map (read-write, used by the interval cleanup loop). */
public getMap(): Map<string, CRDTPeerMetadata> {
return this.peers;
}
/** Drop every peer (emits `crdt:peer-lost` for each). */
public clear(): void {
const ids = Array.from(this.peers.keys());
this.peers.clear();
for (const id of ids) {
this.bus?.emit("crdt:peer-lost", { peerId: id, at: this.clock() });
this.registry?.deregister(`crdt:${this.room}:${id}`);
}
}
}

View File

@@ -0,0 +1 @@
export { CRDTPeerManager } from "./crdt-peer-manager.js";

View File

@@ -0,0 +1 @@
export { YjsProviderAdapter } from "./yjs-provider.js";

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,388 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
FakeYjsBroker,
fakeYjsDocFactory,
flushMicrotasks,
} from "../../../tests/setup/fake-yjs.js";
import { NET_DISC_CRDT_CODES, NetDiscCRDTError } from "../../errors/errors.js";
import type {
CRDTPeerMetadata,
CRDTProviderCallbacks,
} from "../../types/types.js";
import { YjsProviderAdapter } from "./yjs-provider.js";
function makeCallbacks() {
const seen: CRDTPeerMetadata[] = [];
const lost: string[] = [];
let syncs = 0;
const callbacks: CRDTProviderCallbacks = {
onPeer: (m) => seen.push(m),
onPeerLost: (id) => lost.push(id),
onSynced: () => {
syncs += 1;
},
};
return { callbacks, seen, lost, syncCount: () => syncs };
}
function makePeer(id: string): CRDTPeerMetadata {
return {
id,
addresses: [`addr-${id}`],
metadata: { tag: id },
lastSeen: 1,
provider: "yjs",
};
}
describe("YjsProviderAdapter.initialize", () => {
it("returns the local peer id and observes the peers map", {
timeout: 5000,
}, async () => {
const broker = new FakeYjsBroker();
const adapter = new YjsProviderAdapter(
"demo",
{ signaling: ["wss://x.invalid"] },
fakeYjsDocFactory(broker)
);
const { callbacks, syncCount } = makeCallbacks();
const id = await adapter.initialize(callbacks);
expect(id).toMatch(/^fake-doc-/);
await flushMicrotasks();
expect(syncCount()).toBeGreaterThanOrEqual(1);
adapter.destroy();
});
it("rejects with AUTOMERGE-like code when factory throws (YJS path)", {
timeout: 5000,
}, async () => {
const adapter = new YjsProviderAdapter(
"demo",
{ signaling: ["wss://x.invalid"] },
() => {
throw new Error("nope");
}
);
const { callbacks } = makeCallbacks();
await expect(adapter.initialize(callbacks)).rejects.toMatchObject({
code: NET_DISC_CRDT_CODES.YJS_AWARENESS_FAILED,
});
});
it("rejects with DISPOSED after destroy", { timeout: 5000 }, async () => {
const broker = new FakeYjsBroker();
const adapter = new YjsProviderAdapter(
"demo",
{ signaling: ["wss://x.invalid"] },
fakeYjsDocFactory(broker)
);
adapter.destroy();
const { callbacks } = makeCallbacks();
await expect(adapter.initialize(callbacks)).rejects.toBeInstanceOf(
NetDiscCRDTError
);
});
it("honours pre-aborted signal", { timeout: 5000 }, async () => {
const broker = new FakeYjsBroker();
const adapter = new YjsProviderAdapter(
"demo",
{ signaling: ["wss://x.invalid"] },
fakeYjsDocFactory(broker)
);
const ctrl = new AbortController();
ctrl.abort();
const { callbacks } = makeCallbacks();
await expect(
adapter.initialize(callbacks, ctrl.signal)
).rejects.toBeDefined();
adapter.destroy();
});
it("forges AbortError when signal.reason is non-Error", {
timeout: 5000,
}, async () => {
const broker = new FakeYjsBroker();
const adapter = new YjsProviderAdapter(
"demo",
{ signaling: ["wss://x.invalid"] },
fakeYjsDocFactory(broker)
);
const ctrl = new AbortController();
Object.defineProperty(ctrl.signal, "aborted", { value: true });
Object.defineProperty(ctrl.signal, "reason", {
value: "string-reason",
});
const { callbacks } = makeCallbacks();
await expect(
adapter.initialize(callbacks, ctrl.signal)
).rejects.toMatchObject({ name: "AbortError" });
adapter.destroy();
});
});
describe("YjsProviderAdapter cross-peer awareness", () => {
it("propagates announces between adapters in the same room", {
timeout: 5000,
}, async () => {
const broker = new FakeYjsBroker();
const factory = fakeYjsDocFactory(broker);
const a = new YjsProviderAdapter(
"demo",
{ signaling: ["wss://x.invalid"] },
factory
);
const b = new YjsProviderAdapter(
"demo",
{ signaling: ["wss://x.invalid"] },
factory
);
const cbA = makeCallbacks();
const cbB = makeCallbacks();
await a.initialize(cbA.callbacks);
await b.initialize(cbB.callbacks);
await flushMicrotasks();
a.announce(makePeer("a-self"));
await flushMicrotasks();
expect(cbB.seen.some((p) => p.id === "a-self")).toBe(true);
a.destroy();
b.destroy();
});
it("flushes existing peers on synced", { timeout: 5000 }, async () => {
const broker = new FakeYjsBroker();
const factory = fakeYjsDocFactory(broker);
const a = new YjsProviderAdapter(
"demo",
{ signaling: ["wss://x.invalid"] },
factory
);
const cbA = makeCallbacks();
await a.initialize(cbA.callbacks);
a.announce(makePeer("a-self"));
await flushMicrotasks();
// Now b joins — it should see a-self via the synced flush.
const b = new YjsProviderAdapter(
"demo",
{ signaling: ["wss://x.invalid"] },
factory
);
const cbB = makeCallbacks();
await b.initialize(cbB.callbacks);
await flushMicrotasks();
expect(cbB.seen.some((p) => p.id === "a-self")).toBe(true);
a.destroy();
b.destroy();
});
it("propagates delete as onPeerLost", { timeout: 5000 }, async () => {
const broker = new FakeYjsBroker();
const factory = fakeYjsDocFactory(broker);
const a = new YjsProviderAdapter(
"demo",
{ signaling: ["wss://x.invalid"] },
factory
);
const b = new YjsProviderAdapter(
"demo",
{ signaling: ["wss://x.invalid"] },
factory
);
const cbA = makeCallbacks();
const cbB = makeCallbacks();
const idA = await a.initialize(cbA.callbacks);
await b.initialize(cbB.callbacks);
await flushMicrotasks();
// `a` announces under its own (local) id — this is what `leave()` deletes.
a.announce({
...makePeer(idA),
});
await flushMicrotasks();
a.leave();
await flushMicrotasks();
expect(cbB.lost).toContain(idA);
a.destroy();
b.destroy();
});
it("listKnownPeers excludes the local peer", {
timeout: 5000,
}, async () => {
const broker = new FakeYjsBroker();
const factory = fakeYjsDocFactory(broker);
const a = new YjsProviderAdapter(
"demo",
{ signaling: ["wss://x.invalid"] },
factory
);
const b = new YjsProviderAdapter(
"demo",
{ signaling: ["wss://x.invalid"] },
factory
);
const cbA = makeCallbacks();
const cbB = makeCallbacks();
const idA = await a.initialize(cbA.callbacks);
await b.initialize(cbB.callbacks);
await flushMicrotasks();
a.announce(makePeer("a-self"));
b.announce(makePeer("b-self"));
await flushMicrotasks();
const seenByA = a.listKnownPeers();
expect(seenByA.every((p) => p.id !== idA)).toBe(true);
a.destroy();
b.destroy();
});
it("isConnected reflects provider state", { timeout: 5000 }, async () => {
const broker = new FakeYjsBroker();
const adapter = new YjsProviderAdapter(
"demo",
{ signaling: ["wss://x.invalid"] },
fakeYjsDocFactory(broker)
);
const cb = makeCallbacks();
await adapter.initialize(cb.callbacks);
expect(adapter.isConnected).toBe(true);
adapter.destroy();
expect(adapter.isConnected).toBe(false);
});
});
describe("YjsProviderAdapter edge cases", () => {
it("announce / leave / listKnownPeers no-op before initialise", {
timeout: 5000,
}, () => {
const broker = new FakeYjsBroker();
const adapter = new YjsProviderAdapter(
"demo",
{ signaling: ["wss://x.invalid"] },
fakeYjsDocFactory(broker)
);
adapter.announce(makePeer("x"));
adapter.leave();
expect(adapter.listKnownPeers()).toEqual([]);
adapter.destroy();
});
it("destroy is idempotent", { timeout: 5000 }, async () => {
const broker = new FakeYjsBroker();
const adapter = new YjsProviderAdapter(
"demo",
{ signaling: ["wss://x.invalid"] },
fakeYjsDocFactory(broker)
);
const cb = makeCallbacks();
await adapter.initialize(cb.callbacks);
adapter.destroy();
adapter.destroy();
expect(adapter.isConnected).toBe(false);
});
it("handleMapChange guards against null map after destroy", {
timeout: 5000,
}, async () => {
const broker = new FakeYjsBroker();
const adapter = new YjsProviderAdapter(
"demo",
{ signaling: ["wss://x.invalid"] },
fakeYjsDocFactory(broker)
);
const cb = makeCallbacks();
await adapter.initialize(cb.callbacks);
const internals = adapter as unknown as {
handleMapChange: (
event: { changes: { keys: Map<string, { action: string }> } },
cb: typeof cb.callbacks
) => void;
peersMap: unknown;
myPeerId: unknown;
};
internals.peersMap = null;
internals.handleMapChange(
{ changes: { keys: new Map([["x", { action: "add" }]]) } },
cb.callbacks
);
adapter.destroy();
});
it("handleMapChange skips missing metadata on add", {
timeout: 5000,
}, async () => {
const broker = new FakeYjsBroker();
const adapter = new YjsProviderAdapter(
"demo",
{ signaling: ["wss://x.invalid"] },
fakeYjsDocFactory(broker)
);
const cb = makeCallbacks();
await adapter.initialize(cb.callbacks);
const seenBefore = cb.seen.length;
const internals = adapter as unknown as {
handleMapChange: (
event: { changes: { keys: Map<string, { action: string }> } },
cb: typeof cb.callbacks
) => void;
};
// Event references a key the map doesn't contain — onPeer must NOT fire.
internals.handleMapChange(
{
changes: {
keys: new Map([["missing-key", { action: "add" }]]),
},
},
cb.callbacks
);
expect(cb.seen.length).toBe(seenBefore);
adapter.destroy();
});
it("flushExisting guards against null peersMap", {
timeout: 5000,
}, async () => {
const broker = new FakeYjsBroker();
const adapter = new YjsProviderAdapter(
"demo",
{ signaling: ["wss://x.invalid"] },
fakeYjsDocFactory(broker)
);
const cb = makeCallbacks();
await adapter.initialize(cb.callbacks);
const internals = adapter as unknown as {
flushExisting: (cb: typeof cb.callbacks) => void;
peersMap: unknown;
};
internals.peersMap = null;
internals.flushExisting(cb.callbacks);
adapter.destroy();
});
it("flushExisting skips the local peer id", {
timeout: 5000,
}, async () => {
const broker = new FakeYjsBroker();
const adapter = new YjsProviderAdapter(
"demo",
{ signaling: ["wss://x.invalid"] },
fakeYjsDocFactory(broker)
);
const cb = makeCallbacks();
const idA = await adapter.initialize(cb.callbacks);
// Announce under the local id so flushExisting must skip it.
adapter.announce({
id: idA,
addresses: [],
metadata: {},
lastSeen: 1,
provider: "yjs",
});
const before = cb.seen.length;
const internals = adapter as unknown as {
flushExisting: (cb: typeof cb.callbacks) => void;
};
internals.flushExisting(cb.callbacks);
// No new onPeer because the only entry is the local peer.
expect(cb.seen.length).toBe(before);
adapter.destroy();
});
});

View File

@@ -0,0 +1,179 @@
import { NET_DISC_CRDT_CODES, NetDiscCRDTError } from "../../errors/errors.js";
import type {
CRDTPeerMetadata,
CRDTProviderAdapter,
CRDTProviderCallbacks,
WebrtcProviderLike,
YDocLike,
YjsDocFactory,
YjsSignalingConfig,
YMapEventLike,
YMapLike,
} from "../../types/types.js";
/**
* Yjs-backed `CRDTProviderAdapter`. Wraps a `Y.Doc` + `y-webrtc` provider
* pair built by the caller-supplied `YjsDocFactory`. Owns the peer-map
* observe handler, the synced + peers events, and the destroy semantics.
*/
export class YjsProviderAdapter implements CRDTProviderAdapter {
public readonly kind = "yjs" as const;
private readonly room: string;
private readonly opts: YjsSignalingConfig;
private readonly factory: YjsDocFactory;
private doc: YDocLike | null = null;
private provider: WebrtcProviderLike | null = null;
private peersMap: YMapLike | null = null;
private myPeerId: string | null = null;
private observeHandler: ((e: YMapEventLike) => void) | null = null;
private destroyed = false;
public constructor(
room: string,
opts: YjsSignalingConfig,
factory: YjsDocFactory
) {
this.room = room;
this.opts = opts;
this.factory = factory;
}
public async initialize(
callbacks: CRDTProviderCallbacks,
signal?: AbortSignal
): Promise<string> {
if (signal?.aborted) throw toAbortError(signal);
if (this.destroyed) {
throw new NetDiscCRDTError(
NET_DISC_CRDT_CODES.DISPOSED,
"errors.crdt.disposed"
);
}
let built: { doc: YDocLike; provider: WebrtcProviderLike };
try {
built = this.factory(this.room, this.opts);
} catch (err) {
throw new NetDiscCRDTError(
NET_DISC_CRDT_CODES.YJS_AWARENESS_FAILED,
"errors.crdt.yjs_init_failed",
{ cause: err }
);
}
this.doc = built.doc;
this.provider = built.provider;
this.peersMap = built.doc.getMap("peers");
this.myPeerId = built.doc.guid;
this.observeHandler = (event: YMapEventLike) => {
this.handleMapChange(event, callbacks);
};
this.peersMap.observe(this.observeHandler);
this.provider.on("synced", () => {
callbacks.onSynced();
this.flushExisting(callbacks);
});
this.provider.on("peers", (e) => {
for (const removed of e.removed) {
callbacks.onPeerLost(removed);
}
});
return this.myPeerId;
}
public announce(metadata: CRDTPeerMetadata): void {
if (this.peersMap === null || this.myPeerId === null) return;
this.peersMap.set(this.myPeerId, metadata);
}
public leave(): void {
if (this.peersMap === null || this.myPeerId === null) return;
try {
this.peersMap.delete(this.myPeerId);
} catch {
/* swallow — already detached */
}
}
public listKnownPeers(): readonly CRDTPeerMetadata[] {
if (this.peersMap === null || this.myPeerId === null) return [];
const out: CRDTPeerMetadata[] = [];
const snap = this.peersMap.toJSON();
for (const [id, metadata] of Object.entries(snap)) {
if (id !== this.myPeerId) out.push(metadata);
}
return out;
}
public destroy(): void {
if (this.destroyed) return;
this.destroyed = true;
if (this.peersMap !== null && this.observeHandler !== null) {
try {
this.peersMap.unobserve(this.observeHandler);
} catch {
/* swallow */
}
}
if (this.provider !== null) {
try {
this.provider.destroy();
} catch {
/* swallow */
}
this.provider = null;
}
if (this.doc !== null) {
try {
this.doc.destroy();
} catch {
/* swallow */
}
this.doc = null;
}
this.peersMap = null;
this.myPeerId = null;
this.observeHandler = null;
}
public get isConnected(): boolean {
return this.provider?.connected ?? false;
}
private handleMapChange(
event: YMapEventLike,
callbacks: CRDTProviderCallbacks
): void {
const map = this.peersMap;
if (map === null || this.myPeerId === null) return;
for (const [peerId, change] of event.changes.keys.entries()) {
if (peerId === this.myPeerId) continue;
if (change.action === "add" || change.action === "update") {
const metadata = map.get(peerId);
if (metadata !== undefined) {
callbacks.onPeer(metadata);
}
} else {
callbacks.onPeerLost(peerId);
}
}
}
private flushExisting(callbacks: CRDTProviderCallbacks): void {
if (this.peersMap === null || this.myPeerId === null) return;
const snap = this.peersMap.toJSON();
for (const [id, metadata] of Object.entries(snap)) {
if (id !== this.myPeerId) callbacks.onPeer(metadata);
}
}
}
function toAbortError(signal: AbortSignal): Error {
if (signal.reason instanceof Error) return signal.reason;
const err = new Error("Aborted");
err.name = "AbortError";
return err;
}

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,48 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NET_DISC_CRDT_CODES, NetDiscCRDTError } from "./errors.js";
describe("NET_DISC_CRDT_CODES", () => {
it("registers the full code set", { timeout: 5000 }, () => {
expect(NET_DISC_CRDT_CODES.PROVIDER_FAILED).toBe(
"NET_DISC_CRDT.PROVIDER_FAILED"
);
expect(NET_DISC_CRDT_CODES.STATE_DESYNC).toBe(
"NET_DISC_CRDT.STATE_DESYNC"
);
expect(NET_DISC_CRDT_CODES.PEER_REJECTED).toBe(
"NET_DISC_CRDT.PEER_REJECTED"
);
expect(NET_DISC_CRDT_CODES.INVALID_OPTIONS).toBe(
"NET_DISC_CRDT.INVALID_OPTIONS"
);
expect(NET_DISC_CRDT_CODES.DISPOSED).toBe("NET_DISC_CRDT.DISPOSED");
expect(NET_DISC_CRDT_CODES.YJS_AWARENESS_FAILED).toBe(
"NET_DISC_CRDT.YJS_AWARENESS_FAILED"
);
expect(NET_DISC_CRDT_CODES.AUTOMERGE_SYNC_FAILED).toBe(
"NET_DISC_CRDT.AUTOMERGE_SYNC_FAILED"
);
});
});
describe("NetDiscCRDTError", () => {
it("constructs with code and messageKey", { timeout: 5000 }, () => {
const err = new NetDiscCRDTError(
NET_DISC_CRDT_CODES.PROVIDER_FAILED,
"errors.crdt.provider_failed"
);
expect(err).toBeInstanceOf(NetDiscCRDTError);
expect(err.code).toBe(NET_DISC_CRDT_CODES.PROVIDER_FAILED);
expect(err.name).toBe("NetDiscCRDTError");
});
it("preserves cause on the error chain", { timeout: 5000 }, () => {
const cause = new Error("root");
const err = new NetDiscCRDTError(
NET_DISC_CRDT_CODES.DISPOSED,
"errors.crdt.disposed",
{ cause }
);
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,48 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
/**
* Error codes emitted by `@suaveplan/network-discovery-crdt`.
*
* - `PROVIDER_FAILED` — the underlying Yjs / Automerge provider could not be
* initialised or surfaced an unrecoverable error during sync.
* - `STATE_DESYNC` — the awareness document is in a state the orchestrator
* cannot reconcile (e.g. duplicate-id collision the CRDT cannot resolve).
* - `PEER_REJECTED` — a peer was rejected during admission (room full,
* schema violation, etc).
* - `INVALID_OPTIONS` — caller-supplied config failed Zod validation, or a
* required provider-specific config / factory was omitted.
* - `DISPOSED` — operation attempted after `dispose()` / `stop()`.
* - `YJS_AWARENESS_FAILED` — Yjs-specific awareness or sync path failed.
* - `AUTOMERGE_SYNC_FAILED` — Automerge-specific sync path failed.
*/
export const NET_DISC_CRDT_CODES = defineCodes("NET_DISC_CRDT", [
"PROVIDER_FAILED",
"STATE_DESYNC",
"PEER_REJECTED",
"INVALID_OPTIONS",
"DISPOSED",
"YJS_AWARENESS_FAILED",
"AUTOMERGE_SYNC_FAILED",
] as const);
export type NetDiscCRDTErrorCode =
(typeof NET_DISC_CRDT_CODES)[keyof typeof NET_DISC_CRDT_CODES];
/**
* Structured error class for the CRDT discovery strategy. Every public
* boundary throws this on failure; consumers branch on `error.code`.
*/
export class NetDiscCRDTError extends SuaveplanError<NetDiscCRDTErrorCode> {
public constructor(
code: NetDiscCRDTErrorCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = "NetDiscCRDTError";
}
}

View File

@@ -0,0 +1,62 @@
// Core
export { AutomergeProviderAdapter } from "./core/automerge-provider/automerge-provider.js";
export { CRDTDiscovery } from "./core/crdt-discovery/crdt-discovery.js";
export {
type CRDTIntervalDeps,
CRDTIntervals,
} from "./core/crdt-intervals/crdt-intervals.js";
export { CRDTPeerManager } from "./core/crdt-peer-manager/crdt-peer-manager.js";
export { YjsProviderAdapter } from "./core/yjs-provider/yjs-provider.js";
// Errors
export {
NET_DISC_CRDT_CODES,
NetDiscCRDTError,
type NetDiscCRDTErrorCode,
} from "./errors/errors.js";
// OTel
export {
type PeerDiscoveryOutcome,
recordPeersDiscovered,
recordReconcileDuration,
withProviderSyncSpan,
withStartSpan,
} from "./otel/otel.js";
// Types
export type {
AutomergeConfig,
AutomergeRepoFactory,
AutomergeRepoLike,
CRDTConnectionStatus,
CRDTDiscoveredEvent,
CRDTDiscoveryConfig,
CRDTEventMap,
CRDTLostEvent,
CRDTMessageTypeValue,
CRDTPeerMetadata,
CRDTProvider,
CRDTProviderAdapter,
CRDTProviderCallbacks,
CRDTStartedEvent,
CRDTSyncFailedEvent,
CRDTSyncStatus,
ResolvedCRDTDiscoveryConfig,
WebrtcProviderLike,
YDocLike,
YjsDocFactory,
YjsSignalingConfig,
YMapEventLike,
YMapLike,
} from "./types/types.js";
export { CRDTMessageType } from "./types/types.js";
// Schemas
export {
AutomergeConfigSchema,
CRDTDiscoveryConfigSchema,
CRDTPeerMetadataSchema,
CRDTProviderSchema,
YjsSignalingConfigSchema,
} from "./types/types.schemas.js";

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,62 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
recordPeersDiscovered,
recordReconcileDuration,
withProviderSyncSpan,
withStartSpan,
} from "./otel.js";
describe("withStartSpan", () => {
it("runs the body and returns its value", { timeout: 5000 }, async () => {
const out = await withStartSpan(async (span) => {
span.setAttribute("k", "v");
return 42;
});
expect(out).toBe(42);
});
it("closes the span even when body throws", { timeout: 5000 }, async () => {
await expect(
withStartSpan(async () => {
throw new Error("boom");
})
).rejects.toThrow("boom");
});
});
describe("withProviderSyncSpan", () => {
it("attaches the provider attribute and returns body value", {
timeout: 5000,
}, async () => {
const out = await withProviderSyncSpan("yjs", async () => "ok");
expect(out).toBe("ok");
});
it("closes the span even when body throws", { timeout: 5000 }, async () => {
await expect(
withProviderSyncSpan("automerge", async () => {
throw new Error("nope");
})
).rejects.toThrow("nope");
});
});
describe("recordPeersDiscovered", () => {
it("accepts every outcome", { timeout: 5000 }, () => {
recordPeersDiscovered("yjs", "found");
recordPeersDiscovered("yjs", "duplicate", 2);
recordPeersDiscovered("automerge", "rejected");
});
it("ignores non-positive counts", { timeout: 5000 }, () => {
recordPeersDiscovered("yjs", "found", 0);
recordPeersDiscovered("yjs", "found", -1);
});
});
describe("recordReconcileDuration", () => {
it("clamps negatives at zero", { timeout: 5000 }, () => {
recordReconcileDuration("yjs", -100);
recordReconcileDuration("yjs", 1);
recordReconcileDuration("automerge", 12);
});
});

View File

@@ -0,0 +1,94 @@
import { metrics, type Span, type Tracer, trace } from "@opentelemetry/api";
import type { CRDTProvider } from "../types/types.js";
const TRACER_NAME = "@suaveplan/network-discovery-crdt";
const METER_NAME = "@suaveplan/network-discovery-crdt";
const tracer: Tracer = trace.getTracer(TRACER_NAME);
const meter = metrics.getMeter(METER_NAME);
/**
* Counter incremented for every peer the strategy emits, labelled by the
* provider and the outcome of the discovery attempt.
*/
const peersDiscoveredCounter = meter.createCounter(
"disc_crdt.peers_discovered_total",
{
description:
"Total peers seen by the CRDT discovery strategy, by provider and outcome.",
}
);
/**
* Histogram of reconciliation-loop wall-clock duration (ms), labelled by
* provider.
*/
const reconcileDurationHistogram = meter.createHistogram(
"disc_crdt.reconcile_duration_ms",
{
description: "CRDT reconciliation duration in milliseconds.",
unit: "ms",
}
);
/**
* Wrap the `start()` body in an OTel span. The caller's function receives the
* live span so it can attach attributes (e.g. provider, resolved peer id).
*/
export async function withStartSpan<T>(
fn: (span: Span) => Promise<T>
): Promise<T> {
return tracer.startActiveSpan("disc_crdt.start", async (span) => {
try {
return await fn(span);
} finally {
span.end();
}
});
}
/**
* Wrap a provider-specific sync block in an OTel span.
*/
export async function withProviderSyncSpan<T>(
provider: CRDTProvider,
fn: (span: Span) => Promise<T>
): Promise<T> {
return tracer.startActiveSpan(
`disc_crdt.provider.${provider}.sync`,
async (span) => {
try {
span.setAttribute("provider", provider);
return await fn(span);
} finally {
span.end();
}
}
);
}
/** Outcome label for {@link recordPeersDiscovered}. */
export type PeerDiscoveryOutcome = "found" | "duplicate" | "rejected";
/**
* Increment the peers-discovered counter.
*/
export function recordPeersDiscovered(
provider: CRDTProvider,
outcome: PeerDiscoveryOutcome,
count = 1
): void {
if (count > 0) {
peersDiscoveredCounter.add(count, { provider, outcome });
}
}
/**
* Record the wall-clock duration of a reconciliation loop.
*/
export function recordReconcileDuration(
provider: CRDTProvider,
ms: number
): void {
reconcileDurationHistogram.record(Math.max(0, ms), { provider });
}

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,61 @@
import z from "zod";
/**
* Zod boundary schemas for every public option struct and wire message in
* `@suaveplan/network-discovery-crdt`. Callable contracts (`yjsDocFactory`,
* `automergeRepoFactory`, `EventBusLike`, `ServiceRegistry`) are type-only
* and tolerated as `z.unknown()` — runtime checks live in the constructor
* that consumes them.
*/
export const CRDTProviderSchema = z.enum(["yjs", "automerge"]);
export const CRDTPeerMetadataSchema = z
.object({
id: z.string().min(1),
addresses: z.array(z.string()).readonly(),
metadata: z.record(z.string(), z.unknown()),
capabilities: z.array(z.string()).optional(),
lastSeen: z.number().int().nonnegative(),
provider: CRDTProviderSchema,
version: z.number().int().nonnegative().optional(),
})
.strict();
export const YjsSignalingConfigSchema = z
.object({
signaling: z.array(z.string().url()).min(1),
password: z.string().min(1).optional(),
maxConns: z.number().int().positive().default(20),
filterBcConns: z.boolean().optional(),
peerExchange: z.boolean().default(true),
})
.strict();
export const AutomergeConfigSchema = z
.object({
websocketUrl: z.string().url().optional(),
enableBroadcastChannel: z.boolean().default(true),
enableIndexedDB: z.boolean().default(true),
storageKey: z.string().min(1).default("automerge-discovery"),
})
.strict();
export const CRDTDiscoveryConfigSchema = z
.object({
provider: CRDTProviderSchema,
room: z.string().min(1),
yjs: YjsSignalingConfigSchema.optional(),
automerge: AutomergeConfigSchema.optional(),
interval: z.number().int().positive().default(30_000),
timeout: z.number().int().positive().default(60_000),
heartbeatInterval: z.number().int().positive().default(15_000),
cleanupInterval: z.number().int().positive().default(30_000),
maxPeers: z.number().int().positive().default(100),
iceServers: z.array(z.unknown()).optional(),
yjsDocFactory: z.unknown().optional(),
automergeRepoFactory: z.unknown().optional(),
bus: z.unknown().optional(),
registry: z.unknown().optional(),
})
.strict();

View File

@@ -0,0 +1,330 @@
import type { ServiceRegistry } from "@suaveplan/service-discovery";
import type { EventBusLike } from "@suaveplan/types";
/**
* Identifier for which CRDT backend `CRDTDiscovery` is wired against. Fixed
* at construction time; cannot be swapped at runtime.
*/
export type CRDTProvider = "yjs" | "automerge";
/**
* Discovery-message types exchanged via the CRDT awareness layer.
*
* Frozen string-literal union (kept as a record so the wire-format values
* stay stable across the family of `network-discovery-*` packages).
*/
export const CRDTMessageType = Object.freeze({
/** Announce presence + metadata in the shared awareness document. */
ANNOUNCE: "announce",
/** Request the full known peer-list. */
DISCOVER: "discover",
/** Reply to a `DISCOVER` with an array of peer ids. */
PEER_LIST: "peer_list",
/** Voluntary leave notification. */
LEAVE: "leave",
/** Liveness ping. */
HEARTBEAT: "heartbeat",
/** Document sync state update (Yjs/Automerge handled internally). */
SYNC: "sync",
} as const);
export type CRDTMessageTypeValue =
(typeof CRDTMessageType)[keyof typeof CRDTMessageType];
/**
* Peer metadata broadcast over the CRDT awareness map.
*/
export interface CRDTPeerMetadata {
/** Unique peer id (assigned by the provider). */
readonly id: string;
/** Reachable addresses (libp2p-style multiaddrs are accepted; opaque). */
readonly addresses: readonly string[];
/** Free-form caller-supplied metadata. */
readonly metadata: Readonly<Record<string, unknown>>;
/** Optional capability strings the peer advertises. */
readonly capabilities?: readonly string[];
/** Unix-ms timestamp of the most recent observation. */
lastSeen: number;
/** Provider that produced this record. */
readonly provider: CRDTProvider;
/** Document/clock version for sync state. */
readonly version?: number;
}
/**
* Yjs-specific signalling configuration. Mirrors `y-webrtc` provider options
* verbatim, modulo Zod-driven defaults.
*/
export interface YjsSignalingConfig {
/** WebRTC signalling-server URLs. REQUIRED — no implicit public default. */
readonly signaling: readonly string[];
/** Password for room encryption. */
readonly password?: string | undefined;
/** Maximum WebRTC connections. Default `20`. */
readonly maxConns?: number | undefined;
/** Filter for broadcast-channel connections. */
readonly filterBcConns?: boolean | undefined;
/** Whether to enable peer-exchange. Default `true`. */
readonly peerExchange?: boolean | undefined;
}
/**
* Automerge-specific configuration.
*/
export interface AutomergeConfig {
/** WebSocket sync-server URL. */
readonly websocketUrl?: string | undefined;
/** Enable broadcast-channel intra-tab transport. Default `true`. */
readonly enableBroadcastChannel?: boolean | undefined;
/** Enable IndexedDB storage. Default `true`. */
readonly enableIndexedDB?: boolean | undefined;
/** IndexedDB storage key. Default `automerge-discovery`. */
readonly storageKey?: string | undefined;
}
/**
* Structural surface of a Yjs `Y.Doc` used by this package. Tests bind a
* deterministic in-memory implementation; production wires to `new Doc()`
* from `yjs`.
*/
export interface YDocLike {
readonly guid: string;
getMap(name: string): YMapLike;
destroy(): void;
on(event: string, handler: (...args: unknown[]) => void): void;
off(event: string, handler: (...args: unknown[]) => void): void;
}
/**
* Structural surface of a Yjs `Y.Map` used by this package.
*/
export interface YMapLike {
set(key: string, value: CRDTPeerMetadata): void;
get(key: string): CRDTPeerMetadata | undefined;
delete(key: string): void;
has(key: string): boolean;
observe(handler: (event: YMapEventLike) => void): void;
unobserve(handler: (event: YMapEventLike) => void): void;
toJSON(): Record<string, CRDTPeerMetadata>;
}
/**
* Structural surface of the change event emitted on `YMapLike.observe`.
*/
export interface YMapEventLike {
readonly changes: {
readonly keys: ReadonlyMap<
string,
{ readonly action: "add" | "update" | "delete" }
>;
};
}
/**
* Structural surface of a `y-webrtc` `WebrtcProvider` used by this package.
*/
export interface WebrtcProviderLike {
readonly connected: boolean;
readonly synced: boolean;
on(event: "synced", handler: () => void): void;
on(
event: "peers",
handler: (e: {
added: readonly string[];
removed: readonly string[];
}) => void
): void;
on(event: string, handler: (...args: unknown[]) => void): void;
off(event: string, handler: (...args: unknown[]) => void): void;
destroy(): void;
}
/**
* Factory wiring `(roomName, doc, options) => WebrtcProviderLike`. Production
* binds this to `new (await import("y-webrtc")).WebrtcProvider`; tests pass a
* deterministic stub.
*/
export type YjsDocFactory = (
room: string,
opts: YjsSignalingConfig
) => {
readonly doc: YDocLike;
readonly provider: WebrtcProviderLike;
};
/**
* Structural surface of an Automerge `Repo` used by this package. Tests bind
* a deterministic in-memory implementation; production wires to `new Repo()`
* from `@automerge/automerge-repo`.
*/
export interface AutomergeRepoLike {
/** Document id of the shared awareness doc. */
readonly peerId: string;
/** Subscribe to repo-level peer connection events. */
on(event: "peer", handler: (e: { peerId: string }) => void): void;
on(
event: "peer-disconnected",
handler: (e: { peerId: string }) => void
): void;
on(event: string, handler: (...args: unknown[]) => void): void;
off(event: string, handler: (...args: unknown[]) => void): void;
/** Announce local peer metadata into the shared map. */
announce(metadata: CRDTPeerMetadata): void;
/** Snapshot of all currently-known peers (sans the local peer). */
listPeers(): readonly CRDTPeerMetadata[];
/** Tear the repo down, closing every network adapter. */
shutdown(): void;
}
/**
* Factory wiring `(room, opts) => AutomergeRepoLike`. Production binds this
* to a `new Repo({network: [...], storage})` instance wrapped to match
* `AutomergeRepoLike`. Tests pass a deterministic stub.
*/
export type AutomergeRepoFactory = (
room: string,
opts: AutomergeConfig
) => AutomergeRepoLike;
/**
* Shared adapter contract both providers conform to. The orchestrator drives
* provider lifecycle without branching on the provider string.
*/
export interface CRDTProviderAdapter {
readonly kind: CRDTProvider;
/** Initialise the provider, returning the assigned local peer id. */
initialize(
callbacks: CRDTProviderCallbacks,
signal?: AbortSignal
): Promise<string>;
/** Announce local peer metadata. */
announce(metadata: CRDTPeerMetadata): void;
/** Mark the local peer as leaving (idempotent). */
leave(): void;
/** Snapshot of peers known to the underlying CRDT (sans local). */
listKnownPeers(): readonly CRDTPeerMetadata[];
/** Tear down. Idempotent. */
destroy(): void;
/** Whether the underlying transport is connected. */
readonly isConnected: boolean;
}
/**
* Callbacks fired by the adapter into the orchestrator.
*/
export interface CRDTProviderCallbacks {
/** A peer (remote) was observed — added or updated. */
onPeer(metadata: CRDTPeerMetadata): void;
/** A peer is no longer in the awareness set. */
onPeerLost(peerId: string): void;
/** Provider has caught up with at least one remote. */
onSynced(): void;
}
/**
* Strict configuration for `CRDTDiscovery`. `provider` and `room` are
* required. Per-provider blocks are required for the matching provider.
*/
export interface CRDTDiscoveryConfig {
/** Provider selection. Fixed at construction time. */
readonly provider: CRDTProvider;
/** Logical room — only same-room peers discover each other. */
readonly room: string;
/** Yjs config (REQUIRED when `provider === "yjs"`). */
readonly yjs?: YjsSignalingConfig;
/** Automerge config (REQUIRED when `provider === "automerge"`). */
readonly automerge?: AutomergeConfig;
/** Announce interval (ms). Default `30_000`. */
readonly interval?: number;
/** Peer staleness timeout (ms). Default `60_000`. */
readonly timeout?: number;
/** Heartbeat interval (ms). Default `15_000`. */
readonly heartbeatInterval?: number;
/** Cleanup-loop interval (ms). Default `30_000`. */
readonly cleanupInterval?: number;
/** Hard cap on tracked peers. Default `100`. */
readonly maxPeers?: number;
/** WebRTC ICE servers (Yjs only). */
readonly iceServers?: readonly RTCIceServer[];
/** Injectable Yjs factory — tests pass a stub. REQUIRED for yjs provider. */
readonly yjsDocFactory?: YjsDocFactory;
/** Injectable Automerge factory — tests pass a stub. REQUIRED for automerge provider. */
readonly automergeRepoFactory?: AutomergeRepoFactory;
/** Optional event bus for typed emissions. */
readonly bus?: EventBusLike<CRDTEventMap>;
/** Optional service registry to mirror discovered peers into. */
readonly registry?: ServiceRegistry;
}
/**
* Fully resolved config — every optional field defaulted, every required
* provider factory present.
*/
export interface ResolvedCRDTDiscoveryConfig {
readonly provider: CRDTProvider;
readonly room: string;
readonly yjs: YjsSignalingConfig | undefined;
readonly automerge: AutomergeConfig | undefined;
readonly interval: number;
readonly timeout: number;
readonly heartbeatInterval: number;
readonly cleanupInterval: number;
readonly maxPeers: number;
readonly iceServers: readonly RTCIceServer[] | undefined;
readonly yjsDocFactory: YjsDocFactory | undefined;
readonly automergeRepoFactory: AutomergeRepoFactory | undefined;
readonly bus: EventBusLike<CRDTEventMap> | undefined;
readonly registry: ServiceRegistry | undefined;
}
/**
* Sync status the orchestrator can surface to callers.
*/
export interface CRDTSyncStatus {
readonly synced: boolean;
readonly syncedPeers: number;
readonly pendingPeers: number;
readonly version: number;
readonly lastSync: number;
}
/**
* Overall connection status.
*/
export interface CRDTConnectionStatus {
readonly provider: CRDTProvider;
readonly connected: boolean;
readonly peerCount: number;
readonly syncStatus: CRDTSyncStatus;
}
/** Typed events emitted on the optional `EventBusLike`. */
export interface CRDTStartedEvent {
readonly peerId: string;
readonly provider: CRDTProvider;
readonly room: string;
readonly at: number;
}
export interface CRDTDiscoveredEvent {
readonly peer: CRDTPeerMetadata;
readonly at: number;
}
export interface CRDTLostEvent {
readonly peerId: string;
readonly at: number;
}
export interface CRDTSyncFailedEvent {
readonly reason: string;
readonly provider: CRDTProvider;
readonly at: number;
}
export type CRDTEventMap = {
"crdt:started": CRDTStartedEvent;
"crdt:peer-discovered": CRDTDiscoveredEvent;
"crdt:peer-lost": CRDTLostEvent;
"crdt:sync-failed": CRDTSyncFailedEvent;
};

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("network-discovery-crdt dep-graph", () => {
it("must not have circular dependencies", async () => {
const mod = await import("../src/index.js");
expect(mod).toBeDefined();
expect(mod.CRDTDiscovery).toBeDefined();
expect(mod.CRDTPeerManager).toBeDefined();
expect(mod.CRDTIntervals).toBeDefined();
expect(mod.YjsProviderAdapter).toBeDefined();
expect(mod.AutomergeProviderAdapter).toBeDefined();
expect(mod.NET_DISC_CRDT_CODES).toBeDefined();
expect(mod.NetDiscCRDTError).toBeDefined();
expect(mod.CRDTDiscoveryConfigSchema).toBeDefined();
expect(mod.CRDTMessageType).toBeDefined();
expect(mod.withStartSpan).toBeDefined();
expect(mod.recordPeersDiscovered).toBeDefined();
});
});

View File

@@ -0,0 +1,138 @@
/**
* Deterministic in-memory Automerge substitute used by the test suites.
* Models a single shared broker — every `FakeAutomergeRepo` constructed
* against the same `FakeAutomergeBroker` shares awareness updates with every
* other repo in the same room. No mocks: real implementation of the
* `AutomergeRepoLike` structural contract.
*/
import type {
AutomergeRepoFactory,
AutomergeRepoLike,
CRDTPeerMetadata,
} from "../../src/types/types.js";
type Handler = (...args: unknown[]) => void;
let nextId = 0;
export class FakeAutomergeBroker {
public readonly rooms = new Map<string, Set<FakeAutomergeRepo>>();
public broken = false;
public join(room: string, repo: FakeAutomergeRepo): void {
let set = this.rooms.get(room);
if (set === undefined) {
set = new Set();
this.rooms.set(room, set);
}
// Replay every existing peer's snapshot into the joiner before fire.
for (const other of set) {
for (const m of other.listPeers()) repo.receiveAnnounce(m);
}
set.add(repo);
queueMicrotask(() => {
for (const other of set as Set<FakeAutomergeRepo>) {
if (other === repo) continue;
other.notePeer(repo.peerId);
repo.notePeer(other.peerId);
}
});
}
public leave(room: string, repo: FakeAutomergeRepo): void {
const set = this.rooms.get(room);
if (set === undefined) return;
set.delete(repo);
for (const other of set) other.notePeerLost(repo.peerId);
}
public broadcast(
room: string,
sender: FakeAutomergeRepo,
metadata: CRDTPeerMetadata
): void {
const set = this.rooms.get(room);
if (set === undefined) return;
for (const other of set) {
if (other === sender) continue;
other.receiveAnnounce(metadata);
}
}
}
export class FakeAutomergeRepo implements AutomergeRepoLike {
public readonly peerId: string;
private readonly handlers = new Map<string, Set<Handler>>();
private readonly known = new Map<string, CRDTPeerMetadata>();
private destroyed = false;
public constructor(
public readonly room: string,
private readonly broker: FakeAutomergeBroker
) {
nextId += 1;
this.peerId = `fake-am-${nextId}`;
broker.join(room, this);
}
public on(event: string, handler: Handler): void {
let set = this.handlers.get(event);
if (set === undefined) {
set = new Set();
this.handlers.set(event, set);
}
set.add(handler);
}
public off(event: string, handler: Handler): void {
this.handlers.get(event)?.delete(handler);
}
public announce(metadata: CRDTPeerMetadata): void {
if (this.destroyed) return;
// Mirror the announce locally so late joiners can replay it.
this.known.set(metadata.id, metadata);
this.broker.broadcast(this.room, this, metadata);
}
public listPeers(): readonly CRDTPeerMetadata[] {
return Array.from(this.known.values());
}
public shutdown(): void {
if (this.destroyed) return;
this.destroyed = true;
this.broker.leave(this.room, this);
this.handlers.clear();
}
public notePeer(peerId: string): void {
this.fire("peer", { peerId });
}
public notePeerLost(peerId: string): void {
this.known.delete(peerId);
this.fire("peer-disconnected", { peerId });
}
public receiveAnnounce(metadata: CRDTPeerMetadata): void {
this.known.set(metadata.id, metadata);
this.fire("peer", { peerId: metadata.id });
}
private fire(event: string, ...args: unknown[]): void {
for (const h of this.handlers.get(event) ?? []) h(...args);
}
}
/** Build an `AutomergeRepoFactory` bound to a shared broker. */
export function fakeAutomergeRepoFactory(
broker: FakeAutomergeBroker
): AutomergeRepoFactory {
return (room) => {
if (broker.broken) {
throw new Error("broker down");
}
return new FakeAutomergeRepo(room, broker);
};
}

View File

@@ -0,0 +1,314 @@
/**
* Deterministic in-memory Yjs substitute used by the test suites. Models a
* single shared "room" broker — every `FakeYDoc` constructed against the same
* `FakeYjsBroker` shares its `peers` map updates with every other doc. No
* mocks: this is a real implementation of the `YDocLike` / `YMapLike` /
* `WebrtcProviderLike` structural contracts.
*/
import type {
CRDTPeerMetadata,
WebrtcProviderLike,
YDocLike,
YjsDocFactory,
YMapEventLike,
YMapLike,
} from "../../src/types/types.js";
type Handler = (...args: unknown[]) => void;
let nextGuid = 0;
export class FakeYjsBroker {
public readonly rooms = new Map<string, Set<FakeWebrtcProvider>>();
public broken = false;
public join(room: string, provider: FakeWebrtcProvider): void {
let set = this.rooms.get(room);
if (set === undefined) {
set = new Set();
this.rooms.set(room, set);
}
// Replay every existing peer's current map state to the new joiner
// before announcing presence — mirrors `y-webrtc`'s "sync existing
// state on join" semantics.
for (const other of set) {
const snap = other.snapshotPeers();
for (const [key, value] of snap) {
provider.applyRemote(key, value, "add");
}
}
set.add(provider);
// Sync immediately — everyone gets everyone else's current peers.
queueMicrotask(() => {
provider.fireSynced();
for (const other of set as Set<FakeWebrtcProvider>) {
if (other !== provider) {
other.notePeerAdded(provider.doc.guid);
provider.notePeerAdded(other.doc.guid);
}
}
});
}
public leave(room: string, provider: FakeWebrtcProvider): void {
const set = this.rooms.get(room);
if (set === undefined) return;
set.delete(provider);
for (const other of set) {
other.notePeerRemoved(provider.doc.guid);
}
}
public broadcast(
room: string,
sender: FakeWebrtcProvider,
key: string,
value: CRDTPeerMetadata | undefined,
action: "add" | "update" | "delete"
): void {
const set = this.rooms.get(room);
if (set === undefined) return;
for (const other of set) {
if (other === sender) continue;
other.applyRemote(key, value, action);
}
}
}
export class FakeYMap implements YMapLike {
private readonly data = new Map<string, CRDTPeerMetadata>();
private readonly observers = new Set<(e: YMapEventLike) => void>();
public constructor(
private readonly emitRemote?: (
key: string,
value: CRDTPeerMetadata | undefined,
action: "add" | "update" | "delete"
) => void
) {}
public set(key: string, value: CRDTPeerMetadata): void {
const action: "add" | "update" = this.data.has(key) ? "update" : "add";
this.data.set(key, value);
this.fire(key, action);
this.emitRemote?.(key, value, action);
}
public get(key: string): CRDTPeerMetadata | undefined {
return this.data.get(key);
}
public delete(key: string): void {
if (!this.data.delete(key)) return;
this.fire(key, "delete");
this.emitRemote?.(key, undefined, "delete");
}
public has(key: string): boolean {
return this.data.has(key);
}
public observe(handler: (e: YMapEventLike) => void): void {
this.observers.add(handler);
}
public unobserve(handler: (e: YMapEventLike) => void): void {
this.observers.delete(handler);
}
public toJSON(): Record<string, CRDTPeerMetadata> {
return Object.fromEntries(this.data.entries());
}
/** Apply a remote change without echoing it back to the network. */
public applyRemote(
key: string,
value: CRDTPeerMetadata | undefined,
action: "add" | "update" | "delete"
): void {
if (action === "delete") {
if (!this.data.delete(key)) return;
} else {
if (value === undefined) return;
this.data.set(key, value);
}
this.fire(key, action);
}
private fire(key: string, action: "add" | "update" | "delete"): void {
const event: YMapEventLike = {
changes: { keys: new Map([[key, { action }]]) },
};
for (const observer of this.observers) observer(event);
}
}
export class FakeYDoc implements YDocLike {
public readonly guid: string;
private map: FakeYMap | null = null;
private readonly handlers = new Map<string, Set<Handler>>();
private remoteEmitter:
| ((
key: string,
value: CRDTPeerMetadata | undefined,
action: "add" | "update" | "delete"
) => void)
| undefined;
public constructor(guid?: string) {
nextGuid += 1;
this.guid = guid ?? `fake-doc-${nextGuid}`;
}
public attachEmitter(
emitter: (
key: string,
value: CRDTPeerMetadata | undefined,
action: "add" | "update" | "delete"
) => void
): void {
this.remoteEmitter = emitter;
}
public getMap(_name: string): YMapLike {
if (this.map === null) {
this.map = new FakeYMap((k, v, a) => this.remoteEmitter?.(k, v, a));
}
return this.map;
}
public destroy(): void {
this.fire("destroy");
this.handlers.clear();
}
public on(event: string, handler: Handler): void {
let set = this.handlers.get(event);
if (set === undefined) {
set = new Set();
this.handlers.set(event, set);
}
set.add(handler);
}
public off(event: string, handler: Handler): void {
this.handlers.get(event)?.delete(handler);
}
/** Apply a remote map mutation (received via the broker). */
public applyRemote(
key: string,
value: CRDTPeerMetadata | undefined,
action: "add" | "update" | "delete"
): void {
if (this.map === null) {
// Lazily create the map so applyRemote works before observers attach.
this.map = new FakeYMap((k, v, a) => this.remoteEmitter?.(k, v, a));
}
this.map.applyRemote(key, value, action);
}
public snapshotPeers(): ReadonlyArray<[string, CRDTPeerMetadata]> {
if (this.map === null) return [];
return Object.entries(this.map.toJSON());
}
private fire(event: string): void {
for (const h of this.handlers.get(event) ?? []) h();
}
}
export class FakeWebrtcProvider implements WebrtcProviderLike {
public readonly doc: FakeYDoc;
public readonly room: string;
public connected = true;
public synced = false;
private readonly handlers = new Map<string, Set<Handler>>();
private destroyed = false;
public constructor(
room: string,
doc: FakeYDoc,
private readonly broker: FakeYjsBroker
) {
this.doc = doc;
this.room = room;
doc.attachEmitter((k, v, a) => broker.broadcast(room, this, k, v, a));
if (broker.broken) {
// Schedule an immediate error after construction so callers can wire
// handlers before the failure fires.
queueMicrotask(() => {
this.connected = false;
this.fire("connection-error", new Error("broker down"));
});
return;
}
broker.join(room, this);
}
public on(event: string, handler: Handler): void {
let set = this.handlers.get(event);
if (set === undefined) {
set = new Set();
this.handlers.set(event, set);
}
set.add(handler);
}
public off(event: string, handler: Handler): void {
this.handlers.get(event)?.delete(handler);
}
public destroy(): void {
if (this.destroyed) return;
this.destroyed = true;
this.connected = false;
this.broker.leave(this.room, this);
this.handlers.clear();
}
public fireSynced(): void {
this.synced = true;
this.fire("synced");
}
public notePeerAdded(peerId: string): void {
this.fire("peers", { added: [peerId], removed: [] });
}
public notePeerRemoved(peerId: string): void {
this.fire("peers", { added: [], removed: [peerId] });
}
public applyRemote(
key: string,
value: CRDTPeerMetadata | undefined,
action: "add" | "update" | "delete"
): void {
this.doc.applyRemote(key, value, action);
}
public snapshotPeers(): ReadonlyArray<[string, CRDTPeerMetadata]> {
return this.doc.snapshotPeers();
}
private fire(event: string, ...args: unknown[]): void {
for (const h of this.handlers.get(event) ?? []) h(...args);
}
}
/** Build a `YjsDocFactory` bound to a shared broker. */
export function fakeYjsDocFactory(broker: FakeYjsBroker): YjsDocFactory {
return (room) => {
const doc = new FakeYDoc();
const provider = new FakeWebrtcProvider(room, doc, broker);
return { doc, provider };
};
}
/** Yield control until pending microtasks have drained. */
export async function flushMicrotasks(): Promise<void> {
for (let i = 0; i < 4; i += 1) {
await Promise.resolve();
}
}

View File

@@ -0,0 +1,8 @@
// Ensure `globalThis.crypto` is available in bare-Node vitest runs.
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

@@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"references": [{ "path": "./tsconfig.lib.json" }]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,48 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/network-discovery-crdt",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/events",
"@suaveplan/network",
"@suaveplan/network-bootstrap",
"@suaveplan/service-discovery",
"@suaveplan/timing",
"@suaveplan/types",
"yjs",
"y-webrtc",
"@automerge/automerge-repo",
"@automerge/automerge-repo-network-broadcastchannel",
"@automerge/automerge-repo-network-websocket",
"@automerge/automerge-repo-storage-indexeddb",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,39 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: [
"src/__benchmarks__/**",
"benchmarks/**",
"e2e/**",
"dist/**",
"node_modules/**",
],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"src/**/*.schemas.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});

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,83 @@
{
"name": "@suaveplan/network-discovery-gundb",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"browser": "./dist/index.js",
"description": "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.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": ["dist", "src", "README.md"],
"exports": {
"./package.json": "./package.json",
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
},
"./types": {
"import": { "types": "./dist/types/types.d.ts", "default": "./dist/types/types.js" },
"require": { "types": "./dist/types/types.d.ts", "default": "./dist/types/types.js" }
},
"./signing": {
"import": { "types": "./dist/signing/signing.d.ts", "default": "./dist/signing/signing.js" },
"require": { "types": "./dist/signing/signing.d.ts", "default": "./dist/signing/signing.js" }
},
"./gundb-discovery": {
"import": { "types": "./dist/core/gundb-discovery/gundb-discovery.d.ts", "default": "./dist/core/gundb-discovery/gundb-discovery.js" },
"require": { "types": "./dist/core/gundb-discovery/gundb-discovery.d.ts", "default": "./dist/core/gundb-discovery/gundb-discovery.js" }
},
"./transport": {
"import": { "types": "./dist/transport/transport.d.ts", "default": "./dist/transport/transport.js" },
"require": { "types": "./dist/transport/transport.d.ts", "default": "./dist/transport/transport.js" }
},
"./plugin": {
"import": { "types": "./dist/plugin/plugin.d.ts", "default": "./dist/plugin/plugin.js" },
"require": { "types": "./dist/plugin/plugin.d.ts", "default": "./dist/plugin/plugin.js" }
}
},
"scripts": {
"build": "vite build",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"lint": "bunx biome check src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/crypto-curve": "workspace:^",
"@suaveplan/crypto-utils": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/network": "workspace:^",
"@suaveplan/network-bootstrap": "workspace:^",
"@suaveplan/service-discovery": "workspace:^",
"@suaveplan/timing": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"peerDependencies": {
"gun": "catalog:network-discovery"
},
"peerDependenciesMeta": {
"gun": { "optional": true }
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
}
}

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,456 @@
import { constantTimeEqual } from "@suaveplan/crypto-utils";
import type { DiscoveryStrategy, PeerInfo } from "@suaveplan/network-bootstrap";
import type { Clock } from "@suaveplan/types";
import {
NET_DISC_GUNDB_CODES,
NetDiscGunDBError,
} from "../../errors/errors.js";
import {
recordAnnounceDuration,
recordInvalidMessage,
recordPeersDiscovered,
recordRelayError,
recordSignatureRejection,
withStartSpan,
} from "../../otel/otel.js";
import {
signAnnounce,
verifyAnnounceSignature,
} from "../../signing/signing.js";
import type {
GunDBDiscoveryConfig,
GunDBPeerAnnounce,
GunDBPeerMetadata,
GunNodeLike,
ResolvedGunDBDiscoveryConfig,
} from "../../types/types.js";
import {
GunDBDiscoveryConfigSchema,
GunDBPeerAnnounceSchema,
} from "../../types/types.schemas.js";
const defaultClock: Clock = () => Date.now();
const encoder = new TextEncoder();
/**
* Constant-time equality over the UTF-8 encoded form of two peer ids.
* Used to compare a candidate incoming peer-id against the local peer-id
* without leaking timing information — short-circuiting byte-level
* compares are a side-channel.
*/
function peerIdEquals(a: string, b: string): boolean {
const ba = encoder.encode(a);
const bb = encoder.encode(b);
if (ba.length !== bb.length) {
// Pad to equal length with a fresh comparison so the timing
// remains independent of the shorter input.
const max = Math.max(ba.length, bb.length);
const padA = new Uint8Array(max);
const padB = new Uint8Array(max);
padA.set(ba);
padB.set(bb);
// Run constantTimeEqual to keep one consistent code path; the
// length-mismatch already determines the answer.
constantTimeEqual(padA, padB);
return false;
}
return constantTimeEqual(ba, bb);
}
/**
* Browser-tier GunDB discovery strategy implementing the
* `@suaveplan/network-bootstrap` `DiscoveryStrategy` contract. Wraps a
* single Gun instance (constructed via the injected factory) and
* exposes a deduped peer set to bootstrap chains.
*
* The `peers` (relay) list is REQUIRED and non-empty — there is no
* implicit public-relay fallback. Construction throws
* `INVALID_OPTIONS` on missing / empty relays, missing factory, or a
* non-32-byte `signingKey`.
*
* Every outbound announce is Ed25519-signed by the local peer.
* Every inbound announce is Zod-validated AND Ed25519-verified before
* being admitted into the local peer set. Failures are silently
* dropped + counted; an attacker cannot crash a node by gossiping
* garbage.
*/
export class GunDBDiscovery implements DiscoveryStrategy {
public readonly name = "gundb";
public readonly config: ResolvedGunDBDiscoveryConfig;
private readonly clock: Clock;
private readonly peers = new Map<string, GunDBPeerMetadata>();
private peersNode: GunNodeLike | null = null;
private running = false;
private disposed = false;
public constructor(
rawConfig: GunDBDiscoveryConfig,
clock: Clock = defaultClock
) {
const parsed = GunDBDiscoveryConfigSchema.safeParse(rawConfig);
if (!parsed.success) {
throw new NetDiscGunDBError(
NET_DISC_GUNDB_CODES.INVALID_OPTIONS,
"errors.gundb.invalid_options",
{ cause: parsed.error }
);
}
// Zod treats `unknown` as "any value accepted" — defend
// against an explicit `undefined` / non-callable factory.
if (typeof rawConfig.gunFactory !== "function") {
throw new NetDiscGunDBError(
NET_DISC_GUNDB_CODES.INVALID_OPTIONS,
"errors.gundb.factory_required"
);
}
this.clock = clock;
this.config = {
peers: parsed.data.peers,
room: parsed.data.room,
localStorage: parsed.data.localStorage,
announceIntervalMs: parsed.data.announceIntervalMs,
maxPeers: parsed.data.maxPeers,
peerTtlMs: parsed.data.peerTtlMs,
gunFactory: rawConfig.gunFactory,
localPeerId: parsed.data.localPeerId,
signingKey: parsed.data.signingKey,
bus: rawConfig.bus,
registry: rawConfig.registry,
};
}
public async start(signal?: AbortSignal): Promise<void> {
if (signal?.aborted) throw toAbortError(signal);
if (this.disposed) {
throw new NetDiscGunDBError(
NET_DISC_GUNDB_CODES.DISPOSED,
"errors.gundb.disposed"
);
}
if (this.running) return;
await withStartSpan(async (span) => {
span.setAttribute("room", this.config.room);
span.setAttribute("relay_count", this.config.peers.length);
let instance: GunNodeLike;
try {
instance = await this.config.gunFactory({
peers: this.config.peers,
localStorage: this.config.localStorage,
});
} catch (err) {
this.emitRelayError(undefined, asError(err).message);
recordRelayError(undefined);
throw new NetDiscGunDBError(
NET_DISC_GUNDB_CODES.RELAY_FAILED,
"errors.gundb.relay_failed",
{ cause: asError(err) }
);
}
if (signal?.aborted) {
throw toAbortError(signal);
}
this.peersNode = instance.get(
`discovery:${this.config.room}:peers`
);
this.subscribePeers(this.peersNode);
this.running = true;
this.config.bus?.emit("gundb:started", {
room: this.config.room,
relayCount: this.config.peers.length,
at: this.clock(),
});
});
}
public async stop(signal?: AbortSignal): Promise<void> {
if (!this.running) return;
this.running = false;
if (signal?.aborted) {
this.teardown();
return;
}
this.teardown();
this.config.bus?.emit("gundb:stopped", { at: this.clock() });
}
public async discover(
_timeoutMs: number,
signal?: AbortSignal
): Promise<PeerInfo[]> {
if (signal?.aborted) throw toAbortError(signal);
if (this.disposed) {
throw new NetDiscGunDBError(
NET_DISC_GUNDB_CODES.DISPOSED,
"errors.gundb.disposed"
);
}
const out: PeerInfo[] = [];
for (const m of this.peers.values()) {
out.push({
peerId: m.id,
addresses: [...m.addresses],
lastSeen: m.lastSeen,
source: this.name,
});
}
return out;
}
public async isAvailable(signal?: AbortSignal): Promise<boolean> {
if (signal?.aborted) throw toAbortError(signal);
return this.running && !this.disposed;
}
public async announce(
peerInfo: PeerInfo,
signal?: AbortSignal
): Promise<void> {
if (signal?.aborted) throw toAbortError(signal);
if (this.disposed) {
throw new NetDiscGunDBError(
NET_DISC_GUNDB_CODES.DISPOSED,
"errors.gundb.disposed"
);
}
if (!this.running || this.peersNode === null) {
throw new NetDiscGunDBError(
NET_DISC_GUNDB_CODES.NOT_RUNNING,
"errors.gundb.not_running"
);
}
const t0 = this.clock();
const signed = signAnnounce(this.config.signingKey, {
kind: "peer-announce",
peerId: this.config.localPeerId,
addresses: [...peerInfo.addresses],
metadata: {},
capabilities: [],
lastSeen: t0,
});
try {
this.peersNode.get(this.config.localPeerId).put(signed);
} catch (err) {
recordRelayError(undefined);
throw new NetDiscGunDBError(
NET_DISC_GUNDB_CODES.RELAY_FAILED,
"errors.gundb.relay_failed",
{ cause: asError(err) }
);
}
recordAnnounceDuration(this.clock() - t0);
// Mirror local peer into the optional registry.
this.config.registry?.register({
id: this.registryId(this.config.localPeerId),
name: "gundb-discovery",
address: peerInfo.addresses[0] ?? this.config.localPeerId,
metadata: {
source: this.name,
room: this.config.room,
addresses: [...peerInfo.addresses],
},
});
}
/** Local peer count. */
public getPeerCount(): number {
return this.peers.size;
}
/** Snapshot of the live peer set. */
public getPeers(): readonly GunDBPeerMetadata[] {
return Array.from(this.peers.values());
}
/** Whether `start()` has completed and `stop()`/`dispose()` have not. */
public isRunning(): boolean {
return this.running;
}
/** Dispose every resource. After `dispose()` the strategy is unusable. */
public dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.running = false;
this.teardown();
}
/** Subscribe to the room peer-set; dispatch into the inbound handler. */
private subscribePeers(node: GunNodeLike): void {
node.map().on((data: unknown, key: string) => {
this.handleIncomingPayload(data, key);
});
}
/**
* Validate + verify an inbound wire payload and either admit it to
* the local peer set or silently drop it with the appropriate
* counter increment. Never throws.
*/
private handleIncomingPayload(data: unknown, key: string): void {
if (!this.running) return;
// Skip the local peer's own record — gossip will echo it back.
if (peerIdEquals(key, this.config.localPeerId)) return;
// Step 1: shape validation via Zod.
const parsed = GunDBPeerAnnounceSchema.safeParse(data);
if (!parsed.success) {
// Distinguish "no signature fields" (UNSIGNED_ANNOUNCE) from
// "malformed" (INVALID_WIRE_PAYLOAD / wrong-kind).
const reason = classifyParseFailure(data);
recordInvalidMessage(reason);
if (reason === "unsigned") {
recordSignatureRejection("unsigned");
}
this.emitRelayError(undefined, `invalid wire payload: ${reason}`);
return;
}
const announce: GunDBPeerAnnounce = parsed.data;
// Step 2: cryptographic verification.
if (!verifyAnnounceSignature(announce)) {
recordInvalidMessage("bad-signature");
recordSignatureRejection("bad-signature");
this.emitRelayError(undefined, "bad signature on inbound announce");
return;
}
// Step 3: ensure the announce's peerId matches the GunDB key
// it was published under (constant-time compare). Mismatches
// are a spoofing attempt — treat as INVALID_WIRE_PAYLOAD.
if (!peerIdEquals(announce.peerId, key)) {
recordInvalidMessage("wrong-kind");
this.emitRelayError(undefined, "peerId mismatch");
return;
}
// Step 4: admit. Update lastSeen if the peer is already known;
// otherwise insert and emit `peer-discovered` (respecting
// `maxPeers`).
const existing = this.peers.get(announce.peerId);
if (existing !== undefined) {
existing.lastSeen = announce.lastSeen;
return;
}
if (this.peers.size >= this.config.maxPeers) {
recordPeersDiscovered("rejected");
return;
}
const metadata: GunDBPeerMetadata = {
id: announce.peerId,
addresses: [...announce.addresses],
metadata: { ...announce.metadata },
capabilities: [...announce.capabilities],
connectedAt: this.clock(),
lastSeen: announce.lastSeen,
};
this.peers.set(announce.peerId, metadata);
recordPeersDiscovered("found");
this.config.bus?.emit("gundb:peer-discovered", {
peer: metadata,
at: this.clock(),
});
this.config.registry?.register({
id: this.registryId(announce.peerId),
name: "gundb-discovery",
address: metadata.addresses[0] ?? announce.peerId,
metadata: {
source: this.name,
room: this.config.room,
addresses: [...metadata.addresses],
},
});
}
private emitRelayError(relay: string | undefined, reason: string): void {
this.config.bus?.emit("gundb:relay-error", {
relay,
reason,
at: this.clock(),
});
}
private teardown(): void {
const node = this.peersNode;
this.peersNode = null;
if (node !== null) {
try {
node.off();
} catch {
/* swallow */
}
}
if (this.config.registry !== undefined) {
for (const id of this.peers.keys()) {
try {
this.config.registry.deregister(this.registryId(id));
} catch {
/* swallow */
}
}
try {
this.config.registry.deregister(
this.registryId(this.config.localPeerId)
);
} catch {
/* swallow */
}
}
this.peers.clear();
}
private registryId(peerId: string): string {
return `gundb:${this.config.room}:${peerId}`;
}
}
/**
* Distinguish "unsigned" announces from other malformed shapes.
* Inspect the raw payload directly so a Zod failure on `signature` /
* `signaturePublicKey` (missing or wrong type) classifies as
* `"unsigned"`, while every other failure classifies as
* `"malformed"` or `"wrong-kind"`.
*/
function classifyParseFailure(
data: unknown
): "unsigned" | "wrong-kind" | "malformed" {
if (data === null || typeof data !== "object") return "malformed";
const obj = data as Record<string, unknown>;
if ("kind" in obj && obj["kind"] !== "peer-announce") return "wrong-kind";
const sig = obj["signature"];
const pub = obj["signaturePublicKey"];
if (
typeof sig !== "string" ||
sig.length === 0 ||
typeof pub !== "string" ||
pub.length === 0
) {
return "unsigned";
}
return "malformed";
}
function asError(value: unknown): Error {
if (value instanceof Error) return value;
return new Error(typeof value === "string" ? value : String(value));
}
function toAbortError(signal: AbortSignal): Error {
if (signal.reason instanceof Error) return signal.reason;
const err = new Error("Aborted");
err.name = "AbortError";
return err;
}

View File

@@ -0,0 +1 @@
export { GunDBDiscovery } from "./gundb-discovery.js";

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,44 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { NET_DISC_GUNDB_CODES, NetDiscGunDBError } from "./errors.js";
describe("NetDiscGunDBError", () => {
it("registers exactly the documented codes", { timeout: 5000 }, () => {
expect(Object.keys(NET_DISC_GUNDB_CODES).sort()).toEqual(
[
"BAD_SIGNATURE",
"DISPOSED",
"INVALID_OPTIONS",
"INVALID_WIRE_PAYLOAD",
"NOT_RUNNING",
"PEER_CONNECT_FAILED",
"PEER_NOT_CONNECTED",
"RELAY_FAILED",
"SEND_FAILED",
"TRANSPORT_CLOSED",
"UNSIGNED_ANNOUNCE",
].sort()
);
});
it("preserves code, name, and cause", { timeout: 5000 }, () => {
const cause = new Error("root");
const err = new NetDiscGunDBError(
NET_DISC_GUNDB_CODES.RELAY_FAILED,
"errors.gundb.relay_failed",
{ cause }
);
expect(err.code).toBe(NET_DISC_GUNDB_CODES.RELAY_FAILED);
expect(err.name).toBe("NetDiscGunDBError");
expect(err.cause).toBe(cause);
expect(err instanceof NetDiscGunDBError).toBe(true);
});
it("constructs without options", { timeout: 5000 }, () => {
const err = new NetDiscGunDBError(
NET_DISC_GUNDB_CODES.INVALID_OPTIONS,
"errors.gundb.invalid_options"
);
expect(err.cause).toBeUndefined();
expect(err.code).toBe(NET_DISC_GUNDB_CODES.INVALID_OPTIONS);
});
});

View File

@@ -0,0 +1,67 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
/**
* Error codes emitted by `@suaveplan/network-discovery-gundb`.
*
* - `INVALID_OPTIONS` — caller-supplied config failed Zod validation, the
* `peers` list was missing or empty, the required `gunFactory` was
* missing, or `signingKey` was not a 32-byte `Uint8Array`.
* - `DISPOSED` — operation attempted after `dispose()` permanently
* cleared state.
* - `NOT_RUNNING` — operation attempted before `start()` completed (or
* after `stop()` cleared state).
* - `RELAY_FAILED` — every relay in the configured list failed, or the
* underlying `Gun` factory rejected.
* - `PEER_CONNECT_FAILED` — the gossip transport surfaced a peer record
* that could not be admitted to the local set.
* - `UNSIGNED_ANNOUNCE` — an inbound announce was missing `signature` or
* `signaturePublicKey`. Silently dropped; counter incremented.
* - `BAD_SIGNATURE` — an inbound announce's Ed25519 verification failed.
* Silently dropped; counter incremented.
* - `INVALID_WIRE_PAYLOAD` — an inbound payload failed Zod validation
* (malformed JSON, missing fields, wrong `kind`, etc.). Silently
* dropped; counter incremented.
*/
export const NET_DISC_GUNDB_CODES = defineCodes("NET_DISC_GUNDB", [
"INVALID_OPTIONS",
"DISPOSED",
"NOT_RUNNING",
"RELAY_FAILED",
"PEER_CONNECT_FAILED",
"UNSIGNED_ANNOUNCE",
"BAD_SIGNATURE",
"INVALID_WIRE_PAYLOAD",
"TRANSPORT_CLOSED",
"PEER_NOT_CONNECTED",
"SEND_FAILED",
] as const);
export type NetDiscGunDBErrorCode =
(typeof NET_DISC_GUNDB_CODES)[keyof typeof NET_DISC_GUNDB_CODES];
/**
* Structured error class for the GunDB discovery strategy. Every public
* boundary throws this on failure; consumers branch on `error.code`.
*
* The wire-payload rejection codes (`UNSIGNED_ANNOUNCE`,
* `BAD_SIGNATURE`, `INVALID_WIRE_PAYLOAD`) are also used as
* `reason`-style labels on the
* `gundb.invalid_messages_total{reason}` / `signature_rejected_total`
* counters — but they are NOT thrown out of the orchestrator's inbound
* message handler, because a malicious peer must not be able to crash
* a node by sending garbage. They are surfaced only via the counter.
*/
export class NetDiscGunDBError extends SuaveplanError<NetDiscGunDBErrorCode> {
public constructor(
code: NetDiscGunDBErrorCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = "NetDiscGunDBError";
}
}

View File

@@ -0,0 +1,72 @@
// Core
export { GunDBDiscovery } from "./core/gundb-discovery/gundb-discovery.js";
// Errors
export {
NET_DISC_GUNDB_CODES,
NetDiscGunDBError,
type NetDiscGunDBErrorCode,
} from "./errors/errors.js";
// OTel
export {
type InvalidMessageReason as OTelInvalidMessageReason,
type PeerDiscoveryOutcome,
recordAnnounceDuration,
recordInvalidMessage,
recordPeersDiscovered,
recordRelayError,
recordSignatureRejection,
type SignatureRejectionReason,
withStartSpan,
} from "./otel/otel.js";
// Plugin
export { GunDBPlugin } from "./plugin/plugin.js";
// Signing
export {
canonicalSerializeAnnounceBody,
publicKeyHex,
signAnnounce,
verifyAnnounceSignature,
} from "./signing/signing.js";
export {
canonicalSerializeEnvelope,
type SignedEnvelopeWire,
signEnvelope,
verifyEnvelopeSignature,
} from "./transport/envelope-signing.js";
// Transport
export {
GunDBTransport,
type GunDBTransportConfig,
} from "./transport/transport.js";
// Types
export type {
GunDBDiscoveredEvent,
GunDBDiscoveryConfig,
GunDBEventMap,
GunDBLostEvent,
GunDBMessage,
GunDBPeerAnnounce,
GunDBPeerAnnounceBody,
GunDBPeerMetadata,
GunDBRelayErrorEvent,
GunDBStartedEvent,
GunDBStoppedEvent,
GunFactory,
GunFactoryConfig,
GunNodeLike,
HexEd25519PublicKey,
HexEd25519Signature,
InvalidMessageReason,
ResolvedGunDBDiscoveryConfig,
} from "./types/types.js";
// Schemas
export {
GunDBDiscoveryConfigSchema,
GunDBMessageSchema,
GunDBPeerAnnounceBodySchema,
GunDBPeerAnnounceSchema,
GunDBPeerMetadataSchema,
} from "./types/types.schemas.js";

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,67 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
recordAnnounceDuration,
recordInvalidMessage,
recordPeersDiscovered,
recordRelayError,
recordSignatureRejection,
recordTransportActivePeers,
recordTransportMessage,
withStartSpan,
withTransportConnectSpan,
withTransportDisconnectSpan,
withTransportSendSpan,
} from "./otel.js";
describe("otel surface", () => {
it("withStartSpan resolves and propagates errors", {
timeout: 5000,
}, async () => {
await expect(withStartSpan(async () => 42)).resolves.toBe(42);
await expect(
withStartSpan(async () => {
throw new Error("boom");
})
).rejects.toThrow("boom");
});
it("counters and histograms accept calls with no SDK", {
timeout: 5000,
}, () => {
recordPeersDiscovered("found", 3);
recordPeersDiscovered("duplicate");
recordPeersDiscovered("rejected", 0);
recordPeersDiscovered("rejected", -5);
recordInvalidMessage("malformed");
recordInvalidMessage("wrong-kind");
recordInvalidMessage("unsigned");
recordInvalidMessage("bad-signature");
recordSignatureRejection("unsigned");
recordSignatureRejection("bad-signature");
recordRelayError("wss://relay.example.com/gun");
recordRelayError(undefined);
recordAnnounceDuration(125);
recordAnnounceDuration(-1);
recordTransportMessage("inbound", "ok");
recordTransportMessage("outbound", "error");
recordTransportActivePeers(1);
recordTransportActivePeers(-1);
recordTransportActivePeers(0);
expect(true).toBe(true);
});
it("transport span wrappers resolve and propagate errors", {
timeout: 5000,
}, async () => {
await expect(withTransportConnectSpan(async () => 1)).resolves.toBe(1);
await expect(withTransportDisconnectSpan(async () => 2)).resolves.toBe(
2
);
await expect(withTransportSendSpan(async () => 3)).resolves.toBe(3);
await expect(
withTransportSendSpan(async () => {
throw new Error("boom");
})
).rejects.toThrow("boom");
});
});

View File

@@ -0,0 +1,218 @@
import { metrics, type Span, type Tracer, trace } from "@opentelemetry/api";
const TRACER_NAME = "@suaveplan/network-discovery-gundb";
const METER_NAME = "@suaveplan/network-discovery-gundb";
const tracer: Tracer = trace.getTracer(TRACER_NAME);
const meter = metrics.getMeter(METER_NAME);
/**
* Counter incremented for every peer the strategy emits, labelled by
* the outcome of the discovery attempt.
*/
const peersDiscoveredCounter = meter.createCounter(
"disc_gundb.peers_discovered_total",
{
description:
"Total peers seen by the GunDB discovery strategy, by outcome.",
}
);
/**
* Counter incremented every time an inbound wire payload is silently
* dropped, labelled by the reason. This is the **security-observability
* lever** — any spike on `reason="bad-signature"` or
* `reason="unsigned"` indicates an active misbehaving peer or relay.
*/
const invalidMessagesCounter = meter.createCounter(
"gundb.invalid_messages_total",
{
description:
"Wire payloads silently dropped because they failed Zod validation or were missing required fields.",
}
);
/**
* Counter incremented every time an inbound announce is dropped due to
* signature problems. Tracked separately from the generic
* invalid-messages counter so dashboards can distinguish protocol
* malformations (`INVALID_WIRE_PAYLOAD`) from cryptographic ones
* (`UNSIGNED_ANNOUNCE` / `BAD_SIGNATURE`).
*/
const signatureRejectedCounter = meter.createCounter(
"gundb.signature_rejected_total",
{
description:
"Inbound announces dropped because they were unsigned or carried a bad signature.",
}
);
/**
* Counter incremented for every relay-level warning or failure
* surfaced by the underlying Gun instance, labelled by relay URL when
* known.
*/
const relayErrorsCounter = meter.createCounter(
"disc_gundb.relay_errors_total",
{
description:
"Relay warnings / failures emitted by the underlying Gun instance.",
}
);
/**
* Histogram of the wall-clock duration (ms) for each outbound
* announce publish. Useful for catching relay back-pressure.
*/
const announceDurationHistogram = meter.createHistogram(
"gundb_announce_duration_ms",
{
description:
"Wall-clock duration of each outbound peer-announce publish, in milliseconds.",
unit: "ms",
}
);
/** Wrap the `start()` body in an OTel span. */
export async function withStartSpan<T>(
fn: (span: Span) => Promise<T>
): Promise<T> {
return tracer.startActiveSpan("disc_gundb.start", async (span) => {
try {
return await fn(span);
} finally {
span.end();
}
});
}
/** Outcome label for {@link recordPeersDiscovered}. */
export type PeerDiscoveryOutcome = "found" | "duplicate" | "rejected";
/** Increment the peers-discovered counter. */
export function recordPeersDiscovered(
outcome: PeerDiscoveryOutcome,
count = 1
): void {
if (count > 0) {
peersDiscoveredCounter.add(count, { outcome });
}
}
/** Reason label for {@link recordInvalidMessage}. */
export type InvalidMessageReason =
| "malformed"
| "wrong-kind"
| "unsigned"
| "bad-signature";
/** Increment the invalid-messages counter. */
export function recordInvalidMessage(reason: InvalidMessageReason): void {
invalidMessagesCounter.add(1, { reason });
}
/** Reason label for {@link recordSignatureRejection}. */
export type SignatureRejectionReason = "unsigned" | "bad-signature";
/** Increment the signature-rejected counter. */
export function recordSignatureRejection(
reason: SignatureRejectionReason
): void {
signatureRejectedCounter.add(1, { reason });
}
/** Increment the relay-errors counter. */
export function recordRelayError(relay: string | undefined): void {
relayErrorsCounter.add(1, { relay: relay ?? "unknown" });
}
/** Record the wall-clock duration of one outbound announce publish. */
export function recordAnnounceDuration(ms: number): void {
announceDurationHistogram.record(Math.max(0, ms));
}
/**
* Transport-tier counter: number of messages sent or received through the
* `GunDBTransport` adapter, labelled by direction (`inbound`/`outbound`)
* and outcome (`ok`/`error`).
*/
const transportMessagesCounter = meter.createCounter(
"gundb_transport.messages_total",
{
description:
"Messages sent or received through the GunDB transport adapter.",
}
);
/**
* Transport-tier active-peer gauge. Updated on every connect/disconnect
* and emitted as an `up-down counter` so dashboards see the live count.
*/
const transportActivePeersGauge = meter.createUpDownCounter(
"gundb_transport.active_peers",
{
description: "Currently connected transport peers.",
}
);
/** Wrap the transport `connect()` body in an OTel span. */
export async function withTransportConnectSpan<T>(
fn: (span: Span) => Promise<T>
): Promise<T> {
return tracer.startActiveSpan("gundb_transport.connect", async (span) => {
try {
return await fn(span);
} finally {
span.end();
}
});
}
/** Wrap the transport `disconnect()` body in an OTel span. */
export async function withTransportDisconnectSpan<T>(
fn: (span: Span) => Promise<T>
): Promise<T> {
return tracer.startActiveSpan(
"gundb_transport.disconnect",
async (span) => {
try {
return await fn(span);
} finally {
span.end();
}
}
);
}
/** Wrap the transport `send()` body in an OTel span. */
export async function withTransportSendSpan<T>(
fn: (span: Span) => Promise<T>
): Promise<T> {
return tracer.startActiveSpan("gundb_transport.send", async (span) => {
try {
return await fn(span);
} finally {
span.end();
}
});
}
/** Direction label for {@link recordTransportMessage}. */
export type TransportMessageDirection = "inbound" | "outbound";
/** Outcome label for {@link recordTransportMessage}. */
export type TransportMessageOutcome = "ok" | "error";
/** Increment the transport-message counter. */
export function recordTransportMessage(
direction: TransportMessageDirection,
outcome: TransportMessageOutcome
): void {
transportMessagesCounter.add(1, { direction, outcome });
}
/** Adjust the transport active-peers gauge by `delta` (+1 / -1). */
export function recordTransportActivePeers(delta: number): void {
if (delta === 0) return;
transportActivePeersGauge.add(delta);
}

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,120 @@
import { ed25519 } from "@suaveplan/crypto-curve";
import { NetworkNode } from "@suaveplan/network";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { FakeGun } from "../../tests/setup/fake-gun.js";
import { publicKeyHex } from "../signing/signing.js";
import { GunDBPlugin } from "./plugin.js";
const RELAYS = ["https://relay.example.com/gun"];
function makeKey(): { sk: Uint8Array; id: string } {
const sk = ed25519.randomPrivateKey();
return { sk, id: publicKeyHex(sk) };
}
describe("GunDBPlugin (NET-01 / NET-04)", () => {
it("exposes the canonical plugin name", { timeout: 5000 }, () => {
const { sk, id } = makeKey();
const p = new GunDBPlugin({
peers: RELAYS,
gunFactory: () => new FakeGun({ peers: RELAYS }),
localPeerId: id,
signingKey: sk,
});
expect(p.name).toBe("gundb");
});
it("registers its transport with the node on install", {
timeout: 5000,
}, () => {
const { sk, id } = makeKey();
const node = new NetworkNode();
const plugin = new GunDBPlugin({
peers: RELAYS,
gunFactory: () => new FakeGun({ peers: RELAYS }),
localPeerId: id,
signingKey: sk,
});
node.use(plugin);
const transports = node.messageRouter.getTransports();
expect(transports.map((t) => t.id)).toContain("gundb");
});
it("start() is a no-op", { timeout: 5000 }, async () => {
const { sk, id } = makeKey();
const node = new NetworkNode();
const plugin = new GunDBPlugin({
peers: RELAYS,
gunFactory: () => new FakeGun({ peers: RELAYS }),
localPeerId: id,
signingKey: sk,
});
node.use(plugin);
await node.start();
expect(node.isStarted).toBe(true);
await node.stop();
});
it("stop() closes the transport and unregisters it from the node", {
timeout: 5000,
}, async () => {
const { sk, id } = makeKey();
const node = new NetworkNode();
const plugin = new GunDBPlugin({
peers: RELAYS,
gunFactory: () => new FakeGun({ peers: RELAYS }),
localPeerId: id,
signingKey: sk,
});
node.use(plugin);
await node.start();
await node.stop();
expect(
node.messageRouter.getTransports().map((t) => t.id)
).not.toContain("gundb");
expect(plugin.transport.isClosed()).toBe(true);
});
it("dispose releases the node reference and is idempotent", {
timeout: 5000,
}, () => {
const { sk, id } = makeKey();
const plugin = new GunDBPlugin({
peers: RELAYS,
gunFactory: () => new FakeGun({ peers: RELAYS }),
localPeerId: id,
signingKey: sk,
});
const node = new NetworkNode();
plugin.install(node);
plugin.dispose();
plugin.dispose();
});
it("stop is safe to call when no node was installed", {
timeout: 5000,
}, async () => {
const { sk, id } = makeKey();
const plugin = new GunDBPlugin({
peers: RELAYS,
gunFactory: () => new FakeGun({ peers: RELAYS }),
localPeerId: id,
signingKey: sk,
});
await plugin.stop();
expect(plugin.transport.isClosed()).toBe(true);
});
it("exposes the transport handle for advanced wiring", {
timeout: 5000,
}, () => {
const { sk, id } = makeKey();
const plugin = new GunDBPlugin({
peers: RELAYS,
gunFactory: () => new FakeGun({ peers: RELAYS }),
localPeerId: id,
signingKey: sk,
});
expect(plugin.transport.id).toBe("gundb");
});
});

View File

@@ -0,0 +1,49 @@
import type { NetworkNode, NetworkPlugin } from "@suaveplan/network";
import {
GunDBTransport,
type GunDBTransportConfig,
} from "../transport/transport.js";
/**
* `NetworkPlugin` that exposes a `GunDBTransport` to the host
* `NetworkNode`. Lifecycle is a thin wrapper:
*
* - `install(node)` registers the transport with the node so the
* router can find it by id.
* - `start()` is a no-op — Gun is opened lazily on first connect/send.
* - `stop()` closes the transport and unregisters it.
* - `dispose()` releases the node reference; idempotent.
*
* The plugin does NOT introduce a separate security surface — it
* delegates every wire-level operation to `GunDBTransport`, which is
* the load-bearing place that signs + verifies envelopes. Bypassing
* the transport (e.g. by reaching into its private state) would
* weaken the cryptographic contract.
*/
export class GunDBPlugin implements NetworkPlugin {
public readonly name = "gundb";
public readonly transport: GunDBTransport;
private node: NetworkNode | null = null;
public constructor(config: GunDBTransportConfig) {
this.transport = new GunDBTransport(config);
}
public install(node: NetworkNode): void {
this.node = node;
node.registerTransport(this.transport);
}
public start(): void {
// Lazy connect — nothing to do.
}
public async stop(): Promise<void> {
await this.transport.close();
this.node?.unregisterTransport(this.transport.id);
}
public dispose(): void {
this.node = null;
}
}

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,191 @@
import { ed25519 } from "@suaveplan/crypto-curve";
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import type {
GunDBPeerAnnounce,
GunDBPeerAnnounceBody,
} from "../types/types.js";
import {
_hex,
canonicalSerializeAnnounceBody,
publicKeyHex,
signAnnounce,
verifyAnnounceSignature,
} from "./signing.js";
const SK = ed25519.randomPrivateKey();
const baseBody: GunDBPeerAnnounceBody = {
kind: "peer-announce",
peerId: "local",
addresses: ["/webrtc/p2p/local"],
metadata: { region: "eu" },
capabilities: ["webrtc"],
lastSeen: 1_700_000_000_000,
};
describe("canonicalSerializeAnnounceBody", () => {
it("is deterministic across key-insertion order", {
timeout: 5000,
}, () => {
const a = canonicalSerializeAnnounceBody({
...baseBody,
metadata: { z: 1, a: 2 },
});
const b = canonicalSerializeAnnounceBody({
...baseBody,
metadata: { a: 2, z: 1 },
});
expect(a).toEqual(b);
});
it("handles nested objects, arrays, null, primitives", {
timeout: 5000,
}, () => {
const a = canonicalSerializeAnnounceBody({
...baseBody,
metadata: {
b: { y: [1, 2, null, "x"], x: false },
a: null,
c: 0,
},
});
const b = canonicalSerializeAnnounceBody({
...baseBody,
metadata: {
c: 0,
a: null,
b: { x: false, y: [1, 2, null, "x"] },
},
});
expect(a).toEqual(b);
});
});
describe("publicKeyHex", () => {
it("derives correct 64-char hex public key", { timeout: 5000 }, () => {
const hex = publicKeyHex(SK);
expect(hex.length).toBe(64);
expect(/^[0-9a-f]+$/.test(hex)).toBe(true);
});
it("throws on wrong-length input", { timeout: 5000 }, () => {
expect(() => publicKeyHex(new Uint8Array(16))).toThrow();
});
});
describe("signAnnounce / verifyAnnounceSignature", () => {
it("round-trip: signed announce verifies", { timeout: 5000 }, () => {
const a = signAnnounce(SK, baseBody);
expect(verifyAnnounceSignature(a)).toBe(true);
expect(a.signaturePublicKey.length).toBe(64);
expect(a.signature.length).toBe(128);
});
it("signAnnounce rejects wrong-length private key", {
timeout: 5000,
}, () => {
expect(() => signAnnounce(new Uint8Array(16), baseBody)).toThrow();
});
it("single-bit-flip in signature breaks verification", {
timeout: 5000,
}, () => {
const a = signAnnounce(SK, baseBody);
const lastChar = a.signature.slice(-1);
const flipped =
a.signature.slice(0, -1) + (lastChar === "0" ? "1" : "0");
const tampered: GunDBPeerAnnounce = { ...a, signature: flipped };
expect(verifyAnnounceSignature(tampered)).toBe(false);
});
it("body tampering breaks verification", { timeout: 5000 }, () => {
const a = signAnnounce(SK, baseBody);
const tampered: GunDBPeerAnnounce = {
...a,
lastSeen: a.lastSeen + 1,
};
expect(verifyAnnounceSignature(tampered)).toBe(false);
});
it("malformed hex signature returns false (never throws)", {
timeout: 5000,
}, () => {
const a = signAnnounce(SK, baseBody);
const malformed1: GunDBPeerAnnounce = {
...a,
signature: `zz${a.signature.slice(2)}`,
};
const malformed2: GunDBPeerAnnounce = {
...a,
signaturePublicKey: "zzz",
};
expect(verifyAnnounceSignature(malformed1)).toBe(false);
expect(verifyAnnounceSignature(malformed2)).toBe(false);
});
it("wrong-length signature returns false", { timeout: 5000 }, () => {
const a = signAnnounce(SK, baseBody);
const shortSig: GunDBPeerAnnounce = {
...a,
signature: "aa".repeat(32),
};
expect(verifyAnnounceSignature(shortSig)).toBe(false);
const shortPub: GunDBPeerAnnounce = {
...a,
signaturePublicKey: "aa".repeat(16),
};
expect(verifyAnnounceSignature(shortPub)).toBe(false);
});
it("odd-length hex returns false", { timeout: 5000 }, () => {
const a = signAnnounce(SK, baseBody);
const odd: GunDBPeerAnnounce = { ...a, signature: "a".repeat(127) };
expect(verifyAnnounceSignature(odd)).toBe(false);
});
it("fast-check: any valid body round-trips", { timeout: 10_000 }, () => {
fc.assert(
fc.property(
fc.string({ minLength: 1, maxLength: 32 }),
fc.array(fc.string({ minLength: 1, maxLength: 16 }), {
maxLength: 5,
}),
fc.array(fc.string({ minLength: 1, maxLength: 16 }), {
maxLength: 5,
}),
fc.integer({ min: 0, max: 2 ** 50 }),
(peerId, addresses, capabilities, lastSeen) => {
const a = signAnnounce(SK, {
kind: "peer-announce",
peerId,
addresses,
metadata: {},
capabilities,
lastSeen,
});
return verifyAnnounceSignature(a);
}
),
{ numRuns: 50 }
);
});
});
describe("_hex internal codec", () => {
it("round-trips bytes ↔ hex", { timeout: 5000 }, () => {
const bytes = new Uint8Array([0, 1, 15, 16, 254, 255]);
const hex = _hex.toHex(bytes);
expect(hex).toBe("00010f10feff");
expect(_hex.fromHex(hex)).toEqual(bytes);
});
it("fromHex rejects odd length", { timeout: 5000 }, () => {
expect(() => _hex.fromHex("abc")).toThrow();
});
it("fromHex rejects non-hex characters", { timeout: 5000 }, () => {
expect(() => _hex.fromHex("zz")).toThrow();
});
});

View File

@@ -0,0 +1,171 @@
import { ed25519 } from "@suaveplan/crypto-curve";
import type {
GunDBPeerAnnounce,
GunDBPeerAnnounceBody,
} from "../types/types.js";
/**
* Canonical UTF-8 byte serialization of a `GunDBPeerAnnounceBody`.
*
* Two implementations producing different byte sequences would
* silently allow signature forgery, so this routine deliberately
* fixes:
*
* 1. Field order: `kind → peerId → lastSeen → addresses → capabilities → metadata`.
* 2. Addresses + capabilities serialize as `["a","b"]` with no
* sorting (caller-controlled order is part of the announcement).
* 3. `metadata` is serialised with object keys sorted lexicographically
* so different runtime insertion orders produce the same canonical
* bytes. Values are JSON-serialised recursively with the same
* key-sort rule so nested objects are also canonical.
*
* The output is a UTF-8 encoded line of the form:
*
* ```
* peer-announce|<peerId>|<lastSeen>|<JSON addresses>|<JSON capabilities>|<JSON metadata>
* ```
*
* which is deliberately not a valid JSON document — this makes the
* signed input visually distinct from the wire payload it is derived
* from, and removes any temptation for callers to "just JSON.stringify"
* the announce body and end up with a non-deterministic byte string.
*/
export function canonicalSerializeAnnounceBody(
body: GunDBPeerAnnounceBody
): Uint8Array {
const addresses = JSON.stringify(body.addresses);
const capabilities = JSON.stringify(body.capabilities);
const metadata = canonicalJSON(body.metadata);
const line = `${body.kind}|${body.peerId}|${body.lastSeen}|${addresses}|${capabilities}|${metadata}`;
return new TextEncoder().encode(line);
}
/**
* Deterministic JSON serializer: object keys sorted lexicographically
* at every level. Arrays preserve order. Primitives serialise via
* `JSON.stringify`. Functions, symbols, and `undefined` produce
* `JSON.stringify`'s usual `undefined` result.
*/
function canonicalJSON(value: unknown): string {
if (value === null) return "null";
if (typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) {
return `[${value.map((v) => canonicalJSON(v)).join(",")}]`;
}
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj).sort();
const parts: string[] = [];
for (const k of keys) {
parts.push(`${JSON.stringify(k)}:${canonicalJSON(obj[k])}`);
}
return `{${parts.join(",")}}`;
}
/**
* Hex-encode a `Uint8Array`. Inlined to keep this module dep-free
* beyond `@suaveplan/crypto-curve`.
*/
function toHex(bytes: Uint8Array): string {
let out = "";
for (const b of bytes) {
out += b.toString(16).padStart(2, "0");
}
return out;
}
/**
* Decode a hex string into a `Uint8Array`. Throws on non-hex / odd
* length so callers can catch malformed inputs.
*/
function fromHex(hex: string): Uint8Array {
if (hex.length % 2 !== 0) {
throw new Error("hex string has odd length");
}
const out = new Uint8Array(hex.length / 2);
for (let i = 0; i < out.length; i++) {
const byte = Number.parseInt(hex.substr(i * 2, 2), 16);
if (Number.isNaN(byte)) {
throw new Error("hex string contains non-hex characters");
}
out[i] = byte;
}
return out;
}
/**
* Sign a `GunDBPeerAnnounceBody` and return a complete
* `GunDBPeerAnnounce` with `signaturePublicKey` and `signature`
* fields populated.
*/
export function signAnnounce(
privateKey: Uint8Array,
body: GunDBPeerAnnounceBody
): GunDBPeerAnnounce {
if (privateKey.length !== 32) {
throw new Error("ed25519 private key must be 32 bytes");
}
const canonical = canonicalSerializeAnnounceBody(body);
const publicKey = ed25519.getPublicKey(privateKey);
const signature = ed25519.sign(canonical, privateKey);
return {
kind: "peer-announce",
peerId: body.peerId,
addresses: body.addresses,
metadata: body.metadata,
capabilities: body.capabilities,
lastSeen: body.lastSeen,
signaturePublicKey: toHex(publicKey),
signature: toHex(signature),
};
}
/**
* Verify the Ed25519 signature on a `GunDBPeerAnnounce`. Returns
* `true` if and only if (a) `signaturePublicKey` decodes to a
* 32-byte key, (b) `signature` decodes to a 64-byte signature, and
* (c) `ed25519.verify` accepts the pair over the canonical body.
*
* Returns `false` for every other failure mode — malformed hex,
* wrong lengths, verifier rejection. The orchestrator distinguishes
* "no signature fields" (which is `UNSIGNED_ANNOUNCE`) from "verify
* returned false" (which is `BAD_SIGNATURE`) at the call site;
* this function only reports the cryptographic verdict.
*/
export function verifyAnnounceSignature(announce: GunDBPeerAnnounce): boolean {
let pubBytes: Uint8Array;
let sigBytes: Uint8Array;
try {
pubBytes = fromHex(announce.signaturePublicKey);
sigBytes = fromHex(announce.signature);
} catch {
return false;
}
if (pubBytes.length !== 32 || sigBytes.length !== 64) {
return false;
}
const body: GunDBPeerAnnounceBody = {
kind: announce.kind,
peerId: announce.peerId,
addresses: announce.addresses,
metadata: announce.metadata,
capabilities: announce.capabilities,
lastSeen: announce.lastSeen,
};
const canonical = canonicalSerializeAnnounceBody(body);
return ed25519.verify(sigBytes, canonical, pubBytes);
}
/**
* Convenience: hex-encode a 32-byte Ed25519 public key derived from
* a 32-byte secret. Useful for callers that want to compute their
* own `peerId` from `signingKey` before constructing the strategy.
*/
export function publicKeyHex(privateKey: Uint8Array): string {
if (privateKey.length !== 32) {
throw new Error("ed25519 private key must be 32 bytes");
}
return toHex(ed25519.getPublicKey(privateKey));
}
/** Re-exported low-level codecs for tests + advanced callers. */
export const _hex = { toHex, fromHex };

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

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