@serfab/cadre-core 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/cadre-node.d.ts +153 -104
  2. package/dist/cadre-node.js +227 -199
  3. package/dist/cadre-node.js.map +1 -1
  4. package/dist/index.d.ts +2 -4
  5. package/dist/index.js +10 -11
  6. package/dist/index.js.map +1 -1
  7. package/dist/node-local-snapshot.d.ts +6 -4
  8. package/dist/node-local-snapshot.js +7 -3
  9. package/dist/node-local-snapshot.js.map +1 -1
  10. package/dist/peer-join-backfill.d.ts +0 -9
  11. package/dist/peer-join-backfill.js +2 -3
  12. package/dist/peer-join-backfill.js.map +1 -1
  13. package/dist/strand-formation-protocol.d.ts +0 -11
  14. package/dist/strand-formation-protocol.js +3 -4
  15. package/dist/strand-formation-protocol.js.map +1 -1
  16. package/dist/strand-fret-addrs.d.ts +54 -0
  17. package/dist/strand-fret-addrs.js +108 -0
  18. package/dist/strand-fret-addrs.js.map +1 -0
  19. package/dist/strand-instance-manager.d.ts +9 -38
  20. package/dist/strand-instance-manager.js +13 -73
  21. package/dist/strand-instance-manager.js.map +1 -1
  22. package/dist/strand-network-state-file.d.ts +12 -0
  23. package/dist/strand-network-state-file.js +38 -0
  24. package/dist/strand-network-state-file.js.map +1 -0
  25. package/dist/strand-network-state.d.ts +111 -0
  26. package/dist/strand-network-state.js +191 -0
  27. package/dist/strand-network-state.js.map +1 -0
  28. package/dist/types.d.ts +20 -23
  29. package/package.json +11 -10
  30. package/src/cadre-node.ts +238 -205
  31. package/src/index.ts +15 -55
  32. package/src/node-local-snapshot.ts +12 -7
  33. package/src/peer-join-backfill.ts +2 -3
  34. package/src/strand-formation-protocol.ts +3 -4
  35. package/src/strand-fret-addrs.ts +145 -0
  36. package/src/strand-instance-manager.ts +20 -96
  37. package/src/strand-network-state-file.ts +40 -0
  38. package/src/strand-network-state.ts +243 -0
  39. package/src/types.ts +20 -23
  40. package/dist/strand-peer-book-file.d.ts +0 -12
  41. package/dist/strand-peer-book-file.js +0 -37
  42. package/dist/strand-peer-book-file.js.map +0 -1
  43. package/dist/strand-peer-book-protocol.d.ts +0 -168
  44. package/dist/strand-peer-book-protocol.js +0 -356
  45. package/dist/strand-peer-book-protocol.js.map +0 -1
  46. package/dist/strand-peer-book-swap.d.ts +0 -104
  47. package/dist/strand-peer-book-swap.js +0 -284
  48. package/dist/strand-peer-book-swap.js.map +0 -1
  49. package/dist/strand-peer-book.d.ts +0 -159
  50. package/dist/strand-peer-book.js +0 -361
  51. package/dist/strand-peer-book.js.map +0 -1
  52. package/dist/strand-peer-observer.d.ts +0 -88
  53. package/dist/strand-peer-observer.js +0 -180
  54. package/dist/strand-peer-observer.js.map +0 -1
  55. package/src/strand-peer-book-file.ts +0 -39
  56. package/src/strand-peer-book-protocol.ts +0 -466
  57. package/src/strand-peer-book-swap.ts +0 -338
  58. package/src/strand-peer-book.ts +0 -475
  59. package/src/strand-peer-observer.ts +0 -235
