@peerbit/stream 4.3.10 → 4.4.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.
package/dist/src/index.js CHANGED
@@ -52,6 +52,25 @@ const getLaneFromPriority = (priority) => {
52
52
  }
53
53
  return 1;
54
54
  };
55
+ // Hook for tests to override queued length measurement (peerStreams, default impl)
56
+ export let measureOutboundQueuedBytes = (ps) => {
57
+ const active = ps._getActiveOutboundPushable();
58
+ if (!active)
59
+ return 0;
60
+ // Prefer lane-aware helper if present
61
+ // @ts-ignore - optional test helper
62
+ if (typeof active.getReadableLength === "function") {
63
+ try {
64
+ // lane 0 only
65
+ return active.getReadableLength(0) || 0;
66
+ }
67
+ catch {
68
+ // ignore
69
+ }
70
+ }
71
+ // @ts-ignore fallback for vanilla pushable
72
+ return active.readableLength || 0;
73
+ };
55
74
  /**
56
75
  * Thin wrapper around a peer's inbound / outbound pubsub streams
57
76
  */
@@ -59,37 +78,95 @@ export class PeerStreams extends TypedEventEmitter {
59
78
  peerId;
60
79
  publicKey;
61
80
  protocol;
81
+ // Removed dedicated outboundStream; first element of outboundStreams[] is active
62
82
  /**
63
- * Write stream - it's preferable to use the write method
64
- */
65
- outboundStream;
66
- /**
67
- * Read stream
83
+ * Backwards compatible single inbound references (points to first inbound candidate)
68
84
  */
69
85
  inboundStream;
70
- /**
71
- * The raw outbound stream, as retrieved from conn.newStream
72
- */
73
- rawOutboundStream;
74
- /**
75
- * The raw inbound stream, as retrieved from the callback from libp2p.handle
76
- */
77
86
  rawInboundStream;
78
87
  /**
79
- * An AbortController for controlled shutdown of the treams
88
+ * Multiple inbound stream support (more permissive than outbound)
89
+ * We retain concurrent inbound streams to avoid races during migration; inactive ones can later be pruned.
80
90
  */
81
- inboundAbortController;
91
+ inboundStreams = [];
92
+ _inboundPruneTimer;
93
+ static INBOUND_IDLE_MS = 10_000; // configurable grace for inactivity (made public for tests)
94
+ static MAX_INBOUND_STREAMS = 8; // sensible default to prevent flood
82
95
  outboundAbortController;
83
96
  closed;
84
97
  connId;
85
98
  seekedOnce;
86
99
  usedBandWidthTracker;
100
+ // Unified outbound streams list (during grace may contain >1; after pruning length==1)
101
+ outboundStreams = [];
102
+ // Public debug exposure of current raw outbound streams (during grace may contain >1)
103
+ get rawOutboundStreams() {
104
+ return this.outboundStreams.map((c) => c.raw);
105
+ }
106
+ _getActiveOutboundPushable() {
107
+ return this.outboundStreams[0]?.pushable;
108
+ }
109
+ _getOutboundCount() {
110
+ return this.outboundStreams.length;
111
+ }
112
+ _getInboundCount() {
113
+ return this.inboundStreams.length;
114
+ }
115
+ _debugInboundStats() {
116
+ return this.inboundStreams.map((c) => ({
117
+ id: c.raw.id,
118
+ created: c.created,
119
+ lastActivity: c.lastActivity,
120
+ bytesReceived: c.bytesReceived,
121
+ }));
122
+ }
123
+ _outboundPruneTimer;
124
+ static OUTBOUND_GRACE_MS = 500; // TODO configurable
125
+ _addOutboundCandidate(raw) {
126
+ const existing = this.outboundStreams.find((c) => c.raw === raw);
127
+ if (existing)
128
+ return existing;
129
+ const pushableInst = pushableLanes({
130
+ lanes: 2,
131
+ onPush: (val) => {
132
+ candidate.bytesDelivered += val.length || val.byteLength || 0;
133
+ },
134
+ });
135
+ const candidate = {
136
+ raw,
137
+ pushable: pushableInst,
138
+ created: Date.now(),
139
+ bytesDelivered: 0,
140
+ aborted: false,
141
+ existing: false,
142
+ };
143
+ pipe(pushableInst, (source) => lp.encode(source, { maxDataLength: MAX_DATA_LENGTH_OUT }), raw).catch((e) => {
144
+ candidate.aborted = true;
145
+ logError(e);
146
+ });
147
+ this.outboundStreams.push(candidate);
148
+ const origAbort = raw.abort?.bind(raw);
149
+ raw.abort = (err) => {
150
+ candidate.aborted = true;
151
+ return origAbort?.(err);
152
+ };
153
+ return candidate;
154
+ }
155
+ _scheduleOutboundPrune(reset = true) {
156
+ if (this.outboundStreams.length <= 1)
157
+ return;
158
+ if (reset && this._outboundPruneTimer) {
159
+ clearTimeout(this._outboundPruneTimer);
160
+ }
161
+ if (!this._outboundPruneTimer) {
162
+ this._outboundPruneTimer = setTimeout(() => this.pruneOutboundCandidates(), PeerStreams.OUTBOUND_GRACE_MS);
163
+ }
164
+ }
87
165
  constructor(init) {
88
166
  super();
89
167
  this.peerId = init.peerId;
90
168
  this.publicKey = init.publicKey;
91
169
  this.protocol = init.protocol;
92
- this.inboundAbortController = new AbortController();
93
170
  this.outboundAbortController = new AbortController();
94
171
  this.closed = false;
95
172
  this.connId = init.connId;
@@ -100,13 +177,13 @@ export class PeerStreams extends TypedEventEmitter {
100
177
  * Do we have a connection to read from?
101
178
  */
102
179
  get isReadable() {
103
- return Boolean(this.inboundStream);
180
+ return this.inboundStreams.length > 0;
104
181
  }
105
182
  /**
106
183
  * Do we have a connection to write on?
107
184
  */
108
185
  get isWritable() {
109
- return Boolean(this.outboundStream);
186
+ return this.outboundStreams.length > 0;
110
187
  }
111
188
  get usedBandwidth() {
112
189
  return this.usedBandWidthTracker.value;
@@ -119,14 +196,56 @@ export class PeerStreams extends TypedEventEmitter {
119
196
  if (data.length > MAX_DATA_LENGTH_OUT) {
120
197
  throw new Error(`Message too large (${data.length * 1e-6}) mb). Needs to be less than ${MAX_DATA_LENGTH_OUT * 1e-6} mb`);
121
198
  }
122
- if (this.outboundStream == null) {
199
+ if (!this.isWritable) {
123
200
  logger.error("No writable connection to " + this.peerId.toString());
124
201
  throw new Error("No writable connection to " + this.peerId.toString());
125
202
  }
126
203
  this.usedBandWidthTracker.add(data.byteLength);
127
- this.outboundStream.push(data instanceof Uint8Array ? data : data.subarray(), this.outboundStream.getReadableLength(0) === 0
128
- ? 0
129
- : getLaneFromPriority(priority));
204
+ // Write to all current outbound streams (normally 1, but >1 during grace)
205
+ const payload = data instanceof Uint8Array ? data : data.subarray();
206
+ let successes = 0;
207
+ let failures = [];
208
+ const failed = [];
209
+ for (const c of this.outboundStreams) {
210
+ try {
211
+ c.pushable.push(payload, c.pushable.getReadableLength(0) === 0
212
+ ? 0
213
+ : getLaneFromPriority(priority));
214
+ successes++;
215
+ }
216
+ catch (e) {
217
+ failures.push(e);
218
+ failed.push(c);
219
+ logError(e);
220
+ }
221
+ }
222
+ if (successes === 0) {
223
+ throw new Error("All outbound writes failed (" +
224
+ failures.map((f) => f?.message).join(", ") +
225
+ ")");
226
+ }
227
+ if (failures.length > 0) {
228
+ logger.warn(`Partial outbound write failure: ${failures.length} failed, ${successes} succeeded`);
229
+ // Remove failed streams immediately (best-effort)
230
+ if (failed.length) {
231
+ this.outboundStreams = this.outboundStreams.filter((c) => !failed.includes(c));
232
+ for (const f of failed) {
233
+ try {
234
+ f.raw.abort?.(new AbortError("Failed write"));
235
+ }
236
+ catch { }
237
+ try {
238
+ f.raw.close?.();
239
+ }
240
+ catch { }
241
+ }
242
+ // If more than one remains schedule prune; else ensure outbound event raised
243
+ if (this.outboundStreams.length > 1)
244
+ this._scheduleOutboundPrune(true);
245
+ else
246
+ this.dispatchEvent(new CustomEvent("stream:outbound"));
247
+ }
248
+ }
130
249
  }
131
250
  /**
132
251
  * Write to the outbound stream, waiting until it becomes writable.
@@ -181,58 +300,207 @@ export class PeerStreams extends TypedEventEmitter {
181
300
  * Attach a raw inbound stream and setup a read stream
182
301
  */
183
302
  attachInboundStream(stream) {
184
- // Create and attach a new inbound stream
185
- // The inbound stream is:
186
- // - abortable, set to only return on abort, rather than throw
187
- // - transformed with length-prefix transform
188
- this.rawInboundStream = stream;
189
- this.inboundStream = abortableSource(pipe(this.rawInboundStream, (source) => lp.decode(source, { maxDataLength: MAX_DATA_LENGTH_IN })), this.inboundAbortController.signal, {
303
+ // Support multiple concurrent inbound streams with inactivity pruning.
304
+ // Enforce max inbound streams (drop least recently active)
305
+ if (this.inboundStreams.length >= PeerStreams.MAX_INBOUND_STREAMS) {
306
+ let dropIndex = 0;
307
+ for (let i = 1; i < this.inboundStreams.length; i++) {
308
+ const a = this.inboundStreams[i];
309
+ const b = this.inboundStreams[dropIndex];
310
+ if (a.lastActivity < b.lastActivity ||
311
+ (a.lastActivity === b.lastActivity && a.created < b.created)) {
312
+ dropIndex = i;
313
+ }
314
+ }
315
+ const [drop] = this.inboundStreams.splice(dropIndex, 1);
316
+ try {
317
+ drop.abortController.abort();
318
+ }
319
+ catch {
320
+ logger.error("Failed to abort inbound stream");
321
+ }
322
+ try {
323
+ drop.raw.close?.();
324
+ }
325
+ catch {
326
+ logger.error("Failed to close inbound stream");
327
+ }
328
+ }
329
+ const abortController = new AbortController();
330
+ const decoded = pipe(stream, (source) => lp.decode(source, { maxDataLength: MAX_DATA_LENGTH_IN }));
331
+ const iterable = abortableSource(decoded, abortController.signal, {
190
332
  returnOnAbort: true,
191
333
  onReturnError: (err) => {
192
334
  logger.error("Inbound stream error", err?.message);
193
335
  },
194
336
  });
195
- /* this.rawInboundStream = stream
196
- this.inboundAbortController = new AbortController()
197
- this.inboundAbortController.signal.addEventListener('abort', () => {
198
- this.rawInboundStream!.close()
199
- .catch(err => {
200
- this.rawInboundStream?.abort(err)
201
- })
202
- })
203
-
204
- this.inboundStream = pipe(
205
- this.rawInboundStream!,
206
- (source) => lp.decode(source, { maxDataLength: MAX_DATA_LENGTH_IN }),
207
- )
208
- */
337
+ const record = {
338
+ raw: stream,
339
+ iterable,
340
+ abortController,
341
+ created: Date.now(),
342
+ lastActivity: Date.now(),
343
+ bytesReceived: 0,
344
+ };
345
+ this.inboundStreams.push(record);
346
+ this._scheduleInboundPrune();
347
+ // Backwards compatibility: keep first inbound as public properties
348
+ if (this.inboundStreams.length === 1) {
349
+ this.rawInboundStream = stream;
350
+ this.inboundStream = iterable;
351
+ }
209
352
  this.dispatchEvent(new CustomEvent("stream:inbound"));
210
- return this.inboundStream;
353
+ return record;
354
+ }
355
+ _scheduleInboundPrune() {
356
+ if (this._inboundPruneTimer)
357
+ return; // already scheduled
358
+ this._inboundPruneTimer = setTimeout(() => {
359
+ this._inboundPruneTimer = undefined;
360
+ this._pruneInboundInactive();
361
+ if (this.inboundStreams.length > 1) {
362
+ // schedule again if still multiple
363
+ this._scheduleInboundPrune();
364
+ }
365
+ }, PeerStreams.INBOUND_IDLE_MS);
366
+ }
367
+ _pruneInboundInactive() {
368
+ if (this.inboundStreams.length <= 1)
369
+ return;
370
+ const now = Date.now();
371
+ // Keep at least one (the most recently active)
372
+ this.inboundStreams.sort((a, b) => b.lastActivity - a.lastActivity);
373
+ const keep = this.inboundStreams[0];
374
+ const survivors = [keep];
375
+ for (let i = 1; i < this.inboundStreams.length; i++) {
376
+ const candidate = this.inboundStreams[i];
377
+ if (now - candidate.lastActivity <= PeerStreams.INBOUND_IDLE_MS) {
378
+ survivors.push(candidate);
379
+ continue; // still active
380
+ }
381
+ try {
382
+ candidate.abortController.abort();
383
+ }
384
+ catch { }
385
+ try {
386
+ candidate.raw.close?.();
387
+ }
388
+ catch { }
389
+ }
390
+ this.inboundStreams = survivors;
391
+ // update legacy references if they were pruned
392
+ if (!this.inboundStreams.includes(keep)) {
393
+ this.rawInboundStream = this.inboundStreams[0]?.raw;
394
+ this.inboundStream = this.inboundStreams[0]?.iterable;
395
+ }
396
+ }
397
+ forcePruneInbound() {
398
+ if (this._inboundPruneTimer) {
399
+ clearTimeout(this._inboundPruneTimer);
400
+ this._inboundPruneTimer = undefined;
401
+ }
402
+ this._pruneInboundInactive();
211
403
  }
212
404
  /**
213
405
  * Attach a raw outbound stream and setup a write stream
214
406
  */
215
407
  async attachOutboundStream(stream) {
216
- // If an outbound stream already exists and is active, ignore the new one; if a raw stream exists, close it to avoid leaks
217
- if (this.outboundStream != null &&
218
- stream.id !== this.rawOutboundStream?.id) {
219
- logger.info(`Outbound stream already exists for ${this.peerId.toString()}, ignoring additional stream`);
220
- try {
221
- await stream.abort?.(new AbortError("Superseded outbound stream"));
222
- }
223
- catch { }
408
+ if (this.outboundStreams[0] && stream.id === this.outboundStreams[0].raw.id)
409
+ return; // duplicate
410
+ this._addOutboundCandidate(stream);
411
+ if (this.outboundStreams.length === 1) {
412
+ this.dispatchEvent(new CustomEvent("stream:outbound"));
224
413
  return;
225
414
  }
226
- this.rawOutboundStream = stream;
227
- this.outboundStream = pushableLanes({ lanes: 2 });
228
- this.outboundAbortController.signal.addEventListener("abort", () => {
229
- this.rawOutboundStream?.close().catch((err) => {
230
- this.rawOutboundStream?.abort(err);
231
- });
232
- });
233
- pipe(this.outboundStream, (source) => lp.encode(source, { maxDataLength: MAX_DATA_LENGTH_OUT }), this.rawOutboundStream).catch(logError);
234
- // Emit if the connection is new
235
- this.dispatchEvent(new CustomEvent("stream:outbound"));
415
+ this._scheduleOutboundPrune(true);
416
+ }
417
+ pruneOutboundCandidates() {
418
+ try {
419
+ const candidates = this.outboundStreams;
420
+ if (!candidates.length)
421
+ return;
422
+ const now = Date.now();
423
+ const healthy = candidates.filter((c) => !c.aborted && c.bytesDelivered > 0);
424
+ let chosen;
425
+ if (healthy.length === 0) {
426
+ chosen = candidates.reduce((a, b) => b.created > a.created ? b : a);
427
+ }
428
+ else {
429
+ let bestScore = -Infinity;
430
+ for (const c of healthy) {
431
+ const age = now - c.created || 1;
432
+ const score = c.bytesDelivered / age;
433
+ if (score > bestScore ||
434
+ (score === bestScore && chosen && c.created > chosen.created)) {
435
+ bestScore = score;
436
+ chosen = c;
437
+ }
438
+ }
439
+ }
440
+ if (!chosen)
441
+ return;
442
+ for (const c of candidates) {
443
+ if (c === chosen)
444
+ continue; // never abort chosen
445
+ try {
446
+ c.raw.abort?.(new AbortError("Replaced outbound stream"));
447
+ }
448
+ catch {
449
+ logger.error("Failed to abort outbound stream");
450
+ }
451
+ try {
452
+ c.pushable.return?.();
453
+ }
454
+ catch {
455
+ logger.error("Failed to close outbound pushable");
456
+ }
457
+ try {
458
+ c.raw.close?.();
459
+ }
460
+ catch {
461
+ logger.error("Failed to close outbound stream");
462
+ }
463
+ }
464
+ this.outboundStreams = [chosen];
465
+ }
466
+ catch (e) {
467
+ logger.error("Error promoting outbound candidate: " + e?.message);
468
+ }
469
+ finally {
470
+ this.dispatchEvent(new CustomEvent("stream:outbound"));
471
+ }
472
+ }
473
+ forcePruneOutbound() {
474
+ if (this._outboundPruneTimer) {
475
+ clearTimeout(this._outboundPruneTimer);
476
+ this._outboundPruneTimer = undefined;
477
+ }
478
+ this.pruneOutboundCandidates();
479
+ }
480
+ /**
481
+ * Internal helper to perform the actual outbound replacement & piping.
482
+ */
483
+ // _replaceOutboundStream removed (legacy path)
484
+ // Debug/testing helper: list active outbound raw stream ids
485
+ _debugActiveOutboundIds() {
486
+ if (this.outboundStreams.length) {
487
+ return this.outboundStreams.map((c) => c.raw.id);
488
+ }
489
+ return this.outboundStreams.map((c) => c.raw.id);
490
+ }
491
+ _debugOutboundStats() {
492
+ if (this.outboundStreams.length) {
493
+ return this.outboundStreams.map((c) => ({
494
+ id: c.raw.id,
495
+ bytes: c.bytesDelivered,
496
+ aborted: !!c.aborted,
497
+ }));
498
+ }
499
+ return this.outboundStreams.map((c) => ({
500
+ id: c.raw.id,
501
+ bytes: c.bytesDelivered,
502
+ aborted: !!c.aborted,
503
+ }));
236
504
  }
237
505
  /**
238
506
  * Closes the open connection to peer
@@ -242,23 +510,47 @@ export class PeerStreams extends TypedEventEmitter {
242
510
  return;
243
511
  }
244
512
  this.closed = true;
513
+ if (this._outboundPruneTimer) {
514
+ clearTimeout(this._outboundPruneTimer);
515
+ this._outboundPruneTimer = undefined;
516
+ }
245
517
  // End the outbound stream
246
- if (this.outboundStream != null) {
247
- await this.outboundStream.return();
248
- this.rawOutboundStream?.abort(new AbortError("Closed"));
518
+ if (this.outboundStreams.length) {
519
+ for (const c of this.outboundStreams) {
520
+ try {
521
+ await c.pushable.return?.();
522
+ }
523
+ catch { }
524
+ try {
525
+ c.raw.abort?.(new AbortError("Closed"));
526
+ }
527
+ catch { }
528
+ }
249
529
  this.outboundAbortController.abort();
250
530
  }
251
- // End the inbound stream
252
- if (this.inboundStream != null) {
253
- this.inboundAbortController.abort();
254
- await this.rawInboundStream?.close();
531
+ // End inbound streams
532
+ if (this.inboundStreams.length) {
533
+ for (const inbound of this.inboundStreams) {
534
+ try {
535
+ inbound.abortController.abort();
536
+ }
537
+ catch {
538
+ logger.error("Failed to abort inbound stream");
539
+ }
540
+ try {
541
+ await inbound.raw.close?.();
542
+ }
543
+ catch {
544
+ logger.error("Failed to close inbound stream");
545
+ }
546
+ }
255
547
  }
256
548
  this.usedBandWidthTracker.stop();
257
549
  this.dispatchEvent(new CustomEvent("close"));
258
- this.rawOutboundStream = undefined;
259
- this.outboundStream = undefined;
550
+ this.outboundStreams = [];
260
551
  this.rawInboundStream = undefined;
261
552
  this.inboundStream = undefined;
553
+ this.inboundStreams = [];
262
554
  }
263
555
  }
264
556
  export class DirectStream extends TypedEventEmitter {
@@ -304,12 +596,16 @@ export class DirectStream extends TypedEventEmitter {
304
596
  constructor(components, multicodecs, options) {
305
597
  super();
306
598
  this.components = components;
307
- const { canRelayMessage = true, messageProcessingConcurrency = 10, maxInboundStreams, maxOutboundStreams, connectionManager, routeSeekInterval = ROUTE_UPDATE_DELAY_FACTOR, seekTimeout = SEEK_DELIVERY_TIMEOUT, routeMaxRetentionPeriod = ROUTE_MAX_RETANTION_PERIOD, } = options || {};
599
+ const { canRelayMessage = true, messageProcessingConcurrency = 10, maxInboundStreams, maxOutboundStreams, connectionManager, routeSeekInterval = ROUTE_UPDATE_DELAY_FACTOR, seekTimeout = SEEK_DELIVERY_TIMEOUT, routeMaxRetentionPeriod = ROUTE_MAX_RETANTION_PERIOD, inboundIdleTimeout, } = options || {};
308
600
  const signKey = getKeypairFromPrivateKey(components.privateKey);
309
601
  this.seekTimeout = seekTimeout;
310
602
  this.sign = signKey.sign.bind(signKey);
311
603
  this.peerId = components.peerId;
312
604
  this.publicKey = signKey.publicKey;
605
+ if (inboundIdleTimeout != null)
606
+ PeerStreams.INBOUND_IDLE_MS = inboundIdleTimeout;
607
+ if (maxInboundStreams != null)
608
+ PeerStreams.MAX_INBOUND_STREAMS = maxInboundStreams;
313
609
  this.publicKeyHash = signKey.publicKey.hashcode();
314
610
  this.multicodecs = multicodecs;
315
611
  this.started = false;
@@ -511,8 +807,8 @@ export class DirectStream extends TypedEventEmitter {
511
807
  }
512
808
  const peer = this.addPeer(peerId, publicKey, stream.protocol, connection.id);
513
809
  // handle inbound
514
- const inboundStream = peer.attachInboundStream(stream);
515
- this.processMessages(peer.publicKey, inboundStream, peer).catch(logError);
810
+ const inboundRecord = peer.attachInboundStream(stream);
811
+ this.processMessages(peer.publicKey, inboundRecord, peer).catch(logError);
516
812
  // try to create outbound stream
517
813
  await this.outboundInflightQueue.push({ peerId, connection });
518
814
  }
@@ -736,15 +1032,14 @@ export class DirectStream extends TypedEventEmitter {
736
1032
  /**
737
1033
  * Responsible for processing each RPC message received by other peers.
738
1034
  */
739
- async processMessages(peerId, stream, peerStreams) {
1035
+ async processMessages(peerId, record, peerStreams) {
740
1036
  try {
741
- await pipe(stream, async (source) => {
742
- for await (const data of source) {
743
- this.processRpc(peerId, peerStreams, data).catch((e) => {
744
- logError(e);
745
- });
746
- }
747
- });
1037
+ for await (const data of record.iterable) {
1038
+ const now = Date.now();
1039
+ record.lastActivity = now;
1040
+ record.bytesReceived += data.length || data.byteLength || 0;
1041
+ this.processRpc(peerId, peerStreams, data).catch((e) => logError(e));
1042
+ }
748
1043
  }
749
1044
  catch (err) {
750
1045
  if (err?.code === "ERR_STREAM_RESET") {
@@ -1555,9 +1850,8 @@ export class DirectStream extends TypedEventEmitter {
1555
1850
  }
1556
1851
  getQueuedBytes() {
1557
1852
  let sum = 0;
1558
- for (const peer of this.peers) {
1559
- const out = peer[1].outboundStream;
1560
- sum += out ? out.readableLength : 0;
1853
+ for (const [_k, ps] of this.peers) {
1854
+ sum += measureOutboundQueuedBytes(ps); // cast to access hook
1561
1855
  }
1562
1856
  return sum;
1563
1857
  }