@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/CHANGELOG.md +63 -0
- package/README.md +29 -5
- package/dist/client.d.ts +4 -4
- package/dist/conn.d.ts +38 -6
- package/dist/errors.d.ts +11 -1
- package/dist/index.cjs +260 -84
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +260 -84
- package/dist/index.js.map +1 -1
- package/dist/stream.d.ts +59 -7
- package/package.json +1 -1
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
928
|
-
*
|
|
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()
|
|
956
|
-
*
|
|
957
|
-
*
|
|
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
|
|
970
|
-
*
|
|
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
|
-
|
|
977
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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.
|
|
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 (
|
|
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 (
|
|
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
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
2122
|
+
item.reject(connErr2);
|
|
1957
2123
|
}
|
|
1958
|
-
|
|
1959
|
-
for (const
|
|
1960
|
-
stream.
|
|
1961
|
-
|
|
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.
|
|
2151
|
+
var SDK_VERSION = "0.7.0";
|
|
1976
2152
|
async function resolveToken(token) {
|
|
1977
2153
|
return typeof token === "function" ? await token() : token;
|
|
1978
2154
|
}
|