@@ -1,39 +0,0 @@
1
- /**
2
- * Node-only file-backed {@link StrandPeerBookStore}: one JSON file per party under
3
- * a configured directory (the node's state directory — `cadre-cli` passes
4
- * `ResolvedConfig.nodeStateDir`), holding the node-local, non-replicated strand
5
- * peer book (the strand peers this node has met, with their last-known addresses).
6
- *
7
- * Nothing but the file itself lives here: the envelope, load policy, per-entry
8
- * validation, the merge rule and the serialised snapshot writes are cross-platform
9
- * in `PersistentStrandPeerBookStore` / `node-local-snapshot.ts`, and the file is a
10
- * `FileDurableSlot`. This module exists only so the `node:fs` edge stays out of the
11
- * package's cross-platform default entry (`./index.js`) — import it from the
12
- * dedicated subpath instead:
13
- *
14
- * ```ts
15
- * import { FileStrandPeerBookStore } from '@serfab/cadre-core/strand-peer-book-file';
16
- * ```
17
- *
18
- * Same isolation pattern as `bootstrap-peer-store-file.ts` / `key-store-file.ts`.
19
- */
20
- import { FileDurableSlot } from './file-durable-slot.js';
21
- import { PersistentStrandPeerBookStore } from './strand-peer-book.js';
22
- import type { StrandPeerBookOptions, StrandPeerBookStore } from './strand-peer-book.js';
23
-
24
- /** Base name of the store file: `<dir>/strand-peers.<encoded partyId>.json`. */
25
- const SLOT_NAME = 'strand-peers';
26
-
27
- /**
28
- * File-backed {@link StrandPeerBookStore}. Open via {@link open}, which loads the
29
- * existing file and returns the cross-platform store over it; an absent, corrupt,
30
- * or wrong-party file is a cold start (empty book) and structurally junk or
31
- * aged-out entries are dropped, while a present-but-unreadable file throws. Those
32
- * rules and their reasoning live on `NodeLocalSnapshot.open`.
33
- */
34
- export const FileStrandPeerBookStore = {
35
- /** Load (or cold-start) the party's strand peer book from a file in `dir`. */
36
- async open(dir: string, partyId: string, options?: StrandPeerBookOptions): Promise<StrandPeerBookStore> {
37
- return PersistentStrandPeerBookStore.open(new FileDurableSlot(dir, SLOT_NAME, partyId), partyId, options);
38
- }
39
- };
@@ -1,466 +0,0 @@
1
- /**
2
- * The strand peer book SWAP protocol, `/sereus/strand-peers/1.0.0`, on the STRAND
3
- * libp2p node: when two strand peers connect, each sends the other the signed
4
- * entries of its strand peer book (`strand-peer-book.ts`) — its own current
5
- * addresses, self-signed, plus the freshest signed entry it holds for every other
6
- * peer of the strand — and merges what comes back. That closes the two gaps the
7
- * node-local book alone leaves: a peer whose relay reservation or address ROTATES is
8
- * known to the others by its old address only until they happen to reconnect, and in
9
- * a strand of three or more parties a late joiner holds an address only for the party
10
- * that invited it. After one swap it holds everyone's.
11
- *
12
- * Wire shape, modeled on `strand-addr-protocol.ts`: 4-byte big-endian length-prefixed
13
- * JSON frames over one stream, one request → one response, the shared
14
- * `control-stream.ts` primitives, a read timeout and a concurrency cap on the
15
- * receiver, and a {@link MAX_BOOK_FRAME_SIZE} cap on what either side buffers.
16
- * Request and response are the same {@link StrandPeerBookFrame}. Only SIGNED entries
17
- * ever travel: an entry this node observed or a formation carried is its own
18
- * observation, not the peer's statement, and is never forwarded.
19
- *
20
- * ── Whose signature ──
21
- *
22
- * Each entry is signed by the named peer's own STRAND TRANSPORT key
23
- * (`strand-transport-key.ts`, the key its strand libp2p node runs under), over the
24
- * canonical JSON of `{ v: 1, strandId, peerId, addrs, issuedAt }`. Verification needs
25
- * no key exchange: an Ed25519 libp2p peer id embeds its public key, so any receiver
26
- * checks that the entry for peer X was signed by X. NOT the strand member key, for
27
- * four reasons:
28
- *
29
- * 1. Open strands have no shared member key at all, and the book has to work there.
30
- * 2. A member-key signature proves only "some member said this", so any member could
31
- * forge another member's address and cost every peer a 16 s relayed dial per
32
- * launch; a self-signature is a claim only the named peer can make.
33
- * 3. On a closed strand the member key is shared by every machine of a party, so it
34
- * cannot identify a machine anyway.
35
- * 4. Membership is a separate question, judged at the connection by the revocation
36
- * gate today (`strand-revocation-enforcer.ts`) and by the allowlist later
37
- * (`feat-strand-member-allowlist-admission`); this protocol rides that gate
38
- * (`node.handle` → `authorizeInboundStream` on a closed strand).
39
- *
40
- * **The book therefore proves "this peer's own claim about where it is", never "this
41
- * peer is a member of the strand".** An address grants no authority (the dialed peer
42
- * authenticates by peer id at the handshake), so the worst a forged or stale entry
43
- * costs is a failed dial, and a self-signature is exactly what bounds who can impose
44
- * that cost on whom.
45
- *
46
- * ── Clocks ──
47
- *
48
- * `issuedAt` is the signer's clock. It is compared only between entries from the
49
- * SAME signer (the store's merge rule), so skew between machines is harmless — except
50
- * that a far-future stamp would pin an entry forever, hence the
51
- * {@link STRAND_PEER_ISSUED_AT_SKEW_MS} ceiling (reject and log). The store ages an
52
- * entry from `max(issuedAt, lastSeenAt)`, so a signer far in the past ages out fast,
53
- * which is the right failure for a machine with a broken clock.
54
- */
55
- import debug from 'debug';
56
- import type { Connection, Libp2p, PrivateKey } from '@libp2p/interface';
57
- import { peerIdFromString } from '@libp2p/peer-id';
58
- import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
59
- import { canonicalJson } from './canonical-json.js';
60
- import { type ControlStream, exchangeFrame, readStreamToEnd, withDeadline, writeFrame } from './control-stream.js';
61
- import { decodeLengthPrefixedFrame } from './seed-bootstrap.js';
62
- import { groupAddrsByPeerId } from './peer-addr-book.js';
63
- import { MAX_STRAND_ADDRS } from './strand-formation-protocol.js';
64
- import { MAX_STRAND_PEERS } from './strand-peer-book.js';
65
-
66
- const log = debug('sereus:cadre:strand-peer-book-protocol');
67
-
68
- /** Protocol id of the strand peer book swap, registered on every running strand node. */
69
- export const STRAND_PEER_BOOK_PROTOCOL = '/sereus/strand-peers/1.0.0';
70
-
71
- /** Version tag inside every signed payload, so a later payload shape cannot verify as this one. */
72
- export const SIGNED_STRAND_PEER_ENTRY_VERSION = 1;
73
-
74
- /**
75
- * How far ahead of the receiver's clock a signed entry's `issuedAt` may be. Skew
76
- * between honest machines is seconds; a stamp further ahead than this is a broken
77
- * clock or an attempt to pin an entry, and is rejected rather than held.
78
- */
79
- export const STRAND_PEER_ISSUED_AT_SKEW_MS = 5 * 60 * 1000;
80
-
81
- /**
82
- * Cap on one frame's bytes. A typical full frame — {@link MAX_STRAND_PEERS} entries of
83
- * {@link MAX_STRAND_ADDRS} addresses at roughly 150 bytes each, plus a peer id and
84
- * signature per entry — is about 40 KiB, but nothing bounds a multiaddr's length (a
85
- * long DNS name can take one past 300 bytes), so a sender trims its forwarded
86
- * entries to fit ({@link trimBookFrameToFit}) rather than trusting the arithmetic;
87
- * {@link assertBookFrameFits} is then the invariant check.
88
- */
89
- export const MAX_BOOK_FRAME_SIZE = 64 * 1024;
90
-
91
- /** Default cap on concurrent inbound swap streams a single strand node serves. */
92
- const DEFAULT_MAX_CONCURRENT = 100;
93
-
94
- /**
95
- * One peer's own signed statement of where it is: a `StrandPeerEntry` without the
96
- * receiver-local `lastSeenAt`. `addrs` may be empty — a peer that lost its relay
97
- * reservation truthfully says "not reachable now", and that displaces a stale
98
- * reachable list at every receiver.
99
- */
100
- export interface SignedStrandPeerEntry {
101
- /** The signer's strand transport peer id (Ed25519, so it embeds the verifying key). */
102
- peerId: string;
103
- /** Bound to `peerId`, signaling-first, at most `MAX_STRAND_ADDRS`. */
104
- addrs: string[];
105
- /** Signer's clock, ms. Greater wins between two statements by the same signer. */
106
- issuedAt: number;
107
- /** base64url Ed25519 signature over {@link signedStrandPeerEntryPayload}. */
108
- sig: string;
109
- }
110
-
111
- /** Request and response of the swap: the sender's signed book for one strand. */
112
- export interface StrandPeerBookFrame {
113
- /** Must name the strand whose network the frame arrived on; anything else is refused. */
114
- strandId: string;
115
- /** At most {@link MAX_STRAND_PEERS}. */
116
- entries: SignedStrandPeerEntry[];
117
- }
118
-
119
- /** The bytes a signed entry's signature covers. */
120
- export function signedStrandPeerEntryPayload(
121
- strandId: string,
122
- entry: Pick<SignedStrandPeerEntry, 'peerId' | 'addrs' | 'issuedAt'>
123
- ): Uint8Array {
124
- return new TextEncoder().encode(canonicalJson({
125
- v: SIGNED_STRAND_PEER_ENTRY_VERSION,
126
- strandId,
127
- peerId: entry.peerId,
128
- addrs: entry.addrs,
129
- issuedAt: entry.issuedAt
130
- }));
131
- }
132
-
133
- /**
134
- * Sign this node's own entry with its strand transport key. `addrs` must already be
135
- * bound to the key's peer id (`dialableAddrs` in `strand-peer-observer.ts` does that);
136
- * a receiver rejects an entry whose addresses do not all attribute to the signer.
137
- */
138
- export async function signStrandPeerEntry(
139
- privateKey: PrivateKey,
140
- strandId: string,
141
- addrs: string[],
142
- issuedAt: number
143
- ): Promise<SignedStrandPeerEntry> {
144
- if (privateKey.type !== 'Ed25519') {
145
- throw new Error(`signStrandPeerEntry requires an Ed25519 key, got ${privateKey.type}`);
146
- }
147
- const peerId = privateKey.publicKey.toString();
148
- const entry = { peerId, addrs: [...addrs], issuedAt };
149
- const sig = await privateKey.sign(signedStrandPeerEntryPayload(strandId, entry));
150
- return { ...entry, sig: uint8ArrayToString(sig, 'base64url') };
151
- }
152
-
153
- /** Why a received entry was dropped — for logs and for the verification test. */
154
- export type StrandPeerEntryVerdict =
155
- | { ok: true; entry: SignedStrandPeerEntry }
156
- | { ok: false; reason: string };
157
-
158
- /**
159
- * Verify one received entry: well-formed, an Ed25519 peer id, every address bound to
160
- * that peer id, `issuedAt` not further ahead of `now` than the skew ceiling, and a
161
- * valid self-signature. Never throws: junk from a peer is a verdict, not an error.
162
- */
163
- export async function verifySignedStrandPeerEntry(
164
- strandId: string,
165
- raw: unknown,
166
- now: number
167
- ): Promise<StrandPeerEntryVerdict> {
168
- const shaped = shapeEntry(raw);
169
- if (typeof shaped === 'string') {
170
- return { ok: false, reason: shaped };
171
- }
172
- let publicKey;
173
- try {
174
- const peerId = peerIdFromString(shaped.peerId);
175
- if (peerId.type !== 'Ed25519') {
176
- return { ok: false, reason: `peer id ${shaped.peerId} is ${peerId.type}, not Ed25519` };
177
- }
178
- publicKey = peerId.publicKey;
179
- } catch (error) {
180
- return { ok: false, reason: `peer id ${shaped.peerId} does not parse: ${(error as Error).message}` };
181
- }
182
- const attributed = groupAddrsByPeerId([...shaped.addrs]).get(shaped.peerId)?.length ?? 0;
183
- if (attributed !== shaped.addrs.length) {
184
- return { ok: false, reason: `${shaped.addrs.length - attributed} addr(s) do not attribute to ${shaped.peerId}` };
185
- }
186
- if (shaped.issuedAt > now + STRAND_PEER_ISSUED_AT_SKEW_MS) {
187
- return { ok: false, reason: `issuedAt ${shaped.issuedAt} is more than ${STRAND_PEER_ISSUED_AT_SKEW_MS} ms ahead of now` };
188
- }
189
- try {
190
- const sig = uint8ArrayFromString(shaped.sig, 'base64url');
191
- if (!(await publicKey.verify(signedStrandPeerEntryPayload(strandId, shaped), sig))) {
192
- return { ok: false, reason: `signature does not verify for ${shaped.peerId}` };
193
- }
194
- } catch (error) {
195
- return { ok: false, reason: `signature unusable for ${shaped.peerId}: ${(error as Error).message}` };
196
- }
197
- return { ok: true, entry: shaped };
198
- }
199
-
200
- /** Structural check of untrusted JSON: the fields, their types, the address cap. */
201
- function shapeEntry(raw: unknown): SignedStrandPeerEntry | string {
202
- if (typeof raw !== 'object' || raw === null) return 'entry is not an object';
203
- const { peerId, addrs, issuedAt, sig } = raw as Record<string, unknown>;
204
- if (typeof peerId !== 'string' || peerId.length === 0) return 'peerId is not a string';
205
- if (!Array.isArray(addrs) || addrs.some((addr) => typeof addr !== 'string' || addr.length === 0)) {
206
- return `addrs of ${peerId} is not a list of strings`;
207
- }
208
- if (addrs.length > MAX_STRAND_ADDRS) return `${peerId} lists ${addrs.length} addrs, over the cap of ${MAX_STRAND_ADDRS}`;
209
- if (!Number.isSafeInteger(issuedAt) || (issuedAt as number) < 0) return `issuedAt of ${peerId} is not a timestamp`;
210
- if (typeof sig !== 'string' || sig.length === 0) return `sig of ${peerId} is not a string`;
211
- // A duplicate address is not a protocol error, but the store de-duplicates and the
212
- // attribution count above compares lengths, so collapse it here.
213
- return { peerId, addrs: [...new Set(addrs as string[])], issuedAt: issuedAt as number, sig };
214
- }
215
-
216
- /**
217
- * Verify a whole received frame. A frame-level violation — the wrong strand, entries
218
- * not a list, more entries than {@link MAX_STRAND_PEERS} — throws, and the caller
219
- * refuses the frame. An entry that fails {@link verifySignedStrandPeerEntry} is
220
- * dropped INDIVIDUALLY with a debug line; its siblings are kept. An entry naming
221
- * `selfPeerId` is dropped too: this node's own statement is authoritative locally
222
- * and a remote copy of it is at best redundant. One entry per peer id (the first).
223
- */
224
- export async function verifyStrandPeerBookFrame(
225
- strandId: string,
226
- raw: unknown,
227
- selfPeerId: string,
228
- now: number
229
- ): Promise<SignedStrandPeerEntry[]> {
230
- if (typeof raw !== 'object' || raw === null) {
231
- throw new Error('strand peer book frame is not an object');
232
- }
233
- const { strandId: named, entries } = raw as Record<string, unknown>;
234
- if (named !== strandId) {
235
- throw new Error(`strand peer book frame names strand ${String(named)}, expected ${strandId}`);
236
- }
237
- if (!Array.isArray(entries)) {
238
- throw new Error('strand peer book frame entries is not a list');
239
- }
240
- if (entries.length > MAX_STRAND_PEERS) {
241
- throw new Error(`strand peer book frame carries ${entries.length} entries, over the cap of ${MAX_STRAND_PEERS}`);
242
- }
243
- const verified = new Map<string, SignedStrandPeerEntry>();
244
- for (const candidate of entries) {
245
- const verdict = await verifySignedStrandPeerEntry(strandId, candidate, now);
246
- if (!verdict.ok) {
247
- log('strand %s: dropping received entry: %s', strandId, verdict.reason);
248
- continue;
249
- }
250
- if (verdict.entry.peerId === selfPeerId || verified.has(verdict.entry.peerId)) {
251
- continue;
252
- }
253
- verified.set(verdict.entry.peerId, verdict.entry);
254
- }
255
- return [...verified.values()];
256
- }
257
-
258
- /** The encoded size of a frame, as {@link writeFrame} would send it (the 4-byte prefix aside). */
259
- function encodedFrameBytes(frame: StrandPeerBookFrame): number {
260
- return new TextEncoder().encode(JSON.stringify(frame)).length;
261
- }
262
-
263
- /**
264
- * The frame with its LAST entries dropped until it encodes within
265
- * {@link MAX_BOOK_FRAME_SIZE} and {@link MAX_STRAND_PEERS}. Callers list the entries
266
- * in the order they would rather keep them (the own entry first, then freshest
267
- * first), so what goes is the stalest forwarded statement, and a frame that fits is
268
- * returned as is. Dropping an entry is logged: a forwarded peer the recipient never
269
- * learns of is worth seeing in a trace.
270
- */
271
- export function trimBookFrameToFit(frame: StrandPeerBookFrame): StrandPeerBookFrame {
272
- let entries = frame.entries.slice(0, MAX_STRAND_PEERS);
273
- while (entries.length > 0 && encodedFrameBytes({ strandId: frame.strandId, entries }) > MAX_BOOK_FRAME_SIZE) {
274
- entries = entries.slice(0, -1);
275
- }
276
- if (entries.length === frame.entries.length) return frame;
277
- log('strand %s: frame trimmed from %d to %d entr(ies) to fit the caps', frame.strandId, frame.entries.length, entries.length);
278
- return { strandId: frame.strandId, entries };
279
- }
280
-
281
- /**
282
- * Refuse to send a frame the receiver would reject: over the entry cap, or over
283
- * {@link MAX_BOOK_FRAME_SIZE} once encoded. A caller that built its frame through
284
- * {@link trimBookFrameToFit} cannot trip this; it is the invariant check, not a
285
- * runtime condition to handle.
286
- */
287
- export function assertBookFrameFits(frame: StrandPeerBookFrame): void {
288
- if (frame.entries.length > MAX_STRAND_PEERS) {
289
- throw new Error(`strand peer book frame carries ${frame.entries.length} entries, over the cap of ${MAX_STRAND_PEERS}`);
290
- }
291
- const bytes = encodedFrameBytes(frame);
292
- if (bytes > MAX_BOOK_FRAME_SIZE) {
293
- throw new Error(`strand peer book frame is ${bytes} bytes, over the cap of ${MAX_BOOK_FRAME_SIZE}`);
294
- }
295
- }
296
-
297
- /** Read a stream to EOF and decode its single frame, bounded by `timeoutMs` and the size cap. */
298
- async function readBookFrame(stream: ControlStream, timeoutMs: number): Promise<unknown> {
299
- const data = await readStreamToEnd(stream, { maxBytes: MAX_BOOK_FRAME_SIZE, timeoutMs, label: 'Strand peer book' });
300
- return JSON.parse(new TextDecoder().decode(decodeLengthPrefixedFrame(data, MAX_BOOK_FRAME_SIZE)));
301
- }
302
-
303
- /** What the {@link StrandPeerBookService} receiver needs from its host, injected so it is testable without a node. */
304
- export interface StrandPeerBookServiceDeps {
305
- strandId: string;
306
- /** This strand node's own peer id: a received entry naming it is never merged. */
307
- selfPeerId: string;
308
- /**
309
- * The entries to answer `forPeerId` with — this node's own signed entry plus every
310
- * signed entry it holds, minus `forPeerId`'s own (the asker knows where it is).
311
- */
312
- localEntries(forPeerId: string): SignedStrandPeerEntry[];
313
- /** The asker's VERIFIED entries, for the host to merge. Must not throw. */
314
- onEntries(entries: SignedStrandPeerEntry[], fromPeerId: string): void;
315
- /** Clock, for tests. Default `Date.now`. */
316
- now?: () => number;
317
- /**
318
- * How long the receiver waits for the inbound request frame (ms). Required for the same
319
- * reason as {@link ExchangeStrandPeerBookOptions.timeoutMs}: the swap gives both sides one
320
- * link-derived deadline.
321
- */
322
- readTimeoutMs: number;
323
- /** Default {@link DEFAULT_MAX_CONCURRENT}. Over it, an empty frame is returned unread. */
324
- maxConcurrent?: number;
325
- }
326
-
327
- /**
328
- * Receiver side: registers the protocol on a strand node and, per inbound frame,
329
- * verifies and hands over the asker's entries, then answers with the local book.
330
- * Registered through `node.handle`, so on a closed strand every inbound stream
331
- * passes the revocation gate first — a revoked machine cannot push addresses into
332
- * anyone's book.
333
- */
334
- export class StrandPeerBookService {
335
- private readonly readTimeoutMs: number;
336
- private readonly maxConcurrent: number;
337
- private readonly now: () => number;
338
- private node: Libp2p | null = null;
339
- private activeStreams = 0;
340
-
341
- constructor(private readonly deps: StrandPeerBookServiceDeps) {
342
- this.readTimeoutMs = deps.readTimeoutMs;
343
- this.maxConcurrent = deps.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
344
- this.now = deps.now ?? Date.now;
345
- }
346
-
347
- /** In-flight inbound streams. */
348
- get activeCount(): number {
349
- return this.activeStreams;
350
- }
351
-
352
- /** Register the handler. Resolves once libp2p has recorded the protocol on the self record. */
353
- async initialize(node: Libp2p): Promise<void> {
354
- this.node = node;
355
- // `runOnLimitedConnection: true`: strand peers reach each other over relayed
356
- // (limited) connections as a matter of course, and this is one small frame each way.
357
- await node.handle(STRAND_PEER_BOOK_PROTOCOL, (rawStream: unknown, rawConnection: unknown) => {
358
- const remotePeerId = (rawConnection as Connection).remotePeer.toString();
359
- void this.handleStream(rawStream as ControlStream, remotePeerId);
360
- }, { runOnLimitedConnection: true });
361
- log('[%s] registered %s', this.deps.strandId, STRAND_PEER_BOOK_PROTOCOL);
362
- }
363
-
364
- /** Unregister the handler and release the node reference. */
365
- async shutdown(): Promise<void> {
366
- if (this.node) {
367
- await this.node.unhandle(STRAND_PEER_BOOK_PROTOCOL);
368
- this.node = null;
369
- }
370
- }
371
-
372
- /**
373
- * Read the request, merge what verifies, answer with the local book. Every failure
374
- * (cap, malformed or oversized frame, wrong strand, read timeout) answers an empty
375
- * frame rather than hanging or dropping the stream, so the asker's exchange settles.
376
- *
377
- * NOTE: the concurrency cap bounds streams in flight, not frames per peer per unit
378
- * time — the ten-minute throttle is the CLIENT's, so a connected peer can push a
379
- * verified frame, and the persist it costs, as fast as it likes; see
380
- * `backlog/debt-strand-peer-book-remote-write-bounds`.
381
- */
382
- private async handleStream(stream: ControlStream, remotePeerId: string): Promise<void> {
383
- if (this.activeStreams >= this.maxConcurrent) {
384
- log('[%s] refusing swap from %s: %d streams at the cap', this.deps.strandId, remotePeerId, this.activeStreams);
385
- this.reply(stream, []);
386
- await closeQuietly(stream);
387
- return;
388
- }
389
- this.activeStreams++;
390
- try {
391
- const raw = await readBookFrame(stream, this.readTimeoutMs);
392
- const entries = await verifyStrandPeerBookFrame(this.deps.strandId, raw, this.deps.selfPeerId, this.now());
393
- log('[%s] swap from %s: %d verified entr(ies) received', this.deps.strandId, remotePeerId, entries.length);
394
- this.deps.onEntries(entries, remotePeerId);
395
- this.reply(stream, this.deps.localEntries(remotePeerId));
396
- } catch (error) {
397
- log('[%s] swap from %s failed: %o', this.deps.strandId, remotePeerId, error);
398
- this.reply(stream, []);
399
- } finally {
400
- this.activeStreams--;
401
- await closeQuietly(stream);
402
- }
403
- }
404
-
405
- private reply(stream: ControlStream, entries: SignedStrandPeerEntry[]): void {
406
- try {
407
- const frame: StrandPeerBookFrame = { strandId: this.deps.strandId, entries };
408
- assertBookFrameFits(frame);
409
- writeFrame(stream, frame);
410
- } catch (error) {
411
- log('[%s] swap reply failed: %o', this.deps.strandId, error);
412
- }
413
- }
414
- }
415
-
416
- async function closeQuietly(stream: ControlStream): Promise<void> {
417
- try {
418
- await stream.close();
419
- } catch (error) {
420
- log('stream close failed (ignored): %o', error);
421
- }
422
- }
423
-
424
- /** Options for {@link exchangeStrandPeerBook}. */
425
- export interface ExchangeStrandPeerBookOptions {
426
- /** This strand node's own peer id: a returned entry naming it is dropped. */
427
- selfPeerId: string;
428
- /**
429
- * Whole-exchange deadline (ms). Required, with no fallback here: the exchange crosses the
430
- * link, so its deadline derives from the declared link round trip (`link-budget.ts`), and
431
- * the caller is the one that knows the declaration. A default in this module would be a
432
- * second number free to drift from that derivation.
433
- */
434
- timeoutMs: number;
435
- /** Clock, for tests. Default `Date.now`. */
436
- now?: () => number;
437
- }
438
-
439
- /**
440
- * Client side: open one stream on `connection` — the live connection to the peer,
441
- * relayed or not — send `request`, and return the peer's VERIFIED entries. Throws on
442
- * any failure (the peer does not speak the protocol, a deadline, a refused frame);
443
- * the caller logs and moves on, never retrying before its throttle expires.
444
- */
445
- export async function exchangeStrandPeerBook(
446
- connection: Connection,
447
- request: StrandPeerBookFrame,
448
- options: ExchangeStrandPeerBookOptions
449
- ): Promise<SignedStrandPeerEntry[]> {
450
- const { timeoutMs } = options;
451
- const remotePeerId = connection.remotePeer.toString();
452
- assertBookFrameFits(request);
453
- // One deadline for the stream open and the exchange together: its signal aborts the
454
- // negotiation and resets a live stream, so neither leaks on a timeout.
455
- const raw = await withDeadline(timeoutMs, `Strand peer book swap with ${remotePeerId}`, async (signal) => {
456
- const stream = await connection.newStream(STRAND_PEER_BOOK_PROTOCOL, { runOnLimitedConnection: true, signal });
457
- return exchangeFrame(
458
- stream as unknown as ControlStream,
459
- signal,
460
- request,
461
- (s) => readBookFrame(s, timeoutMs),
462
- 'Strand peer book swap aborted by timeout'
463
- );
464
- });
465
- return verifyStrandPeerBookFrame(request.strandId, raw, options.selfPeerId, (options.now ?? Date.now)());
466
- }