@optimystic/db-p2p 0.24.0 → 0.24.2

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 (60) hide show
  1. package/dist/src/cluster/service.d.ts +8 -0
  2. package/dist/src/cluster/service.d.ts.map +1 -1
  3. package/dist/src/cluster/service.js +16 -4
  4. package/dist/src/cluster/service.js.map +1 -1
  5. package/dist/src/cohort-topic/host.js +34 -11
  6. package/dist/src/cohort-topic/host.js.map +1 -1
  7. package/dist/src/cohort-topic/stream-util.d.ts +25 -11
  8. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  9. package/dist/src/cohort-topic/stream-util.js +31 -19
  10. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  11. package/dist/src/libp2p-key-network.d.ts +68 -0
  12. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  13. package/dist/src/libp2p-key-network.js +123 -14
  14. package/dist/src/libp2p-key-network.js.map +1 -1
  15. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  16. package/dist/src/libp2p-node-base.js +8 -5
  17. package/dist/src/libp2p-node-base.js.map +1 -1
  18. package/dist/src/logger.d.ts +2 -2
  19. package/dist/src/logger.js +2 -2
  20. package/dist/src/matchmaking/query-transport.js +3 -3
  21. package/dist/src/matchmaking/query-transport.js.map +1 -1
  22. package/dist/src/peer-address-book.d.ts +69 -0
  23. package/dist/src/peer-address-book.d.ts.map +1 -1
  24. package/dist/src/peer-address-book.js +110 -15
  25. package/dist/src/peer-address-book.js.map +1 -1
  26. package/dist/src/reactivity/notify-transport.d.ts +4 -4
  27. package/dist/src/reactivity/notify-transport.js +6 -6
  28. package/dist/src/reactivity/notify-transport.js.map +1 -1
  29. package/dist/src/reactivity/push-state-gossip.js +2 -2
  30. package/dist/src/reactivity/push-state-gossip.js.map +1 -1
  31. package/dist/src/reactivity/recover-transport.d.ts +6 -2
  32. package/dist/src/reactivity/recover-transport.d.ts.map +1 -1
  33. package/dist/src/reactivity/recover-transport.js +7 -3
  34. package/dist/src/reactivity/recover-transport.js.map +1 -1
  35. package/dist/src/repo/service.d.ts +6 -0
  36. package/dist/src/repo/service.d.ts.map +1 -1
  37. package/dist/src/repo/service.js +12 -2
  38. package/dist/src/repo/service.js.map +1 -1
  39. package/dist/src/routing/libp2p-known-peers.d.ts.map +1 -1
  40. package/dist/src/routing/libp2p-known-peers.js +5 -0
  41. package/dist/src/routing/libp2p-known-peers.js.map +1 -1
  42. package/dist/src/testing/cohort-topic-mesh-harness.d.ts +13 -6
  43. package/dist/src/testing/cohort-topic-mesh-harness.d.ts.map +1 -1
  44. package/dist/src/testing/cohort-topic-mesh-harness.js +15 -6
  45. package/dist/src/testing/cohort-topic-mesh-harness.js.map +1 -1
  46. package/package.json +3 -3
  47. package/src/cluster/service.ts +305 -293
  48. package/src/cohort-topic/host.ts +2932 -2901
  49. package/src/cohort-topic/stream-util.ts +147 -135
  50. package/src/libp2p-key-network.ts +1235 -1120
  51. package/src/libp2p-node-base.ts +1678 -1675
  52. package/src/logger.ts +27 -27
  53. package/src/matchmaking/query-transport.ts +492 -492
  54. package/src/peer-address-book.ts +266 -149
  55. package/src/reactivity/notify-transport.ts +144 -144
  56. package/src/reactivity/push-state-gossip.ts +291 -291
  57. package/src/reactivity/recover-transport.ts +412 -408
  58. package/src/repo/service.ts +323 -313
  59. package/src/routing/libp2p-known-peers.ts +31 -26
  60. package/src/testing/cohort-topic-mesh-harness.ts +673 -663
