first commit

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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