first commit
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,86 @@
|
||||
# connections
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Per-peer connection registry. The `ConnectionRegistry` is the single
|
||||
source of truth for "which peers does the transport believe are
|
||||
currently attached"; it owns the duplex socket reference, the dispose
|
||||
callback that unwires every `socket.on(...)` listener, and the OTel
|
||||
delta on `hyperswarm.active_peers`.
|
||||
|
||||
The companion `writeFrame` helper resolves the callback-style
|
||||
`socket.write` into a Promise so the transport's `send()` method stays
|
||||
async without dropping bytes or losing errors.
|
||||
|
||||
## Features
|
||||
|
||||
- Single-entry-per-peer invariant (duplicate `add` ⇒ `PEER_ALREADY_CONNECTED`).
|
||||
- O(1) `has`/`get`/`remove`.
|
||||
- Dispose-on-remove and dispose-on-clear semantics — listeners can't leak.
|
||||
- `adjustActivePeers` (gauge) wired to `add`/`remove`/`clear`.
|
||||
- `writeFrame` returns Promise; rejects with `STREAM_FAILED` carrying
|
||||
the underlying socket error as `cause`; increments
|
||||
`hyperswarm.bytes_total{direction=out}` on success.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
ConnectionRegistry,
|
||||
writeFrame,
|
||||
} from "@suaveplan/network-transport-hyperswarm";
|
||||
|
||||
const registry = new ConnectionRegistry();
|
||||
registry.add({ peerId, socket, dispose: () => socket.destroy() });
|
||||
await writeFrame(socket, frame);
|
||||
registry.remove(peerId);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
const registry = new ConnectionRegistry();
|
||||
|
||||
swarm.on("connection", (socket, peerInfo) => {
|
||||
const peerId = peerKeyHex(peerInfo.publicKey);
|
||||
const dataHandler = (chunk: Uint8Array) => {
|
||||
for (const env of decoder.push(chunk)) handleEnvelope(peerId, env);
|
||||
};
|
||||
const closeHandler = () => registry.remove(peerId);
|
||||
const errHandler = () => registry.remove(peerId);
|
||||
socket.on("data", dataHandler);
|
||||
socket.on("close", closeHandler);
|
||||
socket.on("error", errHandler);
|
||||
registry.add({
|
||||
peerId,
|
||||
socket,
|
||||
dispose: () => {
|
||||
socket.destroy();
|
||||
},
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `class ConnectionRegistry`
|
||||
- `add(connection: PeerConnection)` — throws `PEER_ALREADY_CONNECTED`
|
||||
on duplicate `peerId`.
|
||||
- `has(peerId)`, `get(peerId)`, `remove(peerId)` — standard map shape.
|
||||
- `peers() => readonly PeerId[]` — snapshot.
|
||||
- `clear()` — runs every dispose callback; bulk-decrements gauge.
|
||||
- `writeFrame(socket, frame) => Promise<void>` — Promise-wrapped write
|
||||
with `STREAM_FAILED` on every error path.
|
||||
- `interface PeerConnection { peerId; socket; dispose }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`writeFrame` checks `socket.destroyed` _before_ invoking `write` to
|
||||
avoid the Node-net `ERR_STREAM_DESTROYED` race where a socket is closed
|
||||
between the registry lookup and the write call. The OTel byte-counter
|
||||
increments on the success branch only — a write that fails has not
|
||||
travelled the wire so it must not be counted, otherwise dashboards
|
||||
double-count failed payloads.
|
||||
@@ -0,0 +1,70 @@
|
||||
# discovery
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Thin orchestration wrapper around Hyperswarm's `join(topic)` and the
|
||||
returned discovery handle. The module's job is to (a) translate every
|
||||
DHT-side exception into a structured `DHT_LOOKUP_FAILED` carrying the
|
||||
original cause, (b) ensure a failed `flushed()` does not leak a
|
||||
half-joined discovery handle, and (c) emit a `hyperswarm.dht_lookup_ms`
|
||||
histogram sample on every code path — success or failure — so dashboards
|
||||
report end-to-end latency from `join` request to confirmed DHT presence.
|
||||
|
||||
## Features
|
||||
|
||||
- AbortSignal-aware entry: a pre-aborted signal short-circuits before
|
||||
any side-effect on the swarm.
|
||||
- `flushed: false` opt-out for tests that don't want to wait on the DHT.
|
||||
- `recordDhtLookup` is called on the success path, on `join`-threw, and
|
||||
on `flushed`-threw — no path skips the measurement.
|
||||
- `leaveTopic` mirrors the structured-error convention for symmetry.
|
||||
- Injectable clock via `opts.now` keeps histogram tests deterministic.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { joinTopic } from "@suaveplan/network-transport-hyperswarm";
|
||||
|
||||
const discovery = await joinTopic(swarm, topic);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
const ctrl = new AbortController();
|
||||
setTimeout(() => ctrl.abort(), 2_000);
|
||||
|
||||
const discovery = await joinTopic(swarm, topic, {
|
||||
server: false,
|
||||
client: true,
|
||||
flushed: true,
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
|
||||
try {
|
||||
await discovery.refresh({ client: true });
|
||||
} finally {
|
||||
await leaveTopic(discovery);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `joinTopic(swarm, topic, opts?) => Promise<HyperswarmDiscoveryLike>`
|
||||
- `opts.server` / `opts.client` — Hyperswarm role flags (default true).
|
||||
- `opts.flushed` — wait for the DHT-flush handshake (default true).
|
||||
- `opts.signal` — AbortSignal; pre-aborted ⇒ throws `DHT_LOOKUP_FAILED`.
|
||||
- `opts.now` — injectable clock for tests; defaults to `performance.now`.
|
||||
- `leaveTopic(discovery) => Promise<void>` — destroy the handle;
|
||||
failures are wrapped to `DHT_LOOKUP_FAILED`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The function deliberately does not retry on its own — the caller (the
|
||||
transport's `start` method) composes `joinTopic` with
|
||||
`@suaveplan/retry`'s exponential-backoff policy when desired. This
|
||||
keeps the discovery module ~30 lines and individually verifiable; the
|
||||
retry policy stays a separate concern with its own tests.
|
||||
@@ -0,0 +1,62 @@
|
||||
# errors
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Frozen registry of every error code raised at the `@suaveplan/network-transport-hyperswarm` boundary. Every public throw path on the package goes through `NetHyperswarmError`; downstream callers branch on `error.code` rather than parsing messages, which is what `@suaveplan/error` was designed to enable.
|
||||
|
||||
The code names are stable contracts. Once shipped to consumers, deletion or rename requires an spec change and a migration window — same rule as every other package in the project.
|
||||
|
||||
## Features
|
||||
|
||||
- 10 codes covering every public-surface failure class.
|
||||
- Uses `defineCodes("NET_HYPERSWARM", [...])` so the prefix is enforced.
|
||||
- `NetHyperswarmError extends SuaveplanError<NetHyperswarmErrorCode>` — full cause chaining, structured logging, OTel span-event support.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
NET_HYPERSWARM_CODES,
|
||||
NetHyperswarmError,
|
||||
} from "@suaveplan/network-transport-hyperswarm";
|
||||
|
||||
throw new NetHyperswarmError(
|
||||
NET_HYPERSWARM_CODES.INVALID_TOPIC,
|
||||
"hyperswarm.invalid-topic"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
try {
|
||||
await transport.connect(peerId, "");
|
||||
} catch (err) {
|
||||
if (err instanceof NetHyperswarmError) {
|
||||
switch (err.code) {
|
||||
case NET_HYPERSWARM_CODES.PEER_ALREADY_CONNECTED:
|
||||
return; // idempotent
|
||||
case NET_HYPERSWARM_CODES.DHT_LOOKUP_FAILED:
|
||||
// backoff + retry
|
||||
break;
|
||||
default:
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `NET_HYPERSWARM_CODES` — frozen object literal: `DHT_LOOKUP_FAILED`, `PEER_REJECTED`, `STREAM_FAILED`, `INVALID_TOPIC`, `INVALID_OPTIONS`, `DISPOSED`, `NOT_RUNNING`, `PEER_ALREADY_CONNECTED`, `PEER_UNKNOWN`, `TRANSPORT_CLOSED`.
|
||||
- `NetHyperswarmErrorCode` — union of the values of the above.
|
||||
- `NetHyperswarmError` — subclass of `SuaveplanError`. Constructor signature: `(code, messageKey, options?)` where `messageKey` is an i18n key and `options` may carry `cause`, `context`, etc.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Every external dependency of this package can throw — the DHT, the duplex stream, the libsodium-based Noise transport — and every one of those raw errors gets wrapped at our boundary into a `NetHyperswarmError` with a `cause` set. Callers see one type, not five. The 10 codes were chosen to mirror the parity-checklist row in the OpenSpec proposal verbatim plus the four lifecycle codes that every other transport in the family raises (`DISPOSED`, `NOT_RUNNING`, `PEER_ALREADY_CONNECTED`, `PEER_UNKNOWN`).
|
||||
@@ -0,0 +1,93 @@
|
||||
# messaging
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Pure-function wire-format codec for Hyperswarm peer streams. Hyperswarm
|
||||
delivers a Noise-encrypted duplex socket — we layer a self-delimiting
|
||||
length-prefix framing on top so a single TCP segment can carry one or
|
||||
many envelopes and a single envelope can span many TCP segments.
|
||||
|
||||
The encoding goes through `@suaveplan/serialize`'s MessagePack binary
|
||||
codec (`encodeBinary`/`decodeBinary`) so we round-trip every type the
|
||||
network bus needs (`Date`, `Map`, `Set`, `Uint8Array`, BigInt) without a
|
||||
JSON-tagged-string detour.
|
||||
|
||||
## Features
|
||||
|
||||
- `[length:4 BE][msgpack-payload:length]` frame layout.
|
||||
- Stateful `FrameDecoder` that handles partial reads safely.
|
||||
- Maximum-frame-size guard maps oversize payloads to `STREAM_FAILED` so
|
||||
a remote peer cannot pin our memory by sending a 4 GiB length-prefix.
|
||||
- Topic preparation: 32-byte buffer pass-through, SHA-256 hash by
|
||||
default, HMAC-SHA256 when `topicSecret` is set.
|
||||
- `peerKeyHex` helper renders a noise public key as the canonical
|
||||
`PeerId` string this package emits on `onPeerEvent`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
encodeFrame,
|
||||
FrameDecoder,
|
||||
prepareTopic,
|
||||
} from "@suaveplan/network-transport-hyperswarm";
|
||||
|
||||
const topic = prepareTopic("my-room");
|
||||
const frame = encodeFrame({ id: "x", type: "ping", payload: {} } as never);
|
||||
const decoder = new FrameDecoder();
|
||||
const envelopes = decoder.push(frame); // [{ id: 'x', type: 'ping', payload: {} }]
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
const decoder = new FrameDecoder(64 * 1024); // 64 KiB cap
|
||||
|
||||
socket.on("data", (chunk) => {
|
||||
try {
|
||||
for (const envelope of decoder.push(chunk)) {
|
||||
bus.publish(envelope);
|
||||
}
|
||||
} catch (err) {
|
||||
// STREAM_FAILED — frame too large or malformed; close socket
|
||||
socket.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("close", () => decoder.reset());
|
||||
|
||||
const hashedTopic = prepareTopic("my-room", "shared-secret-for-hmac");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `prepareTopic(topic, secret?) => Uint8Array` — accepts a 32-byte
|
||||
`Uint8Array` (pass-through) or a non-empty string. Empty strings and
|
||||
wrong-length buffers raise `INVALID_TOPIC`.
|
||||
- `encodeFrame(envelope) => Uint8Array` — encodes an envelope into a
|
||||
length-prefixed frame ready to write to the socket.
|
||||
- `FrameDecoder` — stateful: hold partial reads, emit complete envelopes
|
||||
in order. Constructor accepts an optional `maxFrameSize` (defaults to
|
||||
`DEFAULT_MAX_FRAME_SIZE` = 1 MiB).
|
||||
- `push(chunk) => MessageEnvelope[]` — append bytes, return ready
|
||||
envelopes. Throws `STREAM_FAILED` for oversize or malformed frames.
|
||||
- `reset()` — drop any partial frame; call on socket close.
|
||||
- `bufferedBytes` — partial-frame count, useful for backpressure.
|
||||
- `peerKeyHex(publicKey) => string` — lowercase hex render of a noise
|
||||
public key.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The maximum-frame-size guard is the most important safety property in
|
||||
this module — without it, a malicious peer could send a 4-byte length
|
||||
prefix of `0xffffffff` and force us to allocate 4 GiB. We check the
|
||||
declared length _before_ waiting for the payload, so the slab is never
|
||||
sized off untrusted bytes.
|
||||
|
||||
Topic hashing uses Node's built-in `crypto` rather than reaching into
|
||||
`@suaveplan/crypto-hash` because this package is server-tier only and
|
||||
the Node primitive is constant-time and FIPS-compliant on managed
|
||||
runtimes that consumers may already audit separately.
|
||||
@@ -0,0 +1,67 @@
|
||||
# otel
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Centralised OpenTelemetry surface for `@suaveplan/network-transport-hyperswarm`. Every metric this package emits is created exactly once here and exposed as a focused, side-effect-free record function so the rest of the codebase never calls `meter.createCounter(...)` directly. This both eliminates duplicate-instrument noise and provides a single grep target when reviewers want to know what observability we ship.
|
||||
|
||||
## Features
|
||||
|
||||
- Counters: `hyperswarm.connections_opened_total{outcome}`, `hyperswarm.bytes_total{direction}`, `hyperswarm.peer_rejects_total{reason}`.
|
||||
- Histograms: `hyperswarm.dht_lookup_ms`, `hyperswarm.dial_ms`.
|
||||
- Gauge: `hyperswarm.active_peers` (UpDownCounter delta-form).
|
||||
- `withHyperswarmSpan(name, fn)` helper threading a tracer span around any async block.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
recordBytes,
|
||||
recordConnectionOutcome,
|
||||
withHyperswarmSpan,
|
||||
} from "@suaveplan/network-transport-hyperswarm/otel";
|
||||
|
||||
await withHyperswarmSpan("hyperswarm.connect", async () => {
|
||||
/* ... */
|
||||
recordConnectionOutcome("ok");
|
||||
recordBytes("out", 128);
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
adjustActivePeers,
|
||||
recordDhtLookup,
|
||||
recordDial,
|
||||
recordPeerReject,
|
||||
} from "@suaveplan/network-transport-hyperswarm/otel";
|
||||
|
||||
const start = performance.now();
|
||||
try {
|
||||
await swarm.join(topic).flushed();
|
||||
recordDhtLookup(performance.now() - start);
|
||||
} catch {
|
||||
recordPeerReject("dht-flush-failed");
|
||||
}
|
||||
|
||||
adjustActivePeers(+1); // on connection-open
|
||||
recordDial(performance.now() - dialStart);
|
||||
adjustActivePeers(-1); // on socket-close
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `withHyperswarmSpan(name, fn)` — wraps `fn` in a tracer span; `span.end()` runs in a `finally` block so exceptions still terminate the span.
|
||||
- `recordConnectionOutcome(outcome)` — increment connection counter; canonical outcomes: `"ok"`, `"error"`, `"rejected"`.
|
||||
- `recordBytes(direction, count)` — increment byte counter; negative or zero counts are dropped to keep the meter histograms clean.
|
||||
- `recordDhtLookup(ms)` / `recordDial(ms)` — clamp non-negative then record.
|
||||
- `adjustActivePeers(delta)` — ±1 typically.
|
||||
- `recordPeerReject(reason)` — categorical bucket: `"firewall"`, `"bad-signature"`, `"banned"`, etc.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The gauge is implemented as an `UpDownCounter` because we never need a query-time observable; the transport always knows the delta. This keeps the cost of incrementing/decrementing it O(1) on the hot connection path. The histograms record raw milliseconds, not seconds — matches what every other package emits so dashboards stay aligned.
|
||||
@@ -0,0 +1,91 @@
|
||||
# plugin
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`HyperswarmPlugin` is the canonical `NetworkPlugin` wrapper that lets a
|
||||
`NetworkNode` host the Hyperswarm transport without knowing anything
|
||||
Hyperswarm-specific. It exactly mirrors the
|
||||
`StandbyPlugin` / `WebRTCPlugin` / `WebSocketPlugin` shape so the
|
||||
`network-plugin-transport-manager` treats every transport identically.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict lifecycle (install → start → stop → dispose).
|
||||
- Owns a single `HyperswarmTransport`; the transport is the
|
||||
publicly-readable `plugin.transport` field for callers that need to
|
||||
drop down to the lower layer (e.g. dashboards reading the reputation
|
||||
store directly).
|
||||
- Idempotent `dispose()` — safe to call twice during shutdown plumbing.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { HyperswarmPlugin } from "@suaveplan/network-transport-hyperswarm";
|
||||
|
||||
const plugin = new HyperswarmPlugin({
|
||||
topic: "my-room",
|
||||
hyperswarmFactory: createRealHyperswarmFactory(),
|
||||
});
|
||||
const node = new NetworkNode({ id: "node-1" });
|
||||
plugin.install(node);
|
||||
await plugin.start();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
const node = new NetworkNode({ id: "node-1" });
|
||||
const plugin = new HyperswarmPlugin({
|
||||
topic: new Uint8Array(32),
|
||||
maxPeers: 32,
|
||||
hyperswarmFactory: createRealHyperswarmFactory({ keyPair }),
|
||||
reputation: { banThreshold: 25 },
|
||||
});
|
||||
|
||||
plugin.install(node);
|
||||
await plugin.start();
|
||||
|
||||
try {
|
||||
/* ... run application ... */
|
||||
} finally {
|
||||
await plugin.stop();
|
||||
plugin.dispose();
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new HyperswarmPlugin(config: HyperswarmConfig)` — constructs the
|
||||
inner transport (Zod validation happens there).
|
||||
- `name: "hyperswarm"` — stable plugin id.
|
||||
- `transport: HyperswarmTransport` — read-only access to the inner
|
||||
transport.
|
||||
- `install(node: NetworkNode)` — registers the transport on the node.
|
||||
- `start(): Promise<void>` — delegates to `transport.start`.
|
||||
- `stop(): Promise<void>` — closes the transport and unregisters.
|
||||
- `dispose(): void` — releases the node ref; idempotent.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The plugin holds the `NetworkNode` reference only between `install` and
|
||||
`dispose`; after `dispose` it is null so a forgotten reference does not
|
||||
keep the node alive in long-running processes. The plugin name is
|
||||
locked at `"hyperswarm"` so the `network-plugin-transport-manager`
|
||||
discovery key remains stable across consumers.
|
||||
|
||||
The lifecycle order matters: `install` must run before `start` because
|
||||
`start` triggers the swarm join and we want the transport already
|
||||
registered with the node by then; `stop` must run before `dispose`
|
||||
because `stop` calls `unregisterTransport` on the node reference, and
|
||||
`dispose` nulls that reference. Re-ordering these is a contract
|
||||
violation and will surface as either a `DISPOSED` error from the
|
||||
underlying transport or a silently-leaked node reference.
|
||||
|
||||
Plugin construction does not start the swarm — it only constructs the
|
||||
inner `HyperswarmTransport`. This keeps the constructor side-effect-free
|
||||
and lets callers wire `install` and `start` at the right point in their
|
||||
own initialisation flow without having to coordinate around an
|
||||
already-running DHT join.
|
||||
@@ -0,0 +1,75 @@
|
||||
# reputation
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Per-peer reputation bookkeeping with monotone scoring. Every message
|
||||
exchanged through the transport updates the remote's score; peers that
|
||||
drop below the configured `banThreshold` are reported as banned and the
|
||||
transport disconnects them. The scoring rules deliberately mirror the
|
||||
upstream `@suaveplan/network-hyperswarm` shape so operational dashboards
|
||||
continue to read the same numbers — only the implementation is
|
||||
rewritten with the modern DI conventions of this project.
|
||||
|
||||
## Features
|
||||
|
||||
- Default scoring: start at 50, +1 per success, -5 per failure, clamped
|
||||
to `[minScore, maxScore]` (defaults 0–100).
|
||||
- Configurable `banThreshold` (default 10); first failure that drops
|
||||
the score below the threshold latches `banned = true`.
|
||||
- Injectable clock for deterministic test fixtures.
|
||||
- Read-only snapshot API (`get`, `getScore`, `isBanned`, `peerKeys`)
|
||||
plus per-peer `clear` for unit-test teardown.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ReputationStore } from "@suaveplan/network-transport-hyperswarm";
|
||||
|
||||
const store = new ReputationStore();
|
||||
store.record("peer-a", true); // 50 -> 51
|
||||
store.record("peer-a", false); // 51 -> 46
|
||||
store.isBanned("peer-a"); // false
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
const store = new ReputationStore(
|
||||
{ maxScore: 100, minScore: 0, banThreshold: 25 },
|
||||
() => fakeNow.value
|
||||
);
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
fakeNow.value += 1_000;
|
||||
store.record("peer-b", false); // five failures take score from 50 to 25, sixth latches banned
|
||||
}
|
||||
|
||||
const rep = store.get("peer-b");
|
||||
// rep.banned === true; rep.score === 20; rep.failures === 6
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new ReputationStore(opts?, clock?)` — `opts` may set `maxScore`,
|
||||
`minScore`, `banThreshold`; `clock` defaults to `Date.now`.
|
||||
- `record(peerKey, success) => PeerReputation` — update and return the
|
||||
post-update entry.
|
||||
- `getScore(peerKey) => number` — returns the neutral start if unseen.
|
||||
- `isBanned(peerKey) => boolean`.
|
||||
- `get(peerKey) => PeerReputation | undefined` — defensive shallow
|
||||
clone; mutating the result does not affect the store.
|
||||
- `clear(peerKey) => void` — drop the entry.
|
||||
- `peerKeys() => readonly string[]` — snapshot of tracked peers.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `banned` flag is sticky — once flipped, only an explicit `clear()`
|
||||
brings the peer back. This matches the upstream behaviour and is the
|
||||
property the `@suaveplan/feature-flags` Composite-strategy hook
|
||||
exercises in its monotonicity property test. The injectable clock is
|
||||
the same `() => number` shape used elsewhere in the project
|
||||
(`@suaveplan/timing`, `@suaveplan/idempotency`) so test setup is
|
||||
boilerplate-free.
|
||||
@@ -0,0 +1,77 @@
|
||||
# security
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Inbound-peer validation gate for Hyperswarm. The upstream package's
|
||||
DHT-security manager merely logged warnings on malformed peer announces
|
||||
— this rewrite tightens the contract: every inbound peer goes through a
|
||||
Zod boundary, every rejection emits a structured `PEER_REJECTED` error,
|
||||
and every denial reason increments the
|
||||
`hyperswarm.peer_rejects_total{reason}` counter so dashboards can
|
||||
distinguish a malformed handshake from a firewall hit from a banned
|
||||
peer.
|
||||
|
||||
## Features
|
||||
|
||||
- Zod-validated `HyperswarmPeerInfo` (publicKey must be `Uint8Array`).
|
||||
- Optional 32-byte exact-length check (on by default).
|
||||
- Optional caller-supplied `firewall(publicKey)` predicate.
|
||||
- Categorical reject reasons: `malformed`, `bad-key-length`, `firewall`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { validatePeer } from "@suaveplan/network-transport-hyperswarm";
|
||||
|
||||
swarm.on("connection", (socket, peerInfo) => {
|
||||
try {
|
||||
validatePeer(peerInfo);
|
||||
} catch {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
/* wire up reader/writer */
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
const denylist = new Set<string>(/* hex public keys */);
|
||||
|
||||
swarm.on("connection", (socket, peerInfo) => {
|
||||
try {
|
||||
validatePeer(peerInfo, {
|
||||
firewall: (pk) => !denylist.has(Buffer.from(pk).toString("hex")),
|
||||
requireExactKeyLength: true,
|
||||
});
|
||||
} catch (err) {
|
||||
// Structured NetHyperswarmError with code PEER_REJECTED;
|
||||
// counter already incremented, just destroy the socket.
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `validatePeer(peerInfo, opts?) => void` — throws
|
||||
`NetHyperswarmError(PEER_REJECTED)` on any gate failure.
|
||||
- `opts.firewall?: (publicKey) => boolean` — predicate; `false` ⇒ reject.
|
||||
- `opts.requireExactKeyLength?: boolean` — default `true`; rejects
|
||||
keys whose `byteLength !== 32`.
|
||||
- `DhtSecurityOptions` — the options shape above.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The boundary deliberately uses `Uint8Array` rather than `Buffer` —
|
||||
`Buffer` is a Node-only API, and even though this is a server-tier
|
||||
package we keep the type surface portable so the same fixture can be
|
||||
shared with a future browser-tier verifier (e.g. for relay-mode
|
||||
audits). The categorical counter labels are fixed; adding a new bucket
|
||||
requires editing the registry in `otel.ts` so reviewers can't
|
||||
accidentally inflate label cardinality.
|
||||
@@ -0,0 +1,119 @@
|
||||
# transport
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The `HyperswarmTransport` is the load-bearing implementation in this
|
||||
package. It composes a real or fake Hyperswarm instance via the
|
||||
injected `HyperswarmFactory`, joins a topic on the DHT, wires every
|
||||
inbound `connection` event into a `ConnectionRegistry`, validates every
|
||||
remote peer through the security gate, and exposes the canonical
|
||||
`Transport` shape from `@suaveplan/network`.
|
||||
|
||||
Where the upstream package extended a `BaseTransport` class and
|
||||
broadcast plain `Message` payloads to all peers, this rewrite implements
|
||||
the modern `Transport` interface verbatim: `connect(peerId, address)`,
|
||||
`disconnect(peerId)`, `send(peerId, envelope)`, `onMessage`,
|
||||
`onPeerEvent`, `getConnectedPeers`, and `close`. The transport is
|
||||
single-topic and single-swarm by design — composing multiple topics is a
|
||||
caller concern.
|
||||
|
||||
## Features
|
||||
|
||||
- Zod boundary on the constructor; rejects with `INVALID_OPTIONS`.
|
||||
- Injectable factory pattern (no `vi.mock`); tests run against
|
||||
`FakeHyperswarm`.
|
||||
- AbortSignal threaded through `start()` and `joinTopic`.
|
||||
- Per-peer `FrameDecoder` so partial reads are tracked independently.
|
||||
- Reputation hook: every `send()` records success/failure; banned peers
|
||||
are force-disconnected on the next failure.
|
||||
- Inbound duplicate handling: a second `connection` event for the same
|
||||
peer closes the older socket cleanly before installing the new one.
|
||||
- Idempotent `close()`; `start()` after `close()` raises `DISPOSED`.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
HyperswarmTransport,
|
||||
type HyperswarmFactory,
|
||||
} from "@suaveplan/network-transport-hyperswarm";
|
||||
|
||||
const factory: HyperswarmFactory = createRealHyperswarmFactory(/* ... */);
|
||||
const transport = new HyperswarmTransport({
|
||||
topic: "my-room",
|
||||
hyperswarmFactory: factory,
|
||||
});
|
||||
await transport.start();
|
||||
transport.onMessage((peerId, envelope) =>
|
||||
console.log(peerId, envelope.type)
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
const transport = new HyperswarmTransport({
|
||||
topic: new Uint8Array(32),
|
||||
topicSecret: "shared-room-key",
|
||||
maxPeers: 64,
|
||||
server: true,
|
||||
client: true,
|
||||
socketTimeoutMs: 15_000,
|
||||
maxFrameSize: 256 * 1024,
|
||||
hyperswarmFactory: createRealHyperswarmFactory({ keyPair }),
|
||||
firewall: (pk) => !banlist.has(Buffer.from(pk).toString("hex")),
|
||||
reputation: { banThreshold: 25, maxScore: 100, minScore: 0 },
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
await transport.start(controller.signal);
|
||||
|
||||
const unsubMsg = transport.onMessage(handleEnvelope);
|
||||
const unsubPeer = transport.onPeerEvent((peerId, event) =>
|
||||
console.log(`peer ${peerId} ${event}`)
|
||||
);
|
||||
|
||||
await transport.send(peerId, envelope);
|
||||
await transport.disconnect(peerId);
|
||||
|
||||
await transport.close();
|
||||
unsubMsg();
|
||||
unsubPeer();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `new HyperswarmTransport(config: HyperswarmConfig)` — see `types.md`.
|
||||
- `start(signal?) => Promise<void>` — initialise the swarm, join topic;
|
||||
idempotent.
|
||||
- `connect(peerId, address) => Promise<void>` — Hyperswarm is a
|
||||
discovery transport so `connect` does not dial; it succeeds only if
|
||||
the peer is already attached via the DHT. Otherwise raises
|
||||
`PEER_UNKNOWN`.
|
||||
- `disconnect(peerId)` / `send(peerId, envelope)` / `onMessage(handler)`
|
||||
/ `onPeerEvent(handler)` / `getConnectedPeers()` / `close()` — match
|
||||
the `@suaveplan/network` contract.
|
||||
- `topic` getter — the 32-byte topic buffer.
|
||||
- `reputation` getter — the `ReputationStore` for inspection.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `connect(peerId, address)` semantics are deliberately
|
||||
narrow-and-honest: Hyperswarm does not let you "dial" an arbitrary peer
|
||||
out of band — the DHT _is_ the dial layer, and once a peer is attached
|
||||
via `connection`, the only operation a caller can perform is `send`.
|
||||
The transport surface accepts the `connect` call so the
|
||||
`network-plugin-transport-manager` treats every transport uniformly,
|
||||
but the implementation refuses to fabricate a connection. This is the
|
||||
right contract because callers that already have the peer attached can
|
||||
call `connect` idempotently, and callers that don't get a clear
|
||||
`PEER_UNKNOWN` error rather than a silent stall.
|
||||
|
||||
The duplicate-inbound-connection branch is necessary because Hyperswarm
|
||||
sometimes opens a second connection during topology updates; closing
|
||||
the old socket first ensures the registry invariant
|
||||
(`single-entry-per-peer`) is preserved and that no zombie `data`
|
||||
listener stays wired to the obsolete socket.
|
||||
@@ -0,0 +1,83 @@
|
||||
# types
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Type-only contracts for `@suaveplan/network-transport-hyperswarm`. The
|
||||
package keeps every Hyperswarm-shaped type behind structural interfaces
|
||||
so the production code never reaches into `hyperswarm`'s internals and
|
||||
tests can swap in a deterministic in-memory swarm via the
|
||||
`HyperswarmFactory` injection point.
|
||||
|
||||
This module is the single source of truth for the public configuration
|
||||
shape (`HyperswarmConfig`), the dependency-injected factory signature
|
||||
(`HyperswarmFactory`), the structural socket/discovery/instance views
|
||||
the transport operates against (`HyperswarmSocketLike`,
|
||||
`HyperswarmDiscoveryLike`, `HyperswarmInstanceLike`), and the handler
|
||||
signatures used by `Transport.onMessage` / `Transport.onPeerEvent`.
|
||||
|
||||
## Features
|
||||
|
||||
- Structural typing — zero runtime coupling to the `hyperswarm` package.
|
||||
- Single `HyperswarmConfig` boundary; every other module accepts the
|
||||
pre-validated shape.
|
||||
- Injectable `HyperswarmFactory` for test seams.
|
||||
- Discriminated `WireFrame` envelope so the messaging layer has one
|
||||
canonical inbound shape.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { HyperswarmConfig } from "@suaveplan/network-transport-hyperswarm";
|
||||
|
||||
const config: HyperswarmConfig = {
|
||||
topic: "my-topic",
|
||||
server: true,
|
||||
client: true,
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
HyperswarmConfig,
|
||||
HyperswarmFactory,
|
||||
} from "@suaveplan/network-transport-hyperswarm";
|
||||
|
||||
const factory: HyperswarmFactory = () =>
|
||||
/* tests can wire a deterministic in-memory swarm here */ ({}) as never;
|
||||
|
||||
const advanced: HyperswarmConfig = {
|
||||
topic: new Uint8Array(32),
|
||||
maxPeers: 64,
|
||||
socketTimeoutMs: 15_000,
|
||||
maxFrameSize: 2 ** 20,
|
||||
hyperswarmFactory: factory,
|
||||
firewall: (pk) => pk[0] !== 0x00,
|
||||
reputation: { banThreshold: 10, maxScore: 100, minScore: 0 },
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `HyperswarmConfig` — see fields above; every field except `topic` is
|
||||
optional and has a documented default in `transport.ts`.
|
||||
- `HyperswarmFactory` — `(opts?) => HyperswarmInstanceLike`.
|
||||
- `HyperswarmInstanceLike` / `HyperswarmDiscoveryLike` / `HyperswarmSocketLike`
|
||||
/ `HyperswarmPeerInfo` — structural views; only the listed members are
|
||||
consumed.
|
||||
- `MessageHandler` / `PeerEventHandler` — match `Transport` from
|
||||
`@suaveplan/network`.
|
||||
- `WireFrame` — shape returned by `decodeFrame`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The factory injection deliberately follows the same pattern as
|
||||
`@suaveplan/network-transport-standby` so consumers and reviewers
|
||||
recognise the contract. Keeping the structural views narrow makes the
|
||||
fake swarm trivial to author (it only has to implement what we use, not
|
||||
what `hyperswarm` exposes) while still letting the real package satisfy
|
||||
the same interface at runtime.
|
||||
Reference in New Issue
Block a user