@@ -1,293 +1,305 @@
1
- import { pipe } from 'it-pipe';
2
- import { decode as lpDecode, encode as lpEncode } from 'it-length-prefixed';
3
- import { peerIdFromString } from '@libp2p/peer-id';
4
- import type { Startable, Logger, Stream, Connection, StreamHandler, PeerId } from '@libp2p/interface';
5
- import type { ICluster, ClusterRecord } from '@optimystic/db-core';
6
- import { encodePeers, type RedirectPayload } from '../repo/redirect.js';
7
- import { toClusterErrorEnvelope } from './cluster-error.js';
8
- import { mergeRecordPeerAddresses } from '../peer-address-book.js';
9
- import { MAX_CONTROL_MESSAGE_BYTES } from '../protocol-limits.js';
10
- import type { Uint8ArrayList } from 'uint8arraylist';
11
- import { createInboundStreamAuthorization, type InboundStreamAuthorization, type InboundStreamAuthorizationInit } from '../inbound-authorization.js';
12
-
13
- interface BaseComponents {
14
- logger: { forComponent: (name: string) => Logger },
15
- registrar: {
16
- handle: (protocol: string, handler: StreamHandler, options: any) => Promise<void>,
17
- unhandle: (protocol: string) => Promise<void>
18
- }
19
- }
20
-
21
- export interface ClusterServiceComponents extends BaseComponents {
22
- cluster: ICluster
23
- /**
24
- * This node's own peer id, used to decide whether we are a member of a
25
- * cluster record's peer set. When absent the service cannot scope membership
26
- * and processes every update locally (no redirect).
27
- */
28
- peerId?: PeerId
29
- /**
30
- * Optional resolver for a peer's dialable multiaddrs, used as a fallback when
31
- * a redirect target has no multiaddrs embedded in `record.peers`.
32
- */
33
- getConnectionAddrs?: (peerId: PeerId) => string[]
34
- /**
35
- * Optional sink for dialable addresses carried by an inbound cluster record, so this
36
- * node can later dial a cohort sibling it has never had a connection to. Omitted →
37
- * no address learning (the pre-existing behavior).
38
- */
39
- recordPeerAddresses?: (peerId: PeerId, multiaddrs: string[]) => void
40
- }
41
-
42
- export interface ClusterServiceInit extends InboundStreamAuthorizationInit {
43
- protocol?: string,
44
- protocolPrefix?: string,
45
- maxInboundStreams?: number,
46
- maxOutboundStreams?: number,
47
- logPrefix?: string,
48
- /**
49
- * Responsibility K - the replica set size for determining cluster membership.
50
- * When the cluster record's peer set is smaller than this, the mesh is treated
51
- * as "small" and the update is processed locally regardless of membership. When
52
- * the peer set is at least this size and we are not a member, the update is
53
- * redirected to the responsible peers.
54
- * Default: 1 (only members process; any larger non-member set redirects)
55
- */
56
- responsibilityK?: number,
57
- }
58
-
59
- export function clusterService(init: ClusterServiceInit = {}): (components: ClusterServiceComponents) => ClusterService {
60
- return (components: ClusterServiceComponents) => new ClusterService(components, init);
61
- }
62
-
63
- /**
64
- * A libp2p service that handles cluster protocol messages
65
- */
66
- export class ClusterService implements Startable {
67
- private readonly protocol: string;
68
- private readonly maxInboundStreams: number;
69
- private readonly maxOutboundStreams: number;
70
- private readonly log: Logger;
71
- private readonly cluster: ICluster;
72
- private readonly components: ClusterServiceComponents;
73
- private running: boolean;
74
- /** Responsibility K - small-mesh bypass threshold for redirect decisions */
75
- private readonly responsibilityK: number;
76
- /** Optional embedder authorization gate; `undefined` (the default) means no check runs. */
77
- private readonly authorization: InboundStreamAuthorization | undefined;
78
-
79
- constructor(components: ClusterServiceComponents, init: ClusterServiceInit = {}) {
80
- this.components = components;
81
- this.protocol = init.protocol ?? (init.protocolPrefix ?? '/db-p2p') + '/cluster/1.0.0';
82
- this.maxInboundStreams = init.maxInboundStreams ?? 32;
83
- this.maxOutboundStreams = init.maxOutboundStreams ?? 64;
84
- this.log = components.logger.forComponent(init.logPrefix ?? 'db-p2p:cluster');
85
- this.cluster = components.cluster;
86
- this.running = false;
87
- this.responsibilityK = init.responsibilityK ?? 1;
88
- this.authorization = createInboundStreamAuthorization(init, this.protocol, (msg, ...args) => this.log.error(msg, ...args));
89
- }
90
-
91
- readonly [Symbol.toStringTag] = '@libp2p/cluster';
92
-
93
- /**
94
- * Best-effort read of `components.libp2p`. When `components` is libp2p's own Proxy, the getter
95
- * THROWS `MissingServiceError('libp2p not set')` for any key it does not hold — and `libp2p` is
96
- * not a component — so the read itself must be guarded; `?.` and a following null check are both
97
- * too late. Every fallback below is a convenience for embedders that register this service
98
- * directly; the production wiring supplies `peerId`/`getConnectionAddrs` explicitly.
99
- */
100
- private getLibp2p(): any {
101
- try {
102
- return (this.components as any).libp2p;
103
- } catch {
104
- return undefined;
105
- }
106
- }
107
-
108
- private getSelfId(): PeerId | undefined {
109
- if (this.components.peerId) return this.components.peerId;
110
- return this.getLibp2p()?.peerId as PeerId | undefined;
111
- }
112
-
113
- private getPeerAddrs(id: string): string[] {
114
- let pid: PeerId;
115
- try {
116
- pid = peerIdFromString(id);
117
- } catch {
118
- return [];
119
- }
120
- if (this.components.getConnectionAddrs) return this.components.getConnectionAddrs(pid);
121
- const libp2p = this.getLibp2p();
122
- if (!libp2p?.getConnections) return [];
123
- const conns: any[] = libp2p.getConnections(pid) ?? [];
124
- const addrs: string[] = [];
125
- for (const c of conns) {
126
- const addr = c.remoteAddr?.toString?.();
127
- if (addr) addrs.push(addr);
128
- }
129
- return addrs;
130
- }
131
-
132
- /**
133
- * Decide whether this node should redirect a cluster update instead of
134
- * participating in its consensus.
135
- *
136
- * Membership is scoped against `record.peers` — the authoritative set the
137
- * coordinator already computed and embedded (it only ever dials peers in this
138
- * set). Using it directly (rather than independently recomputing the cluster
139
- * from the key) is regression-proof against the "empty promises" symptom: a
140
- * peer the coordinator legitimately included is, by construction, present in
141
- * `record.peers` and is therefore never redirected.
142
- *
143
- * Returns a {@link RedirectPayload} when this node is not responsible, or null
144
- * when the update should be processed locally (we are a member, the mesh is too
145
- * small to scope, or we lack the identity/peer set to make a decision).
146
- */
147
- checkRedirect(record: ClusterRecord): RedirectPayload | null {
148
- const selfId = this.getSelfId();
149
- if (!selfId) return null; // no identity can't scope, process locally
150
-
151
- const peers = record.peers ?? {};
152
- const peerIds = Object.keys(peers);
153
- if (peerIds.length === 0) return null; // nothing to scope against → process locally
154
-
155
- const selfStr = selfId.toString();
156
- const isMember = peerIds.includes(selfStr);
157
- const smallMesh = peerIds.length < this.responsibilityK;
158
-
159
- if (!smallMesh && !isMember) {
160
- const others = peerIds.filter(id => id !== selfStr);
161
- return encodePeers(others.map(id => {
162
- const recAddrs = peers[id]?.multiaddrs ?? [];
163
- const addrs = recAddrs.length > 0 ? recAddrs : this.getPeerAddrs(id);
164
- return { id, addrs };
165
- }));
166
- }
167
-
168
- return null;
169
- }
170
-
171
- async start(): Promise<void> {
172
- if (this.running) {
173
- return;
174
- }
175
-
176
- await this.components.registrar.handle(this.protocol, this.handleIncomingStream.bind(this), {
177
- maxInboundStreams: this.maxInboundStreams,
178
- maxOutboundStreams: this.maxOutboundStreams
179
- });
180
-
181
- this.running = true;
182
- }
183
-
184
- async stop(): Promise<void> {
185
- if (!this.running) {
186
- return;
187
- }
188
-
189
- await this.components.registrar.unhandle(this.protocol);
190
- this.running = false;
191
- }
192
-
193
- /**
194
- * Run a single decoded protocol message. An application-level throw
195
- * (validation / signature / merge / consensus failure inside `cluster.update`)
196
- * propagates to the caller, which turns it into a structured error envelope;
197
- * a redirect or a successful {@link ClusterRecord} is returned as-is.
198
- *
199
- * Public for the same reason {@link checkRedirect} is: it is the whole wire-ingress decision
200
- * for a cluster update, and a test that reconstructs it by hand stops proving anything about
201
- * the real ordering (address learning before redirect before consensus).
202
- */
203
- async processOperation(message: { operation: string; record: ClusterRecord }): Promise<unknown> {
204
- if (message.operation === 'update') {
205
- // Learn the cohort's addresses FIRST — before both the redirect decision and
206
- // local consensus, since either can go on to dial these same peers. libp2p only
207
- // tells us the addresses of peers we are directly connected to, so for a cohort
208
- // picked by key position this record is often the only place a relay-only
209
- // sibling's address ever reaches us.
210
- this.learnPeerAddresses(message.record);
211
- // Scope consensus to responsible peers: redirect when we are not a
212
- // member of the record's authoritative peer set, otherwise process.
213
- const redirect = this.checkRedirect(message.record);
214
- return redirect ?? await this.cluster.update(message.record);
215
- }
216
- throw new Error(`Unknown operation: ${message.operation}`);
217
- }
218
-
219
- /**
220
- * Offer every address the record carries for its cohort members to the node's address book.
221
- *
222
- * This runs on a record NOTHING has validated yet — before {@link checkRedirect} and before
223
- * `cluster.update` checks a signature and inbound stream authorization is opt-in, so the
224
- * peer map here is whatever the dialer chose to send. The traversal (and the cap on how many
225
- * peers one record may introduce) is therefore shared with `ClusterClient`, in
226
- * `peer-address-book.ts`, along with the per-address validation and the trust boundary.
227
- */
228
- private learnPeerAddresses(record: ClusterRecord): void {
229
- const sink = this.components.recordPeerAddresses;
230
- if (!sink) return;
231
- mergeRecordPeerAddresses(
232
- record.peers,
233
- sink,
234
- (fmt, ...args) => this.log.error(fmt, ...args),
235
- this.getSelfId()?.toString()
236
- );
237
- }
238
-
239
- private handleIncomingStream(stream: Stream, connection?: Connection): void {
240
- const peerId = connection?.remotePeer;
241
-
242
- const processStream = async function* (this: ClusterService, source: AsyncIterable<Uint8ArrayList>) {
243
- for await (const msg of source) {
244
- // Decode the framing. A malformed/undecodable message is a transport
245
- // fault handled by the outer abort path, not an application error.
246
- const decoded = new TextDecoder().decode(msg.subarray());
247
- const message = JSON.parse(decoded) as { operation: string; record: ClusterRecord };
248
-
249
- // Application-level processing: surface any throw to the coordinator as
250
- // a structured error envelope (closing the stream normally) instead of
251
- // aborting, so the real cause — not an opaque StreamResetError — reaches
252
- // the coordinator, which already enables debug logging. The abort path
253
- // is reserved for genuinely unrecoverable framing/transport faults.
254
- let response: unknown;
255
- try {
256
- response = await this.processOperation(message);
257
- } catch (err) {
258
- this.log.error('error processing cluster %s from %p - %e', message.operation, peerId, err);
259
- response = toClusterErrorEnvelope(err);
260
- }
261
-
262
- // Encode and yield the response
263
- yield new TextEncoder().encode(JSON.stringify(response));
264
- // One request per stream: every real ClusterClient sends exactly one
265
- // request per dial (see ProtocolClient.processMessage), so complete the
266
- // generator after the first response. A second frame a peer queued is
267
- // then never read or parsed. Mirrors sync/block-transfer.
268
- return;
269
- }
270
- };
271
-
272
- void (async () => {
273
- try {
274
- // Authorization runs before ANY decoding or execution. Guarded on the field so a
275
- // node without a predicate keeps the original path untouched.
276
- if (this.authorization && await this.authorization.deny(stream, peerId?.toString())) return;
277
- const responses = pipe(
278
- stream,
279
- (source) => lpDecode(source, { maxDataLength: MAX_CONTROL_MESSAGE_BYTES }),
280
- processStream.bind(this),
281
- (source) => lpEncode(source)
282
- );
283
- for await (const chunk of responses) {
284
- stream.send(chunk);
285
- }
286
- await stream.close();
287
- } catch (err) {
288
- this.log.error('error handling cluster protocol message from %p - %e', peerId, err);
289
- stream.abort(err instanceof Error ? err : new Error(String(err)));
290
- }
291
- })();
292
- }
293
- }
1
+ import { pipe } from 'it-pipe';
2
+ import { decode as lpDecode, encode as lpEncode } from 'it-length-prefixed';
3
+ import { peerIdFromString } from '@libp2p/peer-id';
4
+ import type { Startable, Logger, Stream, Connection, StreamHandler, PeerId } from '@libp2p/interface';
5
+ import type { ICluster, ClusterRecord } from '@optimystic/db-core';
6
+ import { encodePeers, type RedirectPayload } from '../repo/redirect.js';
7
+ import { toClusterErrorEnvelope } from './cluster-error.js';
8
+ import { mergeRecordPeerAddresses, publishableConnectionAddr, type AddressLog, type DirectionalConnection } from '../peer-address-book.js';
9
+ import { MAX_CONTROL_MESSAGE_BYTES } from '../protocol-limits.js';
10
+ import type { Uint8ArrayList } from 'uint8arraylist';
11
+ import { createLogger } from '../logger.js';
12
+ import { createInboundStreamAuthorization, type InboundStreamAuthorization, type InboundStreamAuthorizationInit } from '../inbound-authorization.js';
13
+
14
+ interface BaseComponents {
15
+ logger: { forComponent: (name: string) => Logger },
16
+ registrar: {
17
+ handle: (protocol: string, handler: StreamHandler, options: any) => Promise<void>,
18
+ unhandle: (protocol: string) => Promise<void>
19
+ }
20
+ }
21
+
22
+ export interface ClusterServiceComponents extends BaseComponents {
23
+ cluster: ICluster
24
+ /**
25
+ * This node's own peer id, used to decide whether we are a member of a
26
+ * cluster record's peer set. When absent the service cannot scope membership
27
+ * and processes every update locally (no redirect).
28
+ */
29
+ peerId?: PeerId
30
+ /**
31
+ * Optional resolver for a peer's dialable multiaddrs, used as a fallback when
32
+ * a redirect target has no multiaddrs embedded in `record.peers`.
33
+ */
34
+ getConnectionAddrs?: (peerId: PeerId) => string[]
35
+ /**
36
+ * Optional sink for dialable addresses carried by an inbound cluster record, so this
37
+ * node can later dial a cohort sibling it has never had a connection to. Omitted →
38
+ * no address learning (the pre-existing behavior).
39
+ */
40
+ recordPeerAddresses?: (peerId: PeerId, multiaddrs: string[]) => void
41
+ }
42
+
43
+ export interface ClusterServiceInit extends InboundStreamAuthorizationInit {
44
+ protocol?: string,
45
+ protocolPrefix?: string,
46
+ maxInboundStreams?: number,
47
+ maxOutboundStreams?: number,
48
+ logPrefix?: string,
49
+ /**
50
+ * Responsibility K - the replica set size for determining cluster membership.
51
+ * When the cluster record's peer set is smaller than this, the mesh is treated
52
+ * as "small" and the update is processed locally regardless of membership. When
53
+ * the peer set is at least this size and we are not a member, the update is
54
+ * redirected to the responsible peers.
55
+ * Default: 1 (only members process; any larger non-member set redirects)
56
+ */
57
+ responsibilityK?: number,
58
+ }
59
+
60
+ export function clusterService(init: ClusterServiceInit = {}): (components: ClusterServiceComponents) => ClusterService {
61
+ return (components: ClusterServiceComponents) => new ClusterService(components, init);
62
+ }
63
+
64
+ /**
65
+ * A libp2p service that handles cluster protocol messages
66
+ */
67
+ export class ClusterService implements Startable {
68
+ private readonly protocol: string;
69
+ private readonly maxInboundStreams: number;
70
+ private readonly maxOutboundStreams: number;
71
+ private readonly log: Logger;
72
+ /**
73
+ * Sink for this service's `peer-address-book:*` lines. Deliberately NOT `this.log.error`, which
74
+ * lands them under libp2p's `db-p2p:cluster:error` namespace invisible to the
75
+ * `DEBUG=optimystic:db-p2p:*` filter this package's docs recommend, and the reason
76
+ * gotchoices/Optimystic#12 read a zero log count as proof the mechanism never ran. One tag
77
+ * family, one namespace tree.
78
+ */
79
+ private readonly addressLog: AddressLog;
80
+ private readonly cluster: ICluster;
81
+ private readonly components: ClusterServiceComponents;
82
+ private running: boolean;
83
+ /** Responsibility K - small-mesh bypass threshold for redirect decisions */
84
+ private readonly responsibilityK: number;
85
+ /** Optional embedder authorization gate; `undefined` (the default) means no check runs. */
86
+ private readonly authorization: InboundStreamAuthorization | undefined;
87
+
88
+ constructor(components: ClusterServiceComponents, init: ClusterServiceInit = {}) {
89
+ this.components = components;
90
+ this.protocol = init.protocol ?? (init.protocolPrefix ?? '/db-p2p') + '/cluster/1.0.0';
91
+ this.maxInboundStreams = init.maxInboundStreams ?? 32;
92
+ this.maxOutboundStreams = init.maxOutboundStreams ?? 64;
93
+ this.log = components.logger.forComponent(init.logPrefix ?? 'db-p2p:cluster');
94
+ this.addressLog = createLogger('peer-address-book', components.peerId?.toString());
95
+ this.cluster = components.cluster;
96
+ this.running = false;
97
+ this.responsibilityK = init.responsibilityK ?? 1;
98
+ this.authorization = createInboundStreamAuthorization(init, this.protocol, (msg, ...args) => this.log.error(msg, ...args));
99
+ }
100
+
101
+ readonly [Symbol.toStringTag] = '@libp2p/cluster';
102
+
103
+ /**
104
+ * Best-effort read of `components.libp2p`. When `components` is libp2p's own Proxy, the getter
105
+ * THROWS `MissingServiceError('libp2p not set')` for any key it does not hold — and `libp2p` is
106
+ * not a component — so the read itself must be guarded; `?.` and a following null check are both
107
+ * too late. Every fallback below is a convenience for embedders that register this service
108
+ * directly; the production wiring supplies `peerId`/`getConnectionAddrs` explicitly.
109
+ */
110
+ private getLibp2p(): any {
111
+ try {
112
+ return (this.components as any).libp2p;
113
+ } catch {
114
+ return undefined;
115
+ }
116
+ }
117
+
118
+ private getSelfId(): PeerId | undefined {
119
+ if (this.components.peerId) return this.components.peerId;
120
+ return this.getLibp2p()?.peerId as PeerId | undefined;
121
+ }
122
+
123
+ private getPeerAddrs(id: string): string[] {
124
+ let pid: PeerId;
125
+ try {
126
+ pid = peerIdFromString(id);
127
+ } catch {
128
+ return [];
129
+ }
130
+ if (this.components.getConnectionAddrs) return this.components.getConnectionAddrs(pid);
131
+ const libp2p = this.getLibp2p();
132
+ if (!libp2p?.getConnections) return [];
133
+ // A redirect payload goes to a THIRD party, so only an outbound connection's remoteAddr
134
+ // qualifies see `publishableConnectionAddr`.
135
+ const conns: DirectionalConnection[] = libp2p.getConnections(pid) ?? [];
136
+ const addrs: string[] = [];
137
+ for (const c of conns) {
138
+ const addr = publishableConnectionAddr(c, this.addressLog);
139
+ if (addr !== undefined) addrs.push(addr);
140
+ }
141
+ return addrs;
142
+ }
143
+
144
+ /**
145
+ * Decide whether this node should redirect a cluster update instead of
146
+ * participating in its consensus.
147
+ *
148
+ * Membership is scoped against `record.peers` — the authoritative set the
149
+ * coordinator already computed and embedded (it only ever dials peers in this
150
+ * set). Using it directly (rather than independently recomputing the cluster
151
+ * from the key) is regression-proof against the "empty promises" symptom: a
152
+ * peer the coordinator legitimately included is, by construction, present in
153
+ * `record.peers` and is therefore never redirected.
154
+ *
155
+ * Returns a {@link RedirectPayload} when this node is not responsible, or null
156
+ * when the update should be processed locally (we are a member, the mesh is too
157
+ * small to scope, or we lack the identity/peer set to make a decision).
158
+ */
159
+ checkRedirect(record: ClusterRecord): RedirectPayload | null {
160
+ const selfId = this.getSelfId();
161
+ if (!selfId) return null; // no identity → can't scope, process locally
162
+
163
+ const peers = record.peers ?? {};
164
+ const peerIds = Object.keys(peers);
165
+ if (peerIds.length === 0) return null; // nothing to scope against → process locally
166
+
167
+ const selfStr = selfId.toString();
168
+ const isMember = peerIds.includes(selfStr);
169
+ const smallMesh = peerIds.length < this.responsibilityK;
170
+
171
+ if (!smallMesh && !isMember) {
172
+ const others = peerIds.filter(id => id !== selfStr);
173
+ return encodePeers(others.map(id => {
174
+ const recAddrs = peers[id]?.multiaddrs ?? [];
175
+ const addrs = recAddrs.length > 0 ? recAddrs : this.getPeerAddrs(id);
176
+ return { id, addrs };
177
+ }));
178
+ }
179
+
180
+ return null;
181
+ }
182
+
183
+ async start(): Promise<void> {
184
+ if (this.running) {
185
+ return;
186
+ }
187
+
188
+ await this.components.registrar.handle(this.protocol, this.handleIncomingStream.bind(this), {
189
+ maxInboundStreams: this.maxInboundStreams,
190
+ maxOutboundStreams: this.maxOutboundStreams
191
+ });
192
+
193
+ this.running = true;
194
+ }
195
+
196
+ async stop(): Promise<void> {
197
+ if (!this.running) {
198
+ return;
199
+ }
200
+
201
+ await this.components.registrar.unhandle(this.protocol);
202
+ this.running = false;
203
+ }
204
+
205
+ /**
206
+ * Run a single decoded protocol message. An application-level throw
207
+ * (validation / signature / merge / consensus failure inside `cluster.update`)
208
+ * propagates to the caller, which turns it into a structured error envelope;
209
+ * a redirect or a successful {@link ClusterRecord} is returned as-is.
210
+ *
211
+ * Public for the same reason {@link checkRedirect} is: it is the whole wire-ingress decision
212
+ * for a cluster update, and a test that reconstructs it by hand stops proving anything about
213
+ * the real ordering (address learning before redirect before consensus).
214
+ */
215
+ async processOperation(message: { operation: string; record: ClusterRecord }): Promise<unknown> {
216
+ if (message.operation === 'update') {
217
+ // Learn the cohort's addresses FIRST — before both the redirect decision and
218
+ // local consensus, since either can go on to dial these same peers. libp2p only
219
+ // tells us the addresses of peers we are directly connected to, so for a cohort
220
+ // picked by key position this record is often the only place a relay-only
221
+ // sibling's address ever reaches us.
222
+ this.learnPeerAddresses(message.record);
223
+ // Scope consensus to responsible peers: redirect when we are not a
224
+ // member of the record's authoritative peer set, otherwise process.
225
+ const redirect = this.checkRedirect(message.record);
226
+ return redirect ?? await this.cluster.update(message.record);
227
+ }
228
+ throw new Error(`Unknown operation: ${message.operation}`);
229
+ }
230
+
231
+ /**
232
+ * Offer every address the record carries for its cohort members to the node's address book.
233
+ *
234
+ * This runs on a record NOTHING has validated yet — before {@link checkRedirect} and before
235
+ * `cluster.update` checks a signature — and inbound stream authorization is opt-in, so the
236
+ * peer map here is whatever the dialer chose to send. The traversal (and the cap on how many
237
+ * peers one record may introduce) is therefore shared with `ClusterClient`, in
238
+ * `peer-address-book.ts`, along with the per-address validation and the trust boundary.
239
+ */
240
+ private learnPeerAddresses(record: ClusterRecord): void {
241
+ const sink = this.components.recordPeerAddresses;
242
+ if (!sink) return;
243
+ mergeRecordPeerAddresses(
244
+ record.peers,
245
+ sink,
246
+ this.addressLog,
247
+ this.getSelfId()?.toString()
248
+ );
249
+ }
250
+
251
+ private handleIncomingStream(stream: Stream, connection?: Connection): void {
252
+ const peerId = connection?.remotePeer;
253
+
254
+ const processStream = async function* (this: ClusterService, source: AsyncIterable<Uint8ArrayList>) {
255
+ for await (const msg of source) {
256
+ // Decode the framing. A malformed/undecodable message is a transport
257
+ // fault handled by the outer abort path, not an application error.
258
+ const decoded = new TextDecoder().decode(msg.subarray());
259
+ const message = JSON.parse(decoded) as { operation: string; record: ClusterRecord };
260
+
261
+ // Application-level processing: surface any throw to the coordinator as
262
+ // a structured error envelope (closing the stream normally) instead of
263
+ // aborting, so the real cause — not an opaque StreamResetError — reaches
264
+ // the coordinator, which already enables debug logging. The abort path
265
+ // is reserved for genuinely unrecoverable framing/transport faults.
266
+ let response: unknown;
267
+ try {
268
+ response = await this.processOperation(message);
269
+ } catch (err) {
270
+ this.log.error('error processing cluster %s from %p - %e', message.operation, peerId, err);
271
+ response = toClusterErrorEnvelope(err);
272
+ }
273
+
274
+ // Encode and yield the response
275
+ yield new TextEncoder().encode(JSON.stringify(response));
276
+ // One request per stream: every real ClusterClient sends exactly one
277
+ // request per dial (see ProtocolClient.processMessage), so complete the
278
+ // generator after the first response. A second frame a peer queued is
279
+ // then never read or parsed. Mirrors sync/block-transfer.
280
+ return;
281
+ }
282
+ };
283
+
284
+ void (async () => {
285
+ try {
286
+ // Authorization runs before ANY decoding or execution. Guarded on the field so a
287
+ // node without a predicate keeps the original path untouched.
288
+ if (this.authorization && await this.authorization.deny(stream, peerId?.toString())) return;
289
+ const responses = pipe(
290
+ stream,
291
+ (source) => lpDecode(source, { maxDataLength: MAX_CONTROL_MESSAGE_BYTES }),
292
+ processStream.bind(this),
293
+ (source) => lpEncode(source)
294
+ );
295
+ for await (const chunk of responses) {
296
+ stream.send(chunk);
297
+ }
298
+ await stream.close();
299
+ } catch (err) {
300
+ this.log.error('error handling cluster protocol message from %p - %e', peerId, err);
301
+ stream.abort(err instanceof Error ? err : new Error(String(err)));
302
+ }
303
+ })();
304
+ }
305
+ }