Initial Commit
This commit is contained in:
123
packages/networking/network/network-bootstrap/README.md
Normal file
123
packages/networking/network/network-bootstrap/README.md
Normal file
File diff suppressed because one or more lines are too long
73
packages/networking/network/network-bootstrap/package.json
Normal file
73
packages/networking/network/network-bootstrap/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { DiscoveryChain } from "./discovery-chain.js";
|
||||
export type {
|
||||
BootstrapEventMap,
|
||||
BootstrapRetryConfig,
|
||||
DiscoveryChainOptions,
|
||||
DiscoveryStrategy,
|
||||
PeerInfo,
|
||||
} from "./discovery-chain.types.js";
|
||||
@@ -0,0 +1,6 @@
|
||||
export { PeerCache } from "./peer-cache.js";
|
||||
export type {
|
||||
PeerCacheConfig,
|
||||
PeerCacheStorageLike,
|
||||
PeerInfo,
|
||||
} from "./peer-cache.types.js";
|
||||
@@ -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.
|
||||
@@ -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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type {
|
||||
PeerCacheConfig,
|
||||
PeerCacheStorageLike,
|
||||
PeerInfo,
|
||||
} from "../../types/types.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
48
packages/networking/network/network-bootstrap/src/index.ts
Normal file
48
packages/networking/network/network-bootstrap/src/index.ts
Normal 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";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type {
|
||||
BootstrapMiddlewareConfig,
|
||||
MessageMiddleware,
|
||||
MiddlewareContext,
|
||||
MiddlewareMessage,
|
||||
} from "../../types/types.js";
|
||||
@@ -0,0 +1,7 @@
|
||||
export { createBootstrapMiddleware } from "./bootstrap-middleware.js";
|
||||
export type {
|
||||
BootstrapMiddlewareConfig,
|
||||
MessageMiddleware,
|
||||
MiddlewareContext,
|
||||
MiddlewareMessage,
|
||||
} from "./bootstrap-middleware.types.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type {
|
||||
BootstrapEventMap,
|
||||
BootstrapPluginConfig,
|
||||
DiscoveryStrategy,
|
||||
PeerInfo,
|
||||
} from "../../types/types.js";
|
||||
@@ -0,0 +1,7 @@
|
||||
export { BootstrapPlugin } from "./bootstrap-plugin.js";
|
||||
export type {
|
||||
BootstrapEventMap,
|
||||
BootstrapPluginConfig,
|
||||
DiscoveryStrategy,
|
||||
PeerInfo,
|
||||
} from "./bootstrap-plugin.types.js";
|
||||
119
packages/networking/network/network-bootstrap/src/types/types.md
Normal file
119
packages/networking/network/network-bootstrap/src/types/types.md
Normal 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).
|
||||
@@ -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();
|
||||
173
packages/networking/network/network-bootstrap/src/types/types.ts
Normal file
173
packages/networking/network/network-bootstrap/src/types/types.ts
Normal 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;
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"references": [{ "path": "./tsconfig.lib.json" }]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
41
packages/networking/network/network-bootstrap/vite.config.ts
Normal file
41
packages/networking/network/network-bootstrap/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user