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

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,
},
});