@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.cjs CHANGED
@@ -125,7 +125,7 @@ var WsMixerError = class extends Error {
125
125
  /** The peer's observed WS close-frame reason, verbatim, under the same condition as `wsCode`. */
126
126
  closeReason;
127
127
  constructor(code, message, opts) {
128
- super(message);
128
+ super(message, opts?.cause !== void 0 ? { cause: opts.cause } : void 0);
129
129
  this.code = code;
130
130
  this.fatal = opts?.fatal ?? false;
131
131
  this.streamId = opts?.streamId;
@@ -582,7 +582,7 @@ var MixerStream = class extends import_node_stream.Duplex {
582
582
  /** Bytes queued via _write, waiting on credit; drives backpressure. */
583
583
  writeQueue = [];
584
584
  draining = false;
585
- /** 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. */
585
+ /** 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. */
586
586
  heldWriteCallbacks = null;
587
587
  /** 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. */
588
588
  resetCode;
@@ -616,7 +616,7 @@ var MixerStream = class extends import_node_stream.Duplex {
616
616
  return;
617
617
  }
618
618
  if (this.state !== "open" && this.state !== "half_closed_remote") {
619
- callback(new StreamError(ErrorCode.STREAM_CLOSED, this.id, "write on a stream that is not open for sending"));
619
+ this.failWrite(callback, new StreamError(ErrorCode.STREAM_CLOSED, this.id, "write on a stream that is not open for sending"));
620
620
  return;
621
621
  }
622
622
  this.writeQueue.push({ chunk, callback });
@@ -654,6 +654,8 @@ var MixerStream = class extends import_node_stream.Duplex {
654
654
  try {
655
655
  while (chunk.length > 0) {
656
656
  const n = await this.reserveSendCredit(chunk.length);
657
+ const { done, error } = this.sendDone();
658
+ if (done) throw error;
657
659
  const piece = chunk.subarray(0, n);
658
660
  chunk = chunk.subarray(n);
659
661
  await this.host.sendData(this.id, piece);
@@ -662,42 +664,66 @@ var MixerStream = class extends import_node_stream.Duplex {
662
664
  callback();
663
665
  } catch (e) {
664
666
  this.writeQueue.shift();
665
- if (this.heldWriteCallbacks) {
666
- this.heldWriteCallbacks.push(callback);
667
- continue;
668
- }
669
- if (this.state === "half_closed_remote") {
670
- const failure = e;
671
- if (!this.hasErrorListener()) {
672
- this.on("error", NOOP_ERROR_LISTENER);
673
- }
674
- if (this.readableEnded) {
675
- callback(failure);
676
- continue;
677
- }
678
- this.heldWriteCallbacks = [(err) => callback(err ?? failure)];
679
- this.once(
680
- "end",
681
- () => process.nextTick(() => {
682
- if (this.destroyed || !this.heldWriteCallbacks) return;
683
- if (this.state === "closed") {
684
- this.destroy();
685
- return;
686
- }
687
- const held = this.heldWriteCallbacks;
688
- this.heldWriteCallbacks = null;
689
- for (const cb of held) cb(this.terminalError ?? void 0);
690
- })
691
- );
692
- continue;
693
- }
694
- if (!this.hasErrorListener()) {
695
- this.on("error", NOOP_ERROR_LISTENER);
696
- }
697
- callback(e);
667
+ this.failWrite(callback, this.terminalError ?? e);
698
668
  }
699
669
  }
700
670
  }
671
+ /**
672
+ * Settles a failed write's callback with `failure` -- now, or held until
673
+ * the read side can no longer be hurt by it. Calling a write callback with
674
+ * an error marks the READABLE side errored too (Node's own Writable/Duplex
675
+ * machinery), which drops whatever is still buffered and blocks 'end'.
676
+ *
677
+ * - terminateNoThrow is already holding write errors for the read side
678
+ * (`heldWriteCallbacks` non-null): join that hold -- see its doc comment.
679
+ * - The peer's CLOSE has arrived and 'end' hasn't fired yet, whatever this
680
+ * side's own write state (`half_closed_remote`, or `closed` after
681
+ * closeWrite()): the read side may still be delivering buffered data, so
682
+ * hold the callback until 'end', without relying on teardown to flush it
683
+ * -- there may be no teardown at all. A connection teardown that does
684
+ * come (it also reaches a stream closeWrite() retired) flushes it once
685
+ * nothing is left unread; so do destroy()/reset(). `failure` is the
686
+ * fallback for a flush with no terminalError of its own (always, on a
687
+ * stream CLOSE'd both ways -- see terminateNoThrow).
688
+ * - Otherwise (peer still sending, 'end' already fired or never will --
689
+ * destroyed, or already errored): fail it now.
690
+ *
691
+ * Node's Writable machinery re-emits a write callback's error as 'error',
692
+ * so -- same rationale as terminateNoThrow() -- the internal no-op listener
693
+ * is attached first if nothing else is listening: a failure is also
694
+ * reachable before terminateNoThrow() has run at all (the connection's
695
+ * writer failed this chunk's socket write, and teardown follows from the
696
+ * socket's own 'close'), and a dying connection must not crash a consumer
697
+ * that never attached its own 'error'.
698
+ */
699
+ failWrite(callback, failure) {
700
+ if (this.heldWriteCallbacks) {
701
+ this.heldWriteCallbacks.push((err) => callback(err ?? failure));
702
+ return;
703
+ }
704
+ if (!this.hasErrorListener()) {
705
+ this.on("error", NOOP_ERROR_LISTENER);
706
+ }
707
+ if (!this.remoteClosed || this.readableEnded || this.destroyed || this.errored) {
708
+ callback(failure);
709
+ return;
710
+ }
711
+ this.heldWriteCallbacks = [(err) => callback(err ?? failure)];
712
+ this.host.pinRetired?.(this.id);
713
+ this.once(
714
+ "end",
715
+ () => process.nextTick(() => {
716
+ if (this.destroyed || !this.heldWriteCallbacks) return;
717
+ if (this.state === "closed") {
718
+ this.destroy();
719
+ return;
720
+ }
721
+ const held = this.heldWriteCallbacks;
722
+ this.heldWriteCallbacks = null;
723
+ for (const cb of held) cb(this.terminalError ?? void 0);
724
+ })
725
+ );
726
+ }
701
727
  /** Waits until send credit is available, then reserves and returns min(want, sendWindow, MAX_CHUNK). */
702
728
  reserveSendCredit(want) {
703
729
  return new Promise((resolve, reject) => {
@@ -810,12 +836,14 @@ var MixerStream = class extends import_node_stream.Duplex {
810
836
  * @internal
811
837
  */
812
838
  handleClose() {
839
+ let closedNow = false;
813
840
  switch (this.state) {
814
841
  case "open":
815
842
  this.state = "half_closed_remote";
816
843
  break;
817
844
  case "half_closed_local":
818
845
  this.state = "closed";
846
+ closedNow = true;
819
847
  this.host.retireStream(this.id);
820
848
  break;
821
849
  case "half_closed_remote":
@@ -825,6 +853,42 @@ var MixerStream = class extends import_node_stream.Duplex {
825
853
  }
826
854
  this.remoteClosed = true;
827
855
  this.maybeDeliverEOF();
856
+ if (closedNow) this.destroyWhenDone();
857
+ }
858
+ /**
859
+ * Both directions CLOSE'd cleanly (closeWrite() + the peer's CLOSE, in
860
+ * either order): the stream is retired, so only a connection teardown
861
+ * (which may never come) would otherwise reach it, and 'close' needs a
862
+ * destroy() on an `autoDestroy: false` Duplex. Mirrors
863
+ * what autoDestroy itself would do: destroy once the readable has emitted
864
+ * 'end' (a tick later, like failWrite()'s hold, so every 'end' listener
865
+ * runs first and 'end' always precedes 'close'), and, when the write side
866
+ * was closed by end(), once 'finish' has fired too -- destroying inside
867
+ * _final() would suppress 'finish' and turn finished()/pipeline() into a
868
+ * premature close. A readable that already errored never emits 'end', so
869
+ * that destroys right away. A consumer that never reads never sees 'end',
870
+ * and so never 'close' -- same as any other Readable.
871
+ */
872
+ destroyWhenDone() {
873
+ if (this.destroyed) return;
874
+ if (this.errored) {
875
+ this.destroy();
876
+ return;
877
+ }
878
+ if (this.writableEnded && !this.writableFinished) {
879
+ this.once("finish", () => this.destroyWhenDone());
880
+ return;
881
+ }
882
+ if (this.readableEnded) {
883
+ this.destroy();
884
+ return;
885
+ }
886
+ this.once(
887
+ "end",
888
+ () => process.nextTick(() => {
889
+ if (!this.destroyed) this.destroy();
890
+ })
891
+ );
828
892
  }
829
893
  // CLOSE preserves buffered data; EOF (push(null)) only follows once every
830
894
  // buffered byte has been delivered to the application (WIRE.md section
@@ -837,7 +901,7 @@ var MixerStream = class extends import_node_stream.Duplex {
837
901
  this.push(null);
838
902
  }
839
903
  }
840
- /** True once something is listening for `'error'`: gates attaching the internal no-op `'error'` listener (terminateNoThrow, drainWriteQueue). */
904
+ /** True once something is listening for `'error'`: gates attaching the internal no-op `'error'` listener (terminateNoThrow, failWrite). */
841
905
  hasErrorListener() {
842
906
  return this.listenerCount("error") > 0;
843
907
  }
@@ -924,8 +988,8 @@ var MixerStream = class extends import_node_stream.Duplex {
924
988
  * too, permanently blocking 'end' regardless of whether an 'error'
925
989
  * listener is attached. So instead of calling those callbacks here,
926
990
  * `heldWriteCallbacks` (non-null for exactly this stretch) collects them
927
- * -- pushed onto by `drainWriteQueue`'s catch and by `_write`'s
928
- * terminal-error branch -- and `_destroy()` flushes them with `err`
991
+ * -- pushed onto by failWrite() and by `_write`'s terminal-error
992
+ * branch -- and `_destroy()` flushes them with `err`
929
993
  * right after `destroy()` actually runs (whether that's the 'end'-
930
994
  * triggered `destroy()` below, or the read side never gets there because
931
995
  * something else destroys the stream first): by then the readable side
@@ -952,9 +1016,13 @@ var MixerStream = class extends import_node_stream.Duplex {
952
1016
  * loop, so no separate queue-flushing step is needed here.
953
1017
  *
954
1018
  * A stream already fully, cleanly closed (both directions CLOSE'd) has
955
- * already called `retireStream()` and is no longer tracked by MixerConn,
956
- * so `rejectOutstanding` never reaches it here at all -- only a stream
957
- * still genuinely open, or half-closed one way, ever is.
1019
+ * already called `retireStream()`, but MixerConn keeps it for
1020
+ * `rejectOutstanding` until it is destroyed: it lands in the branch above
1021
+ * (`remoteClosed`), so its read side still ends cleanly, and 'close' fires
1022
+ * once nothing is left unread. Its exchange already completed, so `err`
1023
+ * is not adopted (no `terminalError`, no `resetCode`): a write callback it
1024
+ * holds keeps its own failure (STREAM_CLOSED, or its socket send's error).
1025
+ * Without a teardown, destroyWhenDone() is what ends it.
958
1026
  *
959
1027
  * `resetCode` is set here too, but only when `err` is a `StreamError`
960
1028
  * (mirrors handleReset()/applyReset(), which construct one for exactly
@@ -966,15 +1034,18 @@ var MixerStream = class extends import_node_stream.Duplex {
966
1034
  * otherwise wrongly discard a response that had already completed on
967
1035
  * every drain hand-over. From the stream's own perspective a `StreamError`
968
1036
  * IS a fourth way it ends in RESET, on top of the three the class doc
969
- * above lists, distinct from an ordinary `WsMixerError`/`ConnError`
970
- * connection failure, which is not RESET-shaped and leaves `resetCode`
971
- * unset.
1037
+ * above lists, distinct from an ordinary connection failure (always a
1038
+ * `ConnError` by the time rejectOutstanding() hands it here), which is not
1039
+ * RESET-shaped and leaves `resetCode` unset.
972
1040
  * @internal
973
1041
  */
974
1042
  terminateNoThrow(err, opts) {
1043
+ const closedCleanly = this.state === "closed" && !this.terminalError;
975
1044
  this.state = "closed";
976
- this.terminalError = err;
977
- if (err instanceof StreamError) this.resetCode = err.code;
1045
+ if (!closedCleanly) {
1046
+ this.terminalError = err;
1047
+ if (err instanceof StreamError) this.resetCode = err.code;
1048
+ }
978
1049
  const deferWriteErrors = !opts?.discardBuffered && this.remoteClosed;
979
1050
  if (deferWriteErrors) this.heldWriteCallbacks = this.heldWriteCallbacks ?? [];
980
1051
  this.wakeSendWaiters();
@@ -1007,8 +1078,21 @@ var MixerStream = class extends import_node_stream.Duplex {
1007
1078
  }
1008
1079
  return { done: false };
1009
1080
  }
1081
+ /**
1082
+ * True while a write callback is held back for the read side (see failWrite()).
1083
+ * @internal
1084
+ */
1085
+ hasHeldWriteCallbacks() {
1086
+ return this.heldWriteCallbacks !== null && this.heldWriteCallbacks.length > 0;
1087
+ }
1010
1088
  // --- public half-close / reset API -----------------------------------------
1011
- /** Sends CLOSE: "I will send no more DATA on this stream." end() is sugar for this via _final. */
1089
+ /**
1090
+ * Sends CLOSE: "I will send no more DATA on this stream." end() is sugar
1091
+ * for this via _final. A write still waiting on send credit can never go
1092
+ * out now, so it is woken to fail with STREAM_CLOSED (reserveSendCredit)
1093
+ * instead of waiting on a WINDOW that, once this stream is retired, would
1094
+ * never reach it.
1095
+ */
1012
1096
  closeWrite() {
1013
1097
  switch (this.state) {
1014
1098
  case "open":
@@ -1023,13 +1107,17 @@ var MixerStream = class extends import_node_stream.Duplex {
1023
1107
  }
1024
1108
  const closedNow = this.state === "closed";
1025
1109
  this.host.sendControlFrame(encodeClose(this.id));
1026
- if (closedNow) this.host.retireStream(this.id);
1110
+ this.wakeSendWaiters();
1111
+ if (closedNow) {
1112
+ this.host.retireStream(this.id);
1113
+ this.destroyWhenDone();
1114
+ }
1027
1115
  }
1028
1116
  /**
1029
1117
  * Sends CLOSE (if not already sent) and stops delivering further reads. If
1030
1118
  * the peer has not yet half-closed its own send side, a plain closeWrite()
1031
1119
  * would leave it writing into a window nobody drains, so close() also
1032
- * RESETs with CANCEL in that case (mirrors go/wsmixer/stream.go Close()).
1120
+ * RESETs with CANCEL in that case (mirrors ws-mixer-go/wsmixer/stream.go Close()).
1033
1121
  */
1034
1122
  close() {
1035
1123
  if (!this.remoteClosed) {
@@ -1068,7 +1156,8 @@ var MixerStream = class extends import_node_stream.Duplex {
1068
1156
  * MixerConn's dispatch loop). Identical effect to reset(), but also emits
1069
1157
  * `'reset'` the same way handleReset() does for a peer-sent RESET: in both
1070
1158
  * cases the stream's owner didn't choose this, so it needs to hear about
1071
- * it. No-ops (and does not emit) if the stream is already closed.
1159
+ * it. Sends nothing and does not emit if the stream is already closed
1160
+ * (applyReset then only finishes a pending local destroy()).
1072
1161
  * @internal
1073
1162
  */
1074
1163
  abort(code, message = "") {
@@ -1076,10 +1165,10 @@ var MixerStream = class extends import_node_stream.Duplex {
1076
1165
  this.emit("reset", { code: this.resetCode, message: this.terminalError.message });
1077
1166
  }
1078
1167
  }
1079
- /** Shared implementation behind reset()/abort(); returns false (no-op) if the stream was already closed. */
1168
+ /** Shared implementation behind reset()/abort(); returns false (nothing sent) if the stream was already closed. */
1080
1169
  applyReset(code, message) {
1081
1170
  if (this.state === "closed") {
1082
- if (this.heldWriteCallbacks && !this.destroyed) this.destroy();
1171
+ if (!this.destroyed) this.destroy();
1083
1172
  return false;
1084
1173
  }
1085
1174
  const numericCode = typeof code === "string" ? parseErrorCode(code) : code;
@@ -1117,6 +1206,23 @@ var DELIVERY_QUEUE_MARGIN = 64;
1117
1206
  function wireCloseCode(wsCode) {
1118
1207
  return wsCode === 1e3 || wsCode >= 4e3 && wsCode <= 4999 ? wsCode : 4e3 + ErrorCode.INTERNAL_ERROR;
1119
1208
  }
1209
+ var MAX_RESET_MESSAGE = 256;
1210
+ function asConnError(err) {
1211
+ if (err instanceof ConnError) return err;
1212
+ if (err instanceof WsMixerError) {
1213
+ return new ConnError(err.code, err.message, {
1214
+ streamId: err.streamId,
1215
+ fatal: err.fatal,
1216
+ wsCode: err.wsCode,
1217
+ closeReason: err.closeReason,
1218
+ cause: err
1219
+ });
1220
+ }
1221
+ return new ConnError(ErrorCode.INTERNAL_ERROR, err.message, { cause: err });
1222
+ }
1223
+ function retiredStreamError(streamId) {
1224
+ return new StreamError(ErrorCode.STREAM_CLOSED, streamId, "DATA not sent: stream already closed");
1225
+ }
1120
1226
  var TokenBucket = class {
1121
1227
  constructor(capacity, ratePerSecond) {
1122
1228
  this.capacity = capacity;
@@ -1153,6 +1259,23 @@ var MixerConn = class extends import_node_events.EventEmitter {
1153
1259
  /** The `last_stream_id` from the most recently received `drain`; set only once draining. */
1154
1260
  lastStreamId;
1155
1261
  streams = /* @__PURE__ */ new Map();
1262
+ /**
1263
+ * Retired (out of `streams`: no frame or DATA is ever routed to them again)
1264
+ * but not yet destroyed -- in practice a stream CLOSE'd both ways, waiting
1265
+ * on its reader's 'end'. Tracked only so rejectOutstanding() still tears
1266
+ * them down; each leaves on its own 'close', and teardown clears the rest.
1267
+ * Weak by default: a stream never read never emits 'end', so never
1268
+ * 'close', and a strong ref would pin every such stream the app abandoned
1269
+ * for the connection's lifetime. Strong while the stream holds a write
1270
+ * callback (retireStream(), or pinRetired() when one is held after
1271
+ * retirement): the stream owns that callback, not the other way round, and
1272
+ * the callback is what the app is waiting on -- collecting the stream
1273
+ * would leave it unsettled forever. A retired stream's held callbacks are
1274
+ * only ever flushed by destroy(), so a strong entry leaves on 'close' and
1275
+ * is never unpinned.
1276
+ * Dead weak refs are pruned whenever the map is touched.
1277
+ */
1278
+ retired = /* @__PURE__ */ new Map();
1156
1279
  highestOpened = 0;
1157
1280
  /**
1158
1281
  * Minimal "ignore and count" counters (item 11 of the review: full
@@ -1194,13 +1317,14 @@ var MixerConn = class extends import_node_events.EventEmitter {
1194
1317
  /**
1195
1318
  * The `StreamHost` a `MixerStream` actually talks to: a plain object of
1196
1319
  * bound closures, not `this` -- so `sendData`/`sendControlFrame`/
1197
- * `retireStream` stay private implementation details of MixerConn instead
1198
- * of leaking onto its public (and `.d.ts`) surface (item 8).
1320
+ * `retireStream`/`pinRetired` stay private implementation details of
1321
+ * MixerConn instead of leaking onto its public (and `.d.ts`) surface (item 8).
1199
1322
  */
1200
1323
  #streamHost = {
1201
1324
  sendData: (streamId, chunk) => this.sendData(streamId, chunk),
1202
1325
  sendControlFrame: (frame) => this.sendControlFrame(frame),
1203
- retireStream: (streamId) => this.retireStream(streamId)
1326
+ retireStream: (streamId) => this.retireStream(streamId),
1327
+ pinRetired: (streamId) => this.pinRetired(streamId)
1204
1328
  };
1205
1329
  // --- write scheduler state ---
1206
1330
  controlQueue = [];
@@ -1215,11 +1339,11 @@ var MixerConn = class extends import_node_events.EventEmitter {
1215
1339
  // order, off one queue, so a slow handler cannot stall frame parsing. ---
1216
1340
  deliveryQueue = [];
1217
1341
  deliveryRunning = false;
1218
- // --- stream-0 flood limit (item 4), mirrors go/wsmixer's Conn.stream0Bucket ---
1342
+ // --- stream-0 flood limit (item 4), mirrors ws-mixer-go/wsmixer's Conn.stream0Bucket ---
1219
1343
  stream0Bucket = new TokenBucket(STREAM0_BURST, STREAM0_RATE_PER_SEC);
1220
1344
  // --- keepalive state ---
1221
1345
  nextPingId = 0;
1222
- /** Watermark: every id below this has been acked (or pruned as stale) at least once. Mirrors go/wsmixer's Conn.lowestUnacked. */
1346
+ /** Watermark: every id below this has been acked (or pruned as stale) at least once. Mirrors ws-mixer-go/wsmixer's Conn.lowestUnacked. */
1223
1347
  lowestUnacked = 0;
1224
1348
  lastPongAt = 0;
1225
1349
  outstandingPings = /* @__PURE__ */ new Map();
@@ -1393,7 +1517,7 @@ var MixerConn = class extends import_node_events.EventEmitter {
1393
1517
  stream.handleClose();
1394
1518
  break;
1395
1519
  case FrameType.RESET:
1396
- stream.handleReset(resetCode(frame), resetMessage(frame));
1520
+ stream.handleReset(resetCode(frame), truncateUtf8(resetMessage(frame), MAX_RESET_MESSAGE));
1397
1521
  break;
1398
1522
  }
1399
1523
  }
@@ -1446,7 +1570,7 @@ var MixerConn = class extends import_node_events.EventEmitter {
1446
1570
  // --- ordered async delivery (item 2) -------------------------------------
1447
1571
  /**
1448
1572
  * Queues one `'stream'`/`'app'`/`'drain'` event for in-order, async
1449
- * delivery, mirroring go/wsmixer's deliveryLoop: the read/dispatch path
1573
+ * delivery, mirroring ws-mixer-go/wsmixer's deliveryLoop: the read/dispatch path
1450
1574
  * above never blocks on application code, but all three still fire in
1451
1575
  * wire order, off a single loop, one at a time. Handlers registered via
1452
1576
  * `on('stream'|'app'|'drain', ...)` must not block for long -- a handler
@@ -1518,9 +1642,13 @@ var MixerConn = class extends import_node_events.EventEmitter {
1518
1642
  * caught here, counted (stats().handlerErrors) and surfaced via a guarded
1519
1643
  * `'handlerError'` emit -- never an unhandled rejection, and never fatal
1520
1644
  * to delivery: the next queued stream/app/drain event still runs.
1645
+ *
1646
+ * Iterates rawListeners(), not listeners(): for a once() registration that
1647
+ * is the self-removing wrapper, so it detaches on first delivery (it
1648
+ * returns and throws exactly what the wrapped listener does).
1521
1649
  */
1522
1650
  async emitOrdered(event, arg) {
1523
- for (const listener of this.listeners(event)) {
1651
+ for (const listener of this.rawListeners(event)) {
1524
1652
  try {
1525
1653
  const result = listener.call(this, arg);
1526
1654
  if (result instanceof Promise) await result;
@@ -1578,6 +1706,10 @@ var MixerConn = class extends import_node_events.EventEmitter {
1578
1706
  reject(new ConnError(ErrorCode.INTERNAL_ERROR, "connection closed"));
1579
1707
  return;
1580
1708
  }
1709
+ if (!this.streams.has(streamId)) {
1710
+ reject(retiredStreamError(streamId));
1711
+ return;
1712
+ }
1581
1713
  let q = this.outbox.get(streamId);
1582
1714
  if (!q) {
1583
1715
  q = [];
@@ -1587,12 +1719,47 @@ var MixerConn = class extends import_node_events.EventEmitter {
1587
1719
  this.markReady(streamId);
1588
1720
  });
1589
1721
  }
1590
- /** Backs `streamHost.retireStream`: retire a fully-closed stream's id. */
1722
+ /**
1723
+ * Backs `streamHost.retireStream`: retire a fully-closed stream's id. DATA
1724
+ * still queued for it is rejected, never dropped (a dropped item's write
1725
+ * callback would hang forever) -- with the stream's own sendDone() error
1726
+ * (its RESET's, or STREAM_CLOSED after its CLOSE). A stream not yet
1727
+ * destroyed moves to `retired` until its 'close' -- strongly if it holds
1728
+ * a write callback, else weakly (see `retired`).
1729
+ */
1591
1730
  retireStream(streamId) {
1731
+ const stream = this.streams.get(streamId);
1592
1732
  this.streams.delete(streamId);
1733
+ if (stream && !stream.destroyed) {
1734
+ this.pruneRetired();
1735
+ this.retired.set(streamId, stream.hasHeldWriteCallbacks() ? stream : new WeakRef(stream));
1736
+ stream.once("close", () => {
1737
+ this.retired.delete(streamId);
1738
+ this.pruneRetired();
1739
+ });
1740
+ }
1741
+ const q = this.outbox.get(streamId);
1593
1742
  this.outbox.delete(streamId);
1743
+ if (q) {
1744
+ const err = stream?.sendDone().error ?? retiredStreamError(streamId);
1745
+ for (const item of q) item.reject(err);
1746
+ }
1594
1747
  if (this.streams.size === 0) this.notifyIdle();
1595
1748
  }
1749
+ /** Backs `streamHost.pinRetired`: a retired stream now holding a write callback is held strongly until its 'close'. */
1750
+ pinRetired(streamId) {
1751
+ const entry = this.retired.get(streamId);
1752
+ if (entry instanceof WeakRef) {
1753
+ const stream = entry.deref();
1754
+ if (stream) this.retired.set(streamId, stream);
1755
+ }
1756
+ }
1757
+ /** Drops weak `retired` entries whose stream has been garbage-collected. */
1758
+ pruneRetired() {
1759
+ for (const [id, entry] of this.retired) {
1760
+ if (entry instanceof WeakRef && entry.deref() === void 0) this.retired.delete(id);
1761
+ }
1762
+ }
1596
1763
  /** Resolves every pending close()'s wait for the stream table to empty, instead of polling. */
1597
1764
  notifyIdle() {
1598
1765
  const waiters = this.idleWaiters;
@@ -1631,12 +1798,14 @@ var MixerConn = class extends import_node_events.EventEmitter {
1631
1798
  if (!q || q.length === 0) continue;
1632
1799
  const item = q.shift();
1633
1800
  const stream = this.streams.get(id);
1634
- if (stream) {
1635
- const { done, error } = stream.sendDone();
1636
- if (done) {
1637
- item.reject(error);
1638
- continue;
1639
- }
1801
+ if (!stream) {
1802
+ item.reject(retiredStreamError(id));
1803
+ continue;
1804
+ }
1805
+ const { done, error } = stream.sendDone();
1806
+ if (done) {
1807
+ item.reject(error);
1808
+ continue;
1640
1809
  }
1641
1810
  if (q.length > 0) this.markReady(id);
1642
1811
  return { streamId: id, item };
@@ -1686,20 +1855,20 @@ var MixerConn = class extends import_node_events.EventEmitter {
1686
1855
  writeRaw(frame) {
1687
1856
  return new Promise((resolve, reject) => {
1688
1857
  if (this.closed) {
1689
- reject(new Error("connection closed"));
1858
+ reject(new ConnError(ErrorCode.INTERNAL_ERROR, "connection closed"));
1690
1859
  return;
1691
1860
  }
1692
1861
  try {
1693
1862
  this.ws.send(frame, (err) => {
1694
1863
  if (err) {
1695
- reject(err);
1864
+ reject(asConnError(err));
1696
1865
  return;
1697
1866
  }
1698
1867
  this.counters.bytesOut += frame.length;
1699
1868
  resolve();
1700
1869
  });
1701
1870
  } catch (e) {
1702
- reject(e);
1871
+ reject(asConnError(e instanceof Error ? e : new Error(String(e))));
1703
1872
  }
1704
1873
  });
1705
1874
  }
@@ -1727,7 +1896,7 @@ var MixerConn = class extends import_node_events.EventEmitter {
1727
1896
  });
1728
1897
  }
1729
1898
  /**
1730
- * Pong watermark scheme, mirroring go/wsmixer/dispatch.go's handlePong:
1899
+ * Pong watermark scheme, mirroring ws-mixer-go/wsmixer/dispatch.go's handlePong:
1731
1900
  * every id below lowestUnacked has been acked (or pruned as stale) at
1732
1901
  * least once, and no id >= nextPingId has ever been sent.
1733
1902
  * - id >= nextPingId: never sent -> PROTOCOL_ERROR, connection-fatal.
@@ -1833,7 +2002,7 @@ var MixerConn = class extends import_node_events.EventEmitter {
1833
2002
  /**
1834
2003
  * The peer sent `error{code,message}`: record it and close with
1835
2004
  * `4000 + code` immediately, without waiting for the peer to do anything
1836
- * else -- mirrors go/wsmixer/dispatch.go's handlePeerError. `error` is
2005
+ * else -- mirrors ws-mixer-go/wsmixer/dispatch.go's handlePeerError. `error` is
1837
2006
  * always the last message on the wire (WIRE.md section 2.7), so
1838
2007
  * there is nothing left to negotiate.
1839
2008
  */
@@ -1948,19 +2117,26 @@ var MixerConn = class extends import_node_events.EventEmitter {
1948
2117
  this.jitterTimer = null;
1949
2118
  }
1950
2119
  rejectOutstanding(err, streamErrorFactory) {
1951
- for (const q of this.outbox.values()) {
1952
- for (const item of q) item.reject(err);
1953
- }
1954
- this.outbox.clear();
2120
+ const connErr2 = asConnError(err);
1955
2121
  for (const item of this.controlQueue.splice(0, this.controlQueue.length)) {
1956
- item.reject(err);
2122
+ item.reject(connErr2);
1957
2123
  }
1958
- this.wake();
1959
- for (const stream of this.streams.values()) {
1960
- stream.terminateNoThrow(
1961
- streamErrorFactory ? streamErrorFactory(stream.id) : err instanceof WsMixerError ? err : new ConnError(ErrorCode.INTERNAL_ERROR, err.message)
1962
- );
2124
+ const retired = [];
2125
+ for (const entry of this.retired.values()) {
2126
+ const stream = entry instanceof WeakRef ? entry.deref() : entry;
2127
+ if (stream) retired.push(stream);
1963
2128
  }
2129
+ for (const stream of [...this.streams.values(), ...retired]) {
2130
+ if (stream.destroyed) continue;
2131
+ stream.terminateNoThrow(streamErrorFactory ? streamErrorFactory(stream.id) : connErr2);
2132
+ }
2133
+ this.retired.clear();
2134
+ for (const [id, q] of this.outbox) {
2135
+ const streamErr = this.streams.get(id)?.sendDone().error ?? connErr2;
2136
+ for (const item of q) item.reject(streamErr);
2137
+ }
2138
+ this.outbox.clear();
2139
+ this.wake();
1964
2140
  }
1965
2141
  isDraining() {
1966
2142
  return this.draining;
@@ -1972,7 +2148,7 @@ var MixerConn = class extends import_node_events.EventEmitter {
1972
2148
 
1973
2149
  // src/client.ts
1974
2150
  var SUBPROTOCOL = "ws-mixer.v1";
1975
- var SDK_VERSION = "0.6.0";
2151
+ var SDK_VERSION = "0.7.0";
1976
2152
  async function resolveToken(token) {
1977
2153
  return typeof token === "function" ? await token() : token;
1978
2154
  }