@mcpwarp/ws-mixer 0.6.0 → 0.7.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/index.js CHANGED
@@ -66,7 +66,7 @@ var WsMixerError = class extends Error {
66
66
  /** The peer's observed WS close-frame reason, verbatim, under the same condition as `wsCode`. */
67
67
  closeReason;
68
68
  constructor(code, message, opts) {
69
- super(message);
69
+ super(message, opts?.cause !== void 0 ? { cause: opts.cause } : void 0);
70
70
  this.code = code;
71
71
  this.fatal = opts?.fatal ?? false;
72
72
  this.streamId = opts?.streamId;
@@ -523,7 +523,7 @@ var MixerStream = class extends Duplex {
523
523
  /** Bytes queued via _write, waiting on credit; drives backpressure. */
524
524
  writeQueue = [];
525
525
  draining = false;
526
- /** Write callbacks (_write's own terminal-error branch, or drainWriteQueue's catch) whose error must wait until the read side has finished delivering buffered data and emitted 'end' -- see terminateNoThrow's doc comment. Non-null exactly while that wait is pending; flushed (and reset to null) from _destroy(), or from the 'end' listener drainWriteQueue's catch installs. */
526
+ /** Write callbacks (_write's own terminal-error branch, or failWrite()) whose error must wait until the read side has finished delivering buffered data and emitted 'end' -- see terminateNoThrow's doc comment. Non-null exactly while that wait is pending; flushed (and reset to null) from _destroy(), or from the 'end' listener failWrite() installs. */
527
527
  heldWriteCallbacks = null;
528
528
  /** Set once this stream is RESET -- by the peer (handleReset), the SDK (abort()), or the app itself (reset()); undefined until then. Set even when no `'error'` listener is attached. */
529
529
  resetCode;
@@ -557,7 +557,7 @@ var MixerStream = class extends Duplex {
557
557
  return;
558
558
  }
559
559
  if (this.state !== "open" && this.state !== "half_closed_remote") {
560
- callback(new StreamError(ErrorCode.STREAM_CLOSED, this.id, "write on a stream that is not open for sending"));
560
+ this.failWrite(callback, new StreamError(ErrorCode.STREAM_CLOSED, this.id, "write on a stream that is not open for sending"));
561
561
  return;
562
562
  }
563
563
  this.writeQueue.push({ chunk, callback });
@@ -595,6 +595,8 @@ var MixerStream = class extends Duplex {
595
595
  try {
596
596
  while (chunk.length > 0) {
597
597
  const n = await this.reserveSendCredit(chunk.length);
598
+ const { done, error } = this.sendDone();
599
+ if (done) throw error;
598
600
  const piece = chunk.subarray(0, n);
599
601
  chunk = chunk.subarray(n);
600
602
  await this.host.sendData(this.id, piece);
@@ -603,42 +605,66 @@ var MixerStream = class extends Duplex {
603
605
  callback();
604
606
  } catch (e) {
605
607
  this.writeQueue.shift();
606
- if (this.heldWriteCallbacks) {
607
- this.heldWriteCallbacks.push(callback);
608
- continue;
609
- }
610
- if (this.state === "half_closed_remote") {
611
- const failure = e;
612
- if (!this.hasErrorListener()) {
613
- this.on("error", NOOP_ERROR_LISTENER);
614
- }
615
- if (this.readableEnded) {
616
- callback(failure);
617
- continue;
618
- }
619
- this.heldWriteCallbacks = [(err) => callback(err ?? failure)];
620
- this.once(
621
- "end",
622
- () => process.nextTick(() => {
623
- if (this.destroyed || !this.heldWriteCallbacks) return;
624
- if (this.state === "closed") {
625
- this.destroy();
626
- return;
627
- }
628
- const held = this.heldWriteCallbacks;
629
- this.heldWriteCallbacks = null;
630
- for (const cb of held) cb(this.terminalError ?? void 0);
631
- })
632
- );
633
- continue;
634
- }
635
- if (!this.hasErrorListener()) {
636
- this.on("error", NOOP_ERROR_LISTENER);
637
- }
638
- callback(e);
608
+ this.failWrite(callback, this.terminalError ?? e);
639
609
  }
640
610
  }
641
611
  }
612
+ /**
613
+ * Settles a failed write's callback with `failure` -- now, or held until
614
+ * the read side can no longer be hurt by it. Calling a write callback with
615
+ * an error marks the READABLE side errored too (Node's own Writable/Duplex
616
+ * machinery), which drops whatever is still buffered and blocks 'end'.
617
+ *
618
+ * - terminateNoThrow is already holding write errors for the read side
619
+ * (`heldWriteCallbacks` non-null): join that hold -- see its doc comment.
620
+ * - The peer's CLOSE has arrived and 'end' hasn't fired yet, whatever this
621
+ * side's own write state (`half_closed_remote`, or `closed` after
622
+ * closeWrite()): the read side may still be delivering buffered data, so
623
+ * hold the callback until 'end', without relying on teardown to flush it
624
+ * -- there may be no teardown at all. A connection teardown that does
625
+ * come (it also reaches a stream closeWrite() retired) flushes it once
626
+ * nothing is left unread; so do destroy()/reset(). `failure` is the
627
+ * fallback for a flush with no terminalError of its own (always, on a
628
+ * stream CLOSE'd both ways -- see terminateNoThrow).
629
+ * - Otherwise (peer still sending, 'end' already fired or never will --
630
+ * destroyed, or already errored): fail it now.
631
+ *
632
+ * Node's Writable machinery re-emits a write callback's error as 'error',
633
+ * so -- same rationale as terminateNoThrow() -- the internal no-op listener
634
+ * is attached first if nothing else is listening: a failure is also
635
+ * reachable before terminateNoThrow() has run at all (the connection's
636
+ * writer failed this chunk's socket write, and teardown follows from the
637
+ * socket's own 'close'), and a dying connection must not crash a consumer
638
+ * that never attached its own 'error'.
639
+ */
640
+ failWrite(callback, failure) {
641
+ if (this.heldWriteCallbacks) {
642
+ this.heldWriteCallbacks.push((err) => callback(err ?? failure));
643
+ return;
644
+ }
645
+ if (!this.hasErrorListener()) {
646
+ this.on("error", NOOP_ERROR_LISTENER);
647
+ }
648
+ if (!this.remoteClosed || this.readableEnded || this.destroyed || this.errored) {
649
+ callback(failure);
650
+ return;
651
+ }
652
+ this.heldWriteCallbacks = [(err) => callback(err ?? failure)];
653
+ this.host.pinRetired?.(this.id);
654
+ this.once(
655
+ "end",
656
+ () => process.nextTick(() => {
657
+ if (this.destroyed || !this.heldWriteCallbacks) return;
658
+ if (this.state === "closed") {
659
+ this.destroy();
660
+ return;
661
+ }
662
+ const held = this.heldWriteCallbacks;
663
+ this.heldWriteCallbacks = null;
664
+ for (const cb of held) cb(this.terminalError ?? void 0);
665
+ })
666
+ );
667
+ }
642
668
  /** Waits until send credit is available, then reserves and returns min(want, sendWindow, MAX_CHUNK). */
643
669
  reserveSendCredit(want) {
644
670
  return new Promise((resolve, reject) => {
@@ -751,12 +777,14 @@ var MixerStream = class extends Duplex {
751
777
  * @internal
752
778
  */
753
779
  handleClose() {
780
+ let closedNow = false;
754
781
  switch (this.state) {
755
782
  case "open":
756
783
  this.state = "half_closed_remote";
757
784
  break;
758
785
  case "half_closed_local":
759
786
  this.state = "closed";
787
+ closedNow = true;
760
788
  this.host.retireStream(this.id);
761
789
  break;
762
790
  case "half_closed_remote":
@@ -766,6 +794,42 @@ var MixerStream = class extends Duplex {
766
794
  }
767
795
  this.remoteClosed = true;
768
796
  this.maybeDeliverEOF();
797
+ if (closedNow) this.destroyWhenDone();
798
+ }
799
+ /**
800
+ * Both directions CLOSE'd cleanly (closeWrite() + the peer's CLOSE, in
801
+ * either order): the stream is retired, so only a connection teardown
802
+ * (which may never come) would otherwise reach it, and 'close' needs a
803
+ * destroy() on an `autoDestroy: false` Duplex. Mirrors
804
+ * what autoDestroy itself would do: destroy once the readable has emitted
805
+ * 'end' (a tick later, like failWrite()'s hold, so every 'end' listener
806
+ * runs first and 'end' always precedes 'close'), and, when the write side
807
+ * was closed by end(), once 'finish' has fired too -- destroying inside
808
+ * _final() would suppress 'finish' and turn finished()/pipeline() into a
809
+ * premature close. A readable that already errored never emits 'end', so
810
+ * that destroys right away. A consumer that never reads never sees 'end',
811
+ * and so never 'close' -- same as any other Readable.
812
+ */
813
+ destroyWhenDone() {
814
+ if (this.destroyed) return;
815
+ if (this.errored) {
816
+ this.destroy();
817
+ return;
818
+ }
819
+ if (this.writableEnded && !this.writableFinished) {
820
+ this.once("finish", () => this.destroyWhenDone());
821
+ return;
822
+ }
823
+ if (this.readableEnded) {
824
+ this.destroy();
825
+ return;
826
+ }
827
+ this.once(
828
+ "end",
829
+ () => process.nextTick(() => {
830
+ if (!this.destroyed) this.destroy();
831
+ })
832
+ );
769
833
  }
770
834
  // CLOSE preserves buffered data; EOF (push(null)) only follows once every
771
835
  // buffered byte has been delivered to the application (WIRE.md section
@@ -778,7 +842,7 @@ var MixerStream = class extends Duplex {
778
842
  this.push(null);
779
843
  }
780
844
  }
781
- /** True once something is listening for `'error'`: gates attaching the internal no-op `'error'` listener (terminateNoThrow, drainWriteQueue). */
845
+ /** True once something is listening for `'error'`: gates attaching the internal no-op `'error'` listener (terminateNoThrow, failWrite). */
782
846
  hasErrorListener() {
783
847
  return this.listenerCount("error") > 0;
784
848
  }
@@ -865,8 +929,8 @@ var MixerStream = class extends Duplex {
865
929
  * too, permanently blocking 'end' regardless of whether an 'error'
866
930
  * listener is attached. So instead of calling those callbacks here,
867
931
  * `heldWriteCallbacks` (non-null for exactly this stretch) collects them
868
- * -- pushed onto by `drainWriteQueue`'s catch and by `_write`'s
869
- * terminal-error branch -- and `_destroy()` flushes them with `err`
932
+ * -- pushed onto by failWrite() and by `_write`'s terminal-error
933
+ * branch -- and `_destroy()` flushes them with `err`
870
934
  * right after `destroy()` actually runs (whether that's the 'end'-
871
935
  * triggered `destroy()` below, or the read side never gets there because
872
936
  * something else destroys the stream first): by then the readable side
@@ -893,9 +957,13 @@ var MixerStream = class extends Duplex {
893
957
  * loop, so no separate queue-flushing step is needed here.
894
958
  *
895
959
  * A stream already fully, cleanly closed (both directions CLOSE'd) has
896
- * already called `retireStream()` and is no longer tracked by MixerConn,
897
- * so `rejectOutstanding` never reaches it here at all -- only a stream
898
- * still genuinely open, or half-closed one way, ever is.
960
+ * already called `retireStream()`, but MixerConn keeps it for
961
+ * `rejectOutstanding` until it is destroyed: it lands in the branch above
962
+ * (`remoteClosed`), so its read side still ends cleanly, and 'close' fires
963
+ * once nothing is left unread. Its exchange already completed, so `err`
964
+ * is not adopted (no `terminalError`, no `resetCode`): a write callback it
965
+ * holds keeps its own failure (STREAM_CLOSED, or its socket send's error).
966
+ * Without a teardown, destroyWhenDone() is what ends it.
899
967
  *
900
968
  * `resetCode` is set here too, but only when `err` is a `StreamError`
901
969
  * (mirrors handleReset()/applyReset(), which construct one for exactly
@@ -907,15 +975,18 @@ var MixerStream = class extends Duplex {
907
975
  * otherwise wrongly discard a response that had already completed on
908
976
  * every drain hand-over. From the stream's own perspective a `StreamError`
909
977
  * IS a fourth way it ends in RESET, on top of the three the class doc
910
- * above lists, distinct from an ordinary `WsMixerError`/`ConnError`
911
- * connection failure, which is not RESET-shaped and leaves `resetCode`
912
- * unset.
978
+ * above lists, distinct from an ordinary connection failure (always a
979
+ * `ConnError` by the time rejectOutstanding() hands it here), which is not
980
+ * RESET-shaped and leaves `resetCode` unset.
913
981
  * @internal
914
982
  */
915
983
  terminateNoThrow(err, opts) {
984
+ const closedCleanly = this.state === "closed" && !this.terminalError;
916
985
  this.state = "closed";
917
- this.terminalError = err;
918
- if (err instanceof StreamError) this.resetCode = err.code;
986
+ if (!closedCleanly) {
987
+ this.terminalError = err;
988
+ if (err instanceof StreamError) this.resetCode = err.code;
989
+ }
919
990
  const deferWriteErrors = !opts?.discardBuffered && this.remoteClosed;
920
991
  if (deferWriteErrors) this.heldWriteCallbacks = this.heldWriteCallbacks ?? [];
921
992
  this.wakeSendWaiters();
@@ -948,8 +1019,21 @@ var MixerStream = class extends Duplex {
948
1019
  }
949
1020
  return { done: false };
950
1021
  }
1022
+ /**
1023
+ * True while a write callback is held back for the read side (see failWrite()).
1024
+ * @internal
1025
+ */
1026
+ hasHeldWriteCallbacks() {
1027
+ return this.heldWriteCallbacks !== null && this.heldWriteCallbacks.length > 0;
1028
+ }
951
1029
  // --- public half-close / reset API -----------------------------------------
952
- /** Sends CLOSE: "I will send no more DATA on this stream." end() is sugar for this via _final. */
1030
+ /**
1031
+ * Sends CLOSE: "I will send no more DATA on this stream." end() is sugar
1032
+ * for this via _final. A write still waiting on send credit can never go
1033
+ * out now, so it is woken to fail with STREAM_CLOSED (reserveSendCredit)
1034
+ * instead of waiting on a WINDOW that, once this stream is retired, would
1035
+ * never reach it.
1036
+ */
953
1037
  closeWrite() {
954
1038
  switch (this.state) {
955
1039
  case "open":
@@ -964,13 +1048,17 @@ var MixerStream = class extends Duplex {
964
1048
  }
965
1049
  const closedNow = this.state === "closed";
966
1050
  this.host.sendControlFrame(encodeClose(this.id));
967
- if (closedNow) this.host.retireStream(this.id);
1051
+ this.wakeSendWaiters();
1052
+ if (closedNow) {
1053
+ this.host.retireStream(this.id);
1054
+ this.destroyWhenDone();
1055
+ }
968
1056
  }
969
1057
  /**
970
1058
  * Sends CLOSE (if not already sent) and stops delivering further reads. If
971
1059
  * the peer has not yet half-closed its own send side, a plain closeWrite()
972
1060
  * would leave it writing into a window nobody drains, so close() also
973
- * RESETs with CANCEL in that case (mirrors go/wsmixer/stream.go Close()).
1061
+ * RESETs with CANCEL in that case (mirrors ws-mixer-go/wsmixer/stream.go Close()).
974
1062
  */
975
1063
  close() {
976
1064
  if (!this.remoteClosed) {
@@ -1009,7 +1097,8 @@ var MixerStream = class extends Duplex {
1009
1097
  * MixerConn's dispatch loop). Identical effect to reset(), but also emits
1010
1098
  * `'reset'` the same way handleReset() does for a peer-sent RESET: in both
1011
1099
  * cases the stream's owner didn't choose this, so it needs to hear about
1012
- * it. No-ops (and does not emit) if the stream is already closed.
1100
+ * it. Sends nothing and does not emit if the stream is already closed
1101
+ * (applyReset then only finishes a pending local destroy()).
1013
1102
  * @internal
1014
1103
  */
1015
1104
  abort(code, message = "") {
@@ -1017,10 +1106,10 @@ var MixerStream = class extends Duplex {
1017
1106
  this.emit("reset", { code: this.resetCode, message: this.terminalError.message });
1018
1107
  }
1019
1108
  }
1020
- /** Shared implementation behind reset()/abort(); returns false (no-op) if the stream was already closed. */
1109
+ /** Shared implementation behind reset()/abort(); returns false (nothing sent) if the stream was already closed. */
1021
1110
  applyReset(code, message) {
1022
1111
  if (this.state === "closed") {
1023
- if (this.heldWriteCallbacks && !this.destroyed) this.destroy();
1112
+ if (!this.destroyed) this.destroy();
1024
1113
  return false;
1025
1114
  }
1026
1115
  const numericCode = typeof code === "string" ? parseErrorCode(code) : code;
@@ -1058,6 +1147,23 @@ var DELIVERY_QUEUE_MARGIN = 64;
1058
1147
  function wireCloseCode(wsCode) {
1059
1148
  return wsCode === 1e3 || wsCode >= 4e3 && wsCode <= 4999 ? wsCode : 4e3 + ErrorCode.INTERNAL_ERROR;
1060
1149
  }
1150
+ var MAX_RESET_MESSAGE = 256;
1151
+ function asConnError(err) {
1152
+ if (err instanceof ConnError) return err;
1153
+ if (err instanceof WsMixerError) {
1154
+ return new ConnError(err.code, err.message, {
1155
+ streamId: err.streamId,
1156
+ fatal: err.fatal,
1157
+ wsCode: err.wsCode,
1158
+ closeReason: err.closeReason,
1159
+ cause: err
1160
+ });
1161
+ }
1162
+ return new ConnError(ErrorCode.INTERNAL_ERROR, err.message, { cause: err });
1163
+ }
1164
+ function retiredStreamError(streamId) {
1165
+ return new StreamError(ErrorCode.STREAM_CLOSED, streamId, "DATA not sent: stream already closed");
1166
+ }
1061
1167
  var TokenBucket = class {
1062
1168
  constructor(capacity, ratePerSecond) {
1063
1169
  this.capacity = capacity;
@@ -1094,6 +1200,23 @@ var MixerConn = class extends EventEmitter {
1094
1200
  /** The `last_stream_id` from the most recently received `drain`; set only once draining. */
1095
1201
  lastStreamId;
1096
1202
  streams = /* @__PURE__ */ new Map();
1203
+ /**
1204
+ * Retired (out of `streams`: no frame or DATA is ever routed to them again)
1205
+ * but not yet destroyed -- in practice a stream CLOSE'd both ways, waiting
1206
+ * on its reader's 'end'. Tracked only so rejectOutstanding() still tears
1207
+ * them down; each leaves on its own 'close', and teardown clears the rest.
1208
+ * Weak by default: a stream never read never emits 'end', so never
1209
+ * 'close', and a strong ref would pin every such stream the app abandoned
1210
+ * for the connection's lifetime. Strong while the stream holds a write
1211
+ * callback (retireStream(), or pinRetired() when one is held after
1212
+ * retirement): the stream owns that callback, not the other way round, and
1213
+ * the callback is what the app is waiting on -- collecting the stream
1214
+ * would leave it unsettled forever. A retired stream's held callbacks are
1215
+ * only ever flushed by destroy(), so a strong entry leaves on 'close' and
1216
+ * is never unpinned.
1217
+ * Dead weak refs are pruned whenever the map is touched.
1218
+ */
1219
+ retired = /* @__PURE__ */ new Map();
1097
1220
  highestOpened = 0;
1098
1221
  /**
1099
1222
  * Minimal "ignore and count" counters (item 11 of the review: full
@@ -1135,13 +1258,14 @@ var MixerConn = class extends EventEmitter {
1135
1258
  /**
1136
1259
  * The `StreamHost` a `MixerStream` actually talks to: a plain object of
1137
1260
  * bound closures, not `this` -- so `sendData`/`sendControlFrame`/
1138
- * `retireStream` stay private implementation details of MixerConn instead
1139
- * of leaking onto its public (and `.d.ts`) surface (item 8).
1261
+ * `retireStream`/`pinRetired` stay private implementation details of
1262
+ * MixerConn instead of leaking onto its public (and `.d.ts`) surface (item 8).
1140
1263
  */
1141
1264
  #streamHost = {
1142
1265
  sendData: (streamId, chunk) => this.sendData(streamId, chunk),
1143
1266
  sendControlFrame: (frame) => this.sendControlFrame(frame),
1144
- retireStream: (streamId) => this.retireStream(streamId)
1267
+ retireStream: (streamId) => this.retireStream(streamId),
1268
+ pinRetired: (streamId) => this.pinRetired(streamId)
1145
1269
  };
1146
1270
  // --- write scheduler state ---
1147
1271
  controlQueue = [];
@@ -1156,11 +1280,11 @@ var MixerConn = class extends EventEmitter {
1156
1280
  // order, off one queue, so a slow handler cannot stall frame parsing. ---
1157
1281
  deliveryQueue = [];
1158
1282
  deliveryRunning = false;
1159
- // --- stream-0 flood limit (item 4), mirrors go/wsmixer's Conn.stream0Bucket ---
1283
+ // --- stream-0 flood limit (item 4), mirrors ws-mixer-go/wsmixer's Conn.stream0Bucket ---
1160
1284
  stream0Bucket = new TokenBucket(STREAM0_BURST, STREAM0_RATE_PER_SEC);
1161
1285
  // --- keepalive state ---
1162
1286
  nextPingId = 0;
1163
- /** Watermark: every id below this has been acked (or pruned as stale) at least once. Mirrors go/wsmixer's Conn.lowestUnacked. */
1287
+ /** Watermark: every id below this has been acked (or pruned as stale) at least once. Mirrors ws-mixer-go/wsmixer's Conn.lowestUnacked. */
1164
1288
  lowestUnacked = 0;
1165
1289
  lastPongAt = 0;
1166
1290
  outstandingPings = /* @__PURE__ */ new Map();
@@ -1334,7 +1458,7 @@ var MixerConn = class extends EventEmitter {
1334
1458
  stream.handleClose();
1335
1459
  break;
1336
1460
  case FrameType.RESET:
1337
- stream.handleReset(resetCode(frame), resetMessage(frame));
1461
+ stream.handleReset(resetCode(frame), truncateUtf8(resetMessage(frame), MAX_RESET_MESSAGE));
1338
1462
  break;
1339
1463
  }
1340
1464
  }
@@ -1387,7 +1511,7 @@ var MixerConn = class extends EventEmitter {
1387
1511
  // --- ordered async delivery (item 2) -------------------------------------
1388
1512
  /**
1389
1513
  * Queues one `'stream'`/`'app'`/`'drain'` event for in-order, async
1390
- * delivery, mirroring go/wsmixer's deliveryLoop: the read/dispatch path
1514
+ * delivery, mirroring ws-mixer-go/wsmixer's deliveryLoop: the read/dispatch path
1391
1515
  * above never blocks on application code, but all three still fire in
1392
1516
  * wire order, off a single loop, one at a time. Handlers registered via
1393
1517
  * `on('stream'|'app'|'drain', ...)` must not block for long -- a handler
@@ -1459,9 +1583,13 @@ var MixerConn = class extends EventEmitter {
1459
1583
  * caught here, counted (stats().handlerErrors) and surfaced via a guarded
1460
1584
  * `'handlerError'` emit -- never an unhandled rejection, and never fatal
1461
1585
  * to delivery: the next queued stream/app/drain event still runs.
1586
+ *
1587
+ * Iterates rawListeners(), not listeners(): for a once() registration that
1588
+ * is the self-removing wrapper, so it detaches on first delivery (it
1589
+ * returns and throws exactly what the wrapped listener does).
1462
1590
  */
1463
1591
  async emitOrdered(event, arg) {
1464
- for (const listener of this.listeners(event)) {
1592
+ for (const listener of this.rawListeners(event)) {
1465
1593
  try {
1466
1594
  const result = listener.call(this, arg);
1467
1595
  if (result instanceof Promise) await result;
@@ -1519,6 +1647,10 @@ var MixerConn = class extends EventEmitter {
1519
1647
  reject(new ConnError(ErrorCode.INTERNAL_ERROR, "connection closed"));
1520
1648
  return;
1521
1649
  }
1650
+ if (!this.streams.has(streamId)) {
1651
+ reject(retiredStreamError(streamId));
1652
+ return;
1653
+ }
1522
1654
  let q = this.outbox.get(streamId);
1523
1655
  if (!q) {
1524
1656
  q = [];
@@ -1528,12 +1660,47 @@ var MixerConn = class extends EventEmitter {
1528
1660
  this.markReady(streamId);
1529
1661
  });
1530
1662
  }
1531
- /** Backs `streamHost.retireStream`: retire a fully-closed stream's id. */
1663
+ /**
1664
+ * Backs `streamHost.retireStream`: retire a fully-closed stream's id. DATA
1665
+ * still queued for it is rejected, never dropped (a dropped item's write
1666
+ * callback would hang forever) -- with the stream's own sendDone() error
1667
+ * (its RESET's, or STREAM_CLOSED after its CLOSE). A stream not yet
1668
+ * destroyed moves to `retired` until its 'close' -- strongly if it holds
1669
+ * a write callback, else weakly (see `retired`).
1670
+ */
1532
1671
  retireStream(streamId) {
1672
+ const stream = this.streams.get(streamId);
1533
1673
  this.streams.delete(streamId);
1674
+ if (stream && !stream.destroyed) {
1675
+ this.pruneRetired();
1676
+ this.retired.set(streamId, stream.hasHeldWriteCallbacks() ? stream : new WeakRef(stream));
1677
+ stream.once("close", () => {
1678
+ this.retired.delete(streamId);
1679
+ this.pruneRetired();
1680
+ });
1681
+ }
1682
+ const q = this.outbox.get(streamId);
1534
1683
  this.outbox.delete(streamId);
1684
+ if (q) {
1685
+ const err = stream?.sendDone().error ?? retiredStreamError(streamId);
1686
+ for (const item of q) item.reject(err);
1687
+ }
1535
1688
  if (this.streams.size === 0) this.notifyIdle();
1536
1689
  }
1690
+ /** Backs `streamHost.pinRetired`: a retired stream now holding a write callback is held strongly until its 'close'. */
1691
+ pinRetired(streamId) {
1692
+ const entry = this.retired.get(streamId);
1693
+ if (entry instanceof WeakRef) {
1694
+ const stream = entry.deref();
1695
+ if (stream) this.retired.set(streamId, stream);
1696
+ }
1697
+ }
1698
+ /** Drops weak `retired` entries whose stream has been garbage-collected. */
1699
+ pruneRetired() {
1700
+ for (const [id, entry] of this.retired) {
1701
+ if (entry instanceof WeakRef && entry.deref() === void 0) this.retired.delete(id);
1702
+ }
1703
+ }
1537
1704
  /** Resolves every pending close()'s wait for the stream table to empty, instead of polling. */
1538
1705
  notifyIdle() {
1539
1706
  const waiters = this.idleWaiters;
@@ -1572,12 +1739,14 @@ var MixerConn = class extends EventEmitter {
1572
1739
  if (!q || q.length === 0) continue;
1573
1740
  const item = q.shift();
1574
1741
  const stream = this.streams.get(id);
1575
- if (stream) {
1576
- const { done, error } = stream.sendDone();
1577
- if (done) {
1578
- item.reject(error);
1579
- continue;
1580
- }
1742
+ if (!stream) {
1743
+ item.reject(retiredStreamError(id));
1744
+ continue;
1745
+ }
1746
+ const { done, error } = stream.sendDone();
1747
+ if (done) {
1748
+ item.reject(error);
1749
+ continue;
1581
1750
  }
1582
1751
  if (q.length > 0) this.markReady(id);
1583
1752
  return { streamId: id, item };
@@ -1627,20 +1796,20 @@ var MixerConn = class extends EventEmitter {
1627
1796
  writeRaw(frame) {
1628
1797
  return new Promise((resolve, reject) => {
1629
1798
  if (this.closed) {
1630
- reject(new Error("connection closed"));
1799
+ reject(new ConnError(ErrorCode.INTERNAL_ERROR, "connection closed"));
1631
1800
  return;
1632
1801
  }
1633
1802
  try {
1634
1803
  this.ws.send(frame, (err) => {
1635
1804
  if (err) {
1636
- reject(err);
1805
+ reject(asConnError(err));
1637
1806
  return;
1638
1807
  }
1639
1808
  this.counters.bytesOut += frame.length;
1640
1809
  resolve();
1641
1810
  });
1642
1811
  } catch (e) {
1643
- reject(e);
1812
+ reject(asConnError(e instanceof Error ? e : new Error(String(e))));
1644
1813
  }
1645
1814
  });
1646
1815
  }
@@ -1668,7 +1837,7 @@ var MixerConn = class extends EventEmitter {
1668
1837
  });
1669
1838
  }
1670
1839
  /**
1671
- * Pong watermark scheme, mirroring go/wsmixer/dispatch.go's handlePong:
1840
+ * Pong watermark scheme, mirroring ws-mixer-go/wsmixer/dispatch.go's handlePong:
1672
1841
  * every id below lowestUnacked has been acked (or pruned as stale) at
1673
1842
  * least once, and no id >= nextPingId has ever been sent.
1674
1843
  * - id >= nextPingId: never sent -> PROTOCOL_ERROR, connection-fatal.
@@ -1774,7 +1943,7 @@ var MixerConn = class extends EventEmitter {
1774
1943
  /**
1775
1944
  * The peer sent `error{code,message}`: record it and close with
1776
1945
  * `4000 + code` immediately, without waiting for the peer to do anything
1777
- * else -- mirrors go/wsmixer/dispatch.go's handlePeerError. `error` is
1946
+ * else -- mirrors ws-mixer-go/wsmixer/dispatch.go's handlePeerError. `error` is
1778
1947
  * always the last message on the wire (WIRE.md section 2.7), so
1779
1948
  * there is nothing left to negotiate.
1780
1949
  */
@@ -1889,19 +2058,26 @@ var MixerConn = class extends EventEmitter {
1889
2058
  this.jitterTimer = null;
1890
2059
  }
1891
2060
  rejectOutstanding(err, streamErrorFactory) {
1892
- for (const q of this.outbox.values()) {
1893
- for (const item of q) item.reject(err);
1894
- }
1895
- this.outbox.clear();
2061
+ const connErr2 = asConnError(err);
1896
2062
  for (const item of this.controlQueue.splice(0, this.controlQueue.length)) {
1897
- item.reject(err);
2063
+ item.reject(connErr2);
1898
2064
  }
1899
- this.wake();
1900
- for (const stream of this.streams.values()) {
1901
- stream.terminateNoThrow(
1902
- streamErrorFactory ? streamErrorFactory(stream.id) : err instanceof WsMixerError ? err : new ConnError(ErrorCode.INTERNAL_ERROR, err.message)
1903
- );
2065
+ const retired = [];
2066
+ for (const entry of this.retired.values()) {
2067
+ const stream = entry instanceof WeakRef ? entry.deref() : entry;
2068
+ if (stream) retired.push(stream);
1904
2069
  }
2070
+ for (const stream of [...this.streams.values(), ...retired]) {
2071
+ if (stream.destroyed) continue;
2072
+ stream.terminateNoThrow(streamErrorFactory ? streamErrorFactory(stream.id) : connErr2);
2073
+ }
2074
+ this.retired.clear();
2075
+ for (const [id, q] of this.outbox) {
2076
+ const streamErr = this.streams.get(id)?.sendDone().error ?? connErr2;
2077
+ for (const item of q) item.reject(streamErr);
2078
+ }
2079
+ this.outbox.clear();
2080
+ this.wake();
1905
2081
  }
1906
2082
  isDraining() {
1907
2083
  return this.draining;
@@ -1913,7 +2089,7 @@ var MixerConn = class extends EventEmitter {
1913
2089
 
1914
2090
  // src/client.ts
1915
2091
  var SUBPROTOCOL = "ws-mixer.v1";
1916
- var SDK_VERSION = "0.6.0";
2092
+ var SDK_VERSION = "0.7.0";
1917
2093
  async function resolveToken(token) {
1918
2094
  return typeof token === "function" ? await token() : token;
1919
2095
  }