@mcpwarp/ws-mixer 0.6.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 +357 -0
- package/LICENSE +202 -0
- package/README.md +431 -0
- package/dist/client.d.ts +453 -0
- package/dist/conn.d.ts +328 -0
- package/dist/control.d.ts +74 -0
- package/dist/errors.d.ts +96 -0
- package/dist/frame.d.ts +52 -0
- package/dist/index.cjs +2866 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +2806 -0
- package/dist/index.js.map +1 -0
- package/dist/stream.d.ts +124 -0
- package/dist/util.d.ts +3 -0
- package/package.json +59 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,2866 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
ConnError: () => ConnError,
|
|
24
|
+
ErrorCode: () => ErrorCode,
|
|
25
|
+
FrameType: () => FrameType,
|
|
26
|
+
MAX_CHUNK: () => MAX_CHUNK,
|
|
27
|
+
MAX_MESSAGE_SIZE: () => MAX_MESSAGE_SIZE,
|
|
28
|
+
MAX_SEND_WINDOW: () => MAX_SEND_WINDOW,
|
|
29
|
+
MAX_STREAM_ZERO_PAYLOAD: () => MAX_STREAM_ZERO_PAYLOAD,
|
|
30
|
+
MixerClient: () => MixerClient,
|
|
31
|
+
MixerConn: () => MixerConn,
|
|
32
|
+
MixerStream: () => MixerStream,
|
|
33
|
+
SDK_VERSION: () => SDK_VERSION,
|
|
34
|
+
SUBPROTOCOL: () => SUBPROTOCOL,
|
|
35
|
+
StreamError: () => StreamError,
|
|
36
|
+
TokenUnavailableError: () => TokenUnavailableError,
|
|
37
|
+
WsMixerError: () => WsMixerError,
|
|
38
|
+
closeCode: () => closeCode,
|
|
39
|
+
codeName: () => codeName,
|
|
40
|
+
connect: () => connect,
|
|
41
|
+
decodeFrame: () => decodeFrame,
|
|
42
|
+
encodeClose: () => encodeClose,
|
|
43
|
+
encodeControl: () => encodeControl,
|
|
44
|
+
encodeData: () => encodeData,
|
|
45
|
+
encodeFrame: () => encodeFrame,
|
|
46
|
+
encodeOpen: () => encodeOpen,
|
|
47
|
+
encodeReset: () => encodeReset,
|
|
48
|
+
encodeWindow: () => encodeWindow,
|
|
49
|
+
frameTypeName: () => frameTypeName,
|
|
50
|
+
fullJitterDelay: () => fullJitterDelay,
|
|
51
|
+
knownDrainReason: () => knownDrainReason,
|
|
52
|
+
parseControl: () => parseControl,
|
|
53
|
+
parseErrorCode: () => parseErrorCode,
|
|
54
|
+
resetCode: () => resetCode,
|
|
55
|
+
resetMessage: () => resetMessage,
|
|
56
|
+
windowIncrement: () => windowIncrement
|
|
57
|
+
});
|
|
58
|
+
module.exports = __toCommonJS(index_exports);
|
|
59
|
+
|
|
60
|
+
// src/client.ts
|
|
61
|
+
var import_node_events2 = require("events");
|
|
62
|
+
var import_ws = require("ws");
|
|
63
|
+
|
|
64
|
+
// src/errors.ts
|
|
65
|
+
var ErrorCode = {
|
|
66
|
+
NO_ERROR: 0,
|
|
67
|
+
PROTOCOL_ERROR: 1,
|
|
68
|
+
INTERNAL_ERROR: 2,
|
|
69
|
+
FLOW_CONTROL_ERROR: 3,
|
|
70
|
+
FRAME_SIZE_ERROR: 4,
|
|
71
|
+
STREAM_CLOSED: 5,
|
|
72
|
+
REFUSED_STREAM: 6,
|
|
73
|
+
CANCEL: 7,
|
|
74
|
+
STREAM_LIMIT: 8,
|
|
75
|
+
ENHANCE_YOUR_CALM: 9,
|
|
76
|
+
UNSUPPORTED: 10,
|
|
77
|
+
UNAUTHORIZED: 11,
|
|
78
|
+
GOING_AWAY: 12,
|
|
79
|
+
KEEPALIVE_TIMEOUT: 13,
|
|
80
|
+
// Connection-level only, never emitted by ws-mixer itself: exists for the
|
|
81
|
+
// application above to close a connection for its own reason, carried in
|
|
82
|
+
// error.message / the WS close reason (WS close 4014).
|
|
83
|
+
APPLICATION_CLOSE: 14
|
|
84
|
+
};
|
|
85
|
+
var codeNames = {
|
|
86
|
+
[ErrorCode.NO_ERROR]: "NO_ERROR",
|
|
87
|
+
[ErrorCode.PROTOCOL_ERROR]: "PROTOCOL_ERROR",
|
|
88
|
+
[ErrorCode.INTERNAL_ERROR]: "INTERNAL_ERROR",
|
|
89
|
+
[ErrorCode.FLOW_CONTROL_ERROR]: "FLOW_CONTROL_ERROR",
|
|
90
|
+
[ErrorCode.FRAME_SIZE_ERROR]: "FRAME_SIZE_ERROR",
|
|
91
|
+
[ErrorCode.STREAM_CLOSED]: "STREAM_CLOSED",
|
|
92
|
+
[ErrorCode.REFUSED_STREAM]: "REFUSED_STREAM",
|
|
93
|
+
[ErrorCode.CANCEL]: "CANCEL",
|
|
94
|
+
[ErrorCode.STREAM_LIMIT]: "STREAM_LIMIT",
|
|
95
|
+
[ErrorCode.ENHANCE_YOUR_CALM]: "ENHANCE_YOUR_CALM",
|
|
96
|
+
[ErrorCode.UNSUPPORTED]: "UNSUPPORTED",
|
|
97
|
+
[ErrorCode.UNAUTHORIZED]: "UNAUTHORIZED",
|
|
98
|
+
[ErrorCode.GOING_AWAY]: "GOING_AWAY",
|
|
99
|
+
[ErrorCode.KEEPALIVE_TIMEOUT]: "KEEPALIVE_TIMEOUT",
|
|
100
|
+
[ErrorCode.APPLICATION_CLOSE]: "APPLICATION_CLOSE"
|
|
101
|
+
};
|
|
102
|
+
var namesToCode = Object.fromEntries(
|
|
103
|
+
Object.entries(codeNames).map(([code, name]) => [name, Number(code)])
|
|
104
|
+
);
|
|
105
|
+
function codeName(code) {
|
|
106
|
+
return codeNames[code] ?? "INTERNAL_ERROR";
|
|
107
|
+
}
|
|
108
|
+
function parseErrorCode(name) {
|
|
109
|
+
return namesToCode[name];
|
|
110
|
+
}
|
|
111
|
+
function closeCode(code) {
|
|
112
|
+
return code === ErrorCode.NO_ERROR ? 1e3 : 4e3 + code;
|
|
113
|
+
}
|
|
114
|
+
var WsMixerError = class extends Error {
|
|
115
|
+
code;
|
|
116
|
+
name = "WsMixerError";
|
|
117
|
+
fatal;
|
|
118
|
+
streamId;
|
|
119
|
+
/**
|
|
120
|
+
* The peer's observed WS close-frame code, set only when this error was
|
|
121
|
+
* built from an actually-observed close frame (e.g. MixerConn.onSocketClose)
|
|
122
|
+
* rather than a locally-raised protocol violation.
|
|
123
|
+
*/
|
|
124
|
+
wsCode;
|
|
125
|
+
/** The peer's observed WS close-frame reason, verbatim, under the same condition as `wsCode`. */
|
|
126
|
+
closeReason;
|
|
127
|
+
constructor(code, message, opts) {
|
|
128
|
+
super(message);
|
|
129
|
+
this.code = code;
|
|
130
|
+
this.fatal = opts?.fatal ?? false;
|
|
131
|
+
this.streamId = opts?.streamId;
|
|
132
|
+
this.wsCode = opts?.wsCode;
|
|
133
|
+
this.closeReason = opts?.closeReason;
|
|
134
|
+
}
|
|
135
|
+
get codeName() {
|
|
136
|
+
return codeName(this.code);
|
|
137
|
+
}
|
|
138
|
+
toString() {
|
|
139
|
+
return `WsMixerError[${this.codeName}]: ${this.message}`;
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
var ConnError = class extends WsMixerError {
|
|
143
|
+
lastStreamId;
|
|
144
|
+
constructor(code, message, opts) {
|
|
145
|
+
super(code, message, opts);
|
|
146
|
+
this.name = "ConnError";
|
|
147
|
+
this.lastStreamId = opts?.lastStreamId;
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
var StreamError = class extends WsMixerError {
|
|
151
|
+
constructor(code, streamId, message) {
|
|
152
|
+
super(code, message, { streamId });
|
|
153
|
+
this.name = "StreamError";
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
var TokenUnavailableError = class extends Error {
|
|
157
|
+
name = "TokenUnavailableError";
|
|
158
|
+
constructor(message, options) {
|
|
159
|
+
super(message, options);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// src/conn.ts
|
|
164
|
+
var import_node_events = require("events");
|
|
165
|
+
|
|
166
|
+
// src/frame.ts
|
|
167
|
+
var FrameType = {
|
|
168
|
+
OPEN: 0,
|
|
169
|
+
DATA: 1,
|
|
170
|
+
WINDOW: 2,
|
|
171
|
+
CLOSE: 3,
|
|
172
|
+
RESET: 4
|
|
173
|
+
};
|
|
174
|
+
var frameTypeNames = {
|
|
175
|
+
[FrameType.OPEN]: "OPEN",
|
|
176
|
+
[FrameType.DATA]: "DATA",
|
|
177
|
+
[FrameType.WINDOW]: "WINDOW",
|
|
178
|
+
[FrameType.CLOSE]: "CLOSE",
|
|
179
|
+
[FrameType.RESET]: "RESET"
|
|
180
|
+
};
|
|
181
|
+
function frameTypeName(t) {
|
|
182
|
+
const name = frameTypeNames[t];
|
|
183
|
+
if (name) return name;
|
|
184
|
+
return `UNKNOWN(0x${t.toString(16).padStart(2, "0")})`;
|
|
185
|
+
}
|
|
186
|
+
function frameTypeKnown(t) {
|
|
187
|
+
return t in frameTypeNames;
|
|
188
|
+
}
|
|
189
|
+
var FRAME_HEADER_SIZE = 8;
|
|
190
|
+
var MAX_MESSAGE_SIZE = FRAME_HEADER_SIZE + 65536;
|
|
191
|
+
var MAX_STREAM_ZERO_PAYLOAD = 16384;
|
|
192
|
+
var MAX_CHUNK = 16384;
|
|
193
|
+
var STREAM_ID_HIGH_BIT = 2147483648;
|
|
194
|
+
var MAX_SEND_WINDOW = 2147483647;
|
|
195
|
+
function decodeFrame(msg) {
|
|
196
|
+
if (msg.length < FRAME_HEADER_SIZE) {
|
|
197
|
+
throw new ConnError(
|
|
198
|
+
ErrorCode.PROTOCOL_ERROR,
|
|
199
|
+
`frame header too short: ${msg.length} bytes, need at least ${FRAME_HEADER_SIZE}`
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
if (msg.length > MAX_MESSAGE_SIZE) {
|
|
203
|
+
throw new ConnError(
|
|
204
|
+
ErrorCode.FRAME_SIZE_ERROR,
|
|
205
|
+
`message of ${msg.length} bytes exceeds the ${MAX_MESSAGE_SIZE} byte limit`
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
const view = new DataView(msg.buffer, msg.byteOffset, msg.byteLength);
|
|
209
|
+
const type = view.getUint8(0);
|
|
210
|
+
const flags = view.getUint8(1);
|
|
211
|
+
const streamId = view.getUint32(4);
|
|
212
|
+
const payload = msg.subarray(FRAME_HEADER_SIZE);
|
|
213
|
+
if ((streamId & STREAM_ID_HIGH_BIT) !== 0) {
|
|
214
|
+
throw new ConnError(ErrorCode.PROTOCOL_ERROR, `stream id 0x${streamId.toString(16)} has the reserved high bit set`);
|
|
215
|
+
}
|
|
216
|
+
const f = { type, flags, streamId, payload };
|
|
217
|
+
if (!frameTypeKnown(type)) {
|
|
218
|
+
return f;
|
|
219
|
+
}
|
|
220
|
+
if ((type === FrameType.OPEN || type === FrameType.CLOSE || type === FrameType.WINDOW || type === FrameType.RESET) && streamId === 0) {
|
|
221
|
+
throw new ConnError(ErrorCode.PROTOCOL_ERROR, `${frameTypeName(type)} is not legal on stream 0 (control channel)`);
|
|
222
|
+
}
|
|
223
|
+
switch (type) {
|
|
224
|
+
case FrameType.OPEN:
|
|
225
|
+
if (streamId % 2 === 0) {
|
|
226
|
+
throw new ConnError(
|
|
227
|
+
ErrorCode.PROTOCOL_ERROR,
|
|
228
|
+
`OPEN for even stream id ${streamId}: only the server opens streams and server ids are always odd`
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
break;
|
|
232
|
+
case FrameType.DATA:
|
|
233
|
+
if (streamId === 0 && payload.length > MAX_STREAM_ZERO_PAYLOAD) {
|
|
234
|
+
throw new ConnError(
|
|
235
|
+
ErrorCode.ENHANCE_YOUR_CALM,
|
|
236
|
+
`stream 0 payload of ${payload.length} bytes exceeds the ${MAX_STREAM_ZERO_PAYLOAD} byte control-channel limit`
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
break;
|
|
240
|
+
case FrameType.WINDOW: {
|
|
241
|
+
if (payload.length !== 4) {
|
|
242
|
+
throw new ConnError(ErrorCode.FRAME_SIZE_ERROR, `WINDOW payload is ${payload.length} bytes, must be exactly 4`);
|
|
243
|
+
}
|
|
244
|
+
const increment = new DataView(payload.buffer, payload.byteOffset, payload.byteLength).getUint32(0);
|
|
245
|
+
if (increment === 0) {
|
|
246
|
+
throw new StreamError(ErrorCode.PROTOCOL_ERROR, streamId, "WINDOW increment of 0 is not legal (range is 1..2^31-1)");
|
|
247
|
+
}
|
|
248
|
+
if ((increment & STREAM_ID_HIGH_BIT) !== 0) {
|
|
249
|
+
throw new ConnError(ErrorCode.FLOW_CONTROL_ERROR, `WINDOW increment ${increment} would push the send window past 2^31-1`);
|
|
250
|
+
}
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
case FrameType.RESET:
|
|
254
|
+
if (payload.length < 4) {
|
|
255
|
+
throw new ConnError(ErrorCode.FRAME_SIZE_ERROR, `RESET payload is ${payload.length} bytes, must be at least 4`);
|
|
256
|
+
}
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
return f;
|
|
260
|
+
}
|
|
261
|
+
function windowIncrement(f) {
|
|
262
|
+
return new DataView(f.payload.buffer, f.payload.byteOffset, f.payload.byteLength).getUint32(0);
|
|
263
|
+
}
|
|
264
|
+
function resetCode(f) {
|
|
265
|
+
return new DataView(f.payload.buffer, f.payload.byteOffset, 4).getUint32(0);
|
|
266
|
+
}
|
|
267
|
+
var utf8Decoder = new TextDecoder("utf-8", { fatal: false });
|
|
268
|
+
function resetMessage(f) {
|
|
269
|
+
return utf8Decoder.decode(f.payload.subarray(4));
|
|
270
|
+
}
|
|
271
|
+
function encodeFrame(f) {
|
|
272
|
+
const payload = f.payload ?? new Uint8Array(0);
|
|
273
|
+
const out = new Uint8Array(FRAME_HEADER_SIZE + payload.length);
|
|
274
|
+
const view = new DataView(out.buffer);
|
|
275
|
+
view.setUint8(0, f.type);
|
|
276
|
+
view.setUint8(1, 0);
|
|
277
|
+
view.setUint16(2, 0);
|
|
278
|
+
view.setUint32(4, f.streamId >>> 0);
|
|
279
|
+
out.set(payload, FRAME_HEADER_SIZE);
|
|
280
|
+
return out;
|
|
281
|
+
}
|
|
282
|
+
function encodeOpen(streamId) {
|
|
283
|
+
return encodeFrame({ type: FrameType.OPEN, streamId });
|
|
284
|
+
}
|
|
285
|
+
function encodeData(streamId, payload) {
|
|
286
|
+
return encodeFrame({ type: FrameType.DATA, streamId, payload });
|
|
287
|
+
}
|
|
288
|
+
function encodeWindow(streamId, increment) {
|
|
289
|
+
const payload = new Uint8Array(4);
|
|
290
|
+
new DataView(payload.buffer).setUint32(0, increment >>> 0);
|
|
291
|
+
return encodeFrame({ type: FrameType.WINDOW, streamId, payload });
|
|
292
|
+
}
|
|
293
|
+
function encodeClose(streamId) {
|
|
294
|
+
return encodeFrame({ type: FrameType.CLOSE, streamId });
|
|
295
|
+
}
|
|
296
|
+
var utf8Encoder = new TextEncoder();
|
|
297
|
+
function encodeReset(streamId, code, message = "") {
|
|
298
|
+
const msgBytes = utf8Encoder.encode(message);
|
|
299
|
+
const payload = new Uint8Array(4 + msgBytes.length);
|
|
300
|
+
new DataView(payload.buffer).setUint32(0, code >>> 0);
|
|
301
|
+
payload.set(msgBytes, 4);
|
|
302
|
+
return encodeFrame({ type: FrameType.RESET, streamId, payload });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// src/control.ts
|
|
306
|
+
var T_HELLO = "hello";
|
|
307
|
+
var T_WELCOME = "welcome";
|
|
308
|
+
var T_PING = "ping";
|
|
309
|
+
var T_PONG = "pong";
|
|
310
|
+
var T_DRAIN = "drain";
|
|
311
|
+
var T_ERROR = "error";
|
|
312
|
+
var T_APP = "app";
|
|
313
|
+
var MAX_TOKEN_LEN = 4096;
|
|
314
|
+
var MAX_AGENT_FIELD_LEN = 128;
|
|
315
|
+
var WINDOW_MIN = 16384;
|
|
316
|
+
var WINDOW_MAX = 2147483647;
|
|
317
|
+
var MAX_STREAMS_MIN = 1;
|
|
318
|
+
var MAX_STREAMS_MAX = 1e5;
|
|
319
|
+
var MAX_SESSION_LEN = 64;
|
|
320
|
+
var PING_INTERVAL_MIN = 5e3;
|
|
321
|
+
var MAX_ERROR_MESSAGE = 1024;
|
|
322
|
+
var MAX_DRAIN_MESSAGE = 256;
|
|
323
|
+
var MAX_ID_VALUE = Number.MAX_SAFE_INTEGER;
|
|
324
|
+
var MAX_STREAM_ID_VALUE = 2147483647;
|
|
325
|
+
var CAPABILITY_RE = /^[a-z0-9_.-]{1,64}$/;
|
|
326
|
+
var KNOWN_DRAIN_REASONS = /* @__PURE__ */ new Set(["rollout", "overload", "id_exhausted", "replaced", "maintenance", "client_requested"]);
|
|
327
|
+
function knownDrainReason(reason) {
|
|
328
|
+
return KNOWN_DRAIN_REASONS.has(reason);
|
|
329
|
+
}
|
|
330
|
+
function connErr(message, code = ErrorCode.PROTOCOL_ERROR) {
|
|
331
|
+
return new ConnError(code, message);
|
|
332
|
+
}
|
|
333
|
+
function isPlainObject(v) {
|
|
334
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
335
|
+
}
|
|
336
|
+
function jsonKind(v) {
|
|
337
|
+
if (v === null) return "null";
|
|
338
|
+
if (typeof v === "boolean") return "boolean";
|
|
339
|
+
if (typeof v === "number") return "number";
|
|
340
|
+
if (typeof v === "string") return "string";
|
|
341
|
+
if (Array.isArray(v)) return "array";
|
|
342
|
+
if (typeof v === "object") return "object";
|
|
343
|
+
return typeof v;
|
|
344
|
+
}
|
|
345
|
+
function requireField(top, name) {
|
|
346
|
+
if (!(name in top)) throw connErr(`missing required field "${name}"`);
|
|
347
|
+
return top[name];
|
|
348
|
+
}
|
|
349
|
+
function fieldString(raw, name) {
|
|
350
|
+
if (typeof raw !== "string") throw connErr(`field "${name}" must be a string`);
|
|
351
|
+
return raw;
|
|
352
|
+
}
|
|
353
|
+
function fieldInt(raw, name) {
|
|
354
|
+
if (typeof raw !== "number" || !Number.isFinite(raw)) throw connErr(`field "${name}" must be an integer`);
|
|
355
|
+
if (!Number.isInteger(raw)) throw connErr(`field "${name}" must be an integer, not a float`);
|
|
356
|
+
return raw;
|
|
357
|
+
}
|
|
358
|
+
function fieldObject(raw, name) {
|
|
359
|
+
if (!isPlainObject(raw)) throw connErr(`field "${name}" must be an object`);
|
|
360
|
+
return raw;
|
|
361
|
+
}
|
|
362
|
+
function fieldStringArray(raw, name) {
|
|
363
|
+
if (!Array.isArray(raw)) throw connErr(`field "${name}" must be an array of strings`);
|
|
364
|
+
return raw.map((item) => {
|
|
365
|
+
if (typeof item !== "string") throw connErr(`field "${name}" must be an array of strings`);
|
|
366
|
+
return item;
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
function requireString(top, name) {
|
|
370
|
+
return fieldString(requireField(top, name), name);
|
|
371
|
+
}
|
|
372
|
+
function requireInt(top, name) {
|
|
373
|
+
return fieldInt(requireField(top, name), name);
|
|
374
|
+
}
|
|
375
|
+
function validateCapabilities(caps) {
|
|
376
|
+
for (const c of caps) {
|
|
377
|
+
if (!CAPABILITY_RE.test(c)) throw connErr(`capability "${c}" does not match ^[a-z0-9_.-]{1,64}$`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
function parseAgentInfo(obj) {
|
|
381
|
+
const sdk = fieldString(requireField(obj, "sdk"), "agent.sdk");
|
|
382
|
+
const sdk_version = fieldString(requireField(obj, "sdk_version"), "agent.sdk_version");
|
|
383
|
+
if ([...sdk].length > MAX_AGENT_FIELD_LEN || [...sdk_version].length > MAX_AGENT_FIELD_LEN) {
|
|
384
|
+
throw connErr(`agent.sdk / agent.sdk_version must be <= ${MAX_AGENT_FIELD_LEN} characters`);
|
|
385
|
+
}
|
|
386
|
+
const a = { sdk, sdk_version };
|
|
387
|
+
if ("runtime" in obj) a.runtime = fieldString(obj.runtime, "agent.runtime");
|
|
388
|
+
if ("os" in obj) a.os = fieldString(obj.os, "agent.os");
|
|
389
|
+
return a;
|
|
390
|
+
}
|
|
391
|
+
function parseHello(top) {
|
|
392
|
+
const v = requireInt(top, "v");
|
|
393
|
+
if (v !== 1) throw connErr(`hello.v=${v} does not match ws-mixer.v1`, ErrorCode.UNSUPPORTED);
|
|
394
|
+
const token = requireString(top, "token");
|
|
395
|
+
if (token.length < 1 || token.length > MAX_TOKEN_LEN) {
|
|
396
|
+
throw connErr(`hello.token length ${token.length} out of range 1..${MAX_TOKEN_LEN}`);
|
|
397
|
+
}
|
|
398
|
+
const agent = parseAgentInfo(fieldObject(requireField(top, "agent"), "agent"));
|
|
399
|
+
let window = 262144;
|
|
400
|
+
if ("window" in top) {
|
|
401
|
+
window = fieldInt(top.window, "window");
|
|
402
|
+
if (window < WINDOW_MIN || window > WINDOW_MAX) {
|
|
403
|
+
throw connErr(`hello.window ${window} out of range ${WINDOW_MIN}..${WINDOW_MAX}`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
let max_streams = 64;
|
|
407
|
+
if ("max_streams" in top) {
|
|
408
|
+
max_streams = fieldInt(top.max_streams, "max_streams");
|
|
409
|
+
if (max_streams < MAX_STREAMS_MIN || max_streams > MAX_STREAMS_MAX) {
|
|
410
|
+
throw connErr(`hello.max_streams ${max_streams} out of range ${MAX_STREAMS_MIN}..${MAX_STREAMS_MAX}`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
const m = { t: "hello", v, token, agent, window, max_streams };
|
|
414
|
+
if ("capabilities" in top) {
|
|
415
|
+
const capabilities = fieldStringArray(top.capabilities, "capabilities");
|
|
416
|
+
validateCapabilities(capabilities);
|
|
417
|
+
m.capabilities = capabilities;
|
|
418
|
+
}
|
|
419
|
+
if ("meta" in top) m.meta = top.meta;
|
|
420
|
+
return m;
|
|
421
|
+
}
|
|
422
|
+
function parseWelcome(top) {
|
|
423
|
+
const v = requireInt(top, "v");
|
|
424
|
+
if (v !== 1) throw connErr(`welcome.v=${v} does not match ws-mixer.v1`, ErrorCode.UNSUPPORTED);
|
|
425
|
+
const session = requireString(top, "session");
|
|
426
|
+
if (session.length > MAX_SESSION_LEN) throw connErr(`welcome.session length ${session.length} exceeds ${MAX_SESSION_LEN}`);
|
|
427
|
+
const window = requireInt(top, "window");
|
|
428
|
+
if (window < WINDOW_MIN || window > WINDOW_MAX) throw connErr(`welcome.window ${window} out of range ${WINDOW_MIN}..${WINDOW_MAX}`);
|
|
429
|
+
const max_streams = requireInt(top, "max_streams");
|
|
430
|
+
if (max_streams < MAX_STREAMS_MIN || max_streams > MAX_STREAMS_MAX) {
|
|
431
|
+
throw connErr(`welcome.max_streams ${max_streams} out of range ${MAX_STREAMS_MIN}..${MAX_STREAMS_MAX}`);
|
|
432
|
+
}
|
|
433
|
+
const ping_interval = requireInt(top, "ping_interval");
|
|
434
|
+
if (ping_interval < PING_INTERVAL_MIN) throw connErr(`welcome.ping_interval ${ping_interval} is below the ${PING_INTERVAL_MIN}ms floor`);
|
|
435
|
+
const ping_timeout = requireInt(top, "ping_timeout");
|
|
436
|
+
if (ping_timeout < 2 * ping_interval) {
|
|
437
|
+
throw connErr(`welcome.ping_timeout (${ping_timeout}) must be at least 2x ping_interval (${ping_interval})`);
|
|
438
|
+
}
|
|
439
|
+
const m = { t: "welcome", v, session, window, max_streams, ping_interval, ping_timeout };
|
|
440
|
+
if ("server" in top) {
|
|
441
|
+
const obj = fieldObject(top.server, "server");
|
|
442
|
+
const si = {};
|
|
443
|
+
if ("name" in obj) si.name = fieldString(obj.name, "server.name");
|
|
444
|
+
if ("version" in obj) si.version = fieldString(obj.version, "server.version");
|
|
445
|
+
if ("node" in obj) si.node = fieldString(obj.node, "server.node");
|
|
446
|
+
m.server = si;
|
|
447
|
+
}
|
|
448
|
+
if ("capabilities" in top) {
|
|
449
|
+
const capabilities = fieldStringArray(top.capabilities, "capabilities");
|
|
450
|
+
validateCapabilities(capabilities);
|
|
451
|
+
m.capabilities = capabilities;
|
|
452
|
+
}
|
|
453
|
+
if ("meta" in top) m.meta = top.meta;
|
|
454
|
+
return m;
|
|
455
|
+
}
|
|
456
|
+
function parsePingPong(top, t) {
|
|
457
|
+
const id = requireInt(top, "id");
|
|
458
|
+
if (id < 0 || id > MAX_ID_VALUE) throw connErr(`${t}.id ${id} out of range 0..2^53-1`);
|
|
459
|
+
const m = { t, id };
|
|
460
|
+
if ("ts" in top) m.ts = fieldInt(top.ts, "ts");
|
|
461
|
+
return m;
|
|
462
|
+
}
|
|
463
|
+
function parseDrain(top) {
|
|
464
|
+
const reason = requireString(top, "reason");
|
|
465
|
+
const lastStreamId = requireInt(top, "last_stream_id");
|
|
466
|
+
if (lastStreamId < 0 || lastStreamId > MAX_STREAM_ID_VALUE) {
|
|
467
|
+
throw connErr(`drain.last_stream_id ${lastStreamId} out of range 0..2^31-1`);
|
|
468
|
+
}
|
|
469
|
+
const m = { t: "drain", reason, last_stream_id: lastStreamId };
|
|
470
|
+
if ("deadline_ms" in top) m.deadline_ms = fieldInt(top.deadline_ms, "deadline_ms");
|
|
471
|
+
if ("retry_after_ms" in top) m.retry_after_ms = fieldInt(top.retry_after_ms, "retry_after_ms");
|
|
472
|
+
if ("message" in top) {
|
|
473
|
+
m.message = fieldString(top.message, "message");
|
|
474
|
+
if (m.message.length > MAX_DRAIN_MESSAGE) throw connErr(`drain.message length ${m.message.length} exceeds ${MAX_DRAIN_MESSAGE}`);
|
|
475
|
+
}
|
|
476
|
+
return m;
|
|
477
|
+
}
|
|
478
|
+
function parseErrorMsg(top) {
|
|
479
|
+
const code = requireInt(top, "code");
|
|
480
|
+
if (code < 0 || code > 4294967295) throw connErr(`error.code ${code} out of range`);
|
|
481
|
+
const message = requireString(top, "message");
|
|
482
|
+
if (message.length > MAX_ERROR_MESSAGE) throw connErr(`error.message length ${message.length} exceeds ${MAX_ERROR_MESSAGE}`);
|
|
483
|
+
const m = { t: "error", code, message };
|
|
484
|
+
if ("stream_id" in top) {
|
|
485
|
+
const v = fieldInt(top.stream_id, "stream_id");
|
|
486
|
+
if (v < 0 || v > MAX_STREAM_ID_VALUE) throw connErr(`error.stream_id ${v} out of range 0..2^31-1`);
|
|
487
|
+
m.stream_id = v;
|
|
488
|
+
}
|
|
489
|
+
if ("last_stream_id" in top) {
|
|
490
|
+
const v = fieldInt(top.last_stream_id, "last_stream_id");
|
|
491
|
+
if (v < 0 || v > MAX_STREAM_ID_VALUE) throw connErr(`error.last_stream_id ${v} out of range 0..2^31-1`);
|
|
492
|
+
m.last_stream_id = v;
|
|
493
|
+
}
|
|
494
|
+
return m;
|
|
495
|
+
}
|
|
496
|
+
function parseApp(top) {
|
|
497
|
+
const body = requireField(top, "body");
|
|
498
|
+
if (!isPlainObject(body)) throw connErr(`app.body must be a JSON object, got ${jsonKind(body)}`);
|
|
499
|
+
return { t: "app", body };
|
|
500
|
+
}
|
|
501
|
+
function parseControl(raw) {
|
|
502
|
+
const bytes = typeof raw === "string" ? utf8Encoder2.encode(raw) : raw;
|
|
503
|
+
if (bytes.length > MAX_STREAM_ZERO_PAYLOAD) {
|
|
504
|
+
throw new ConnError(
|
|
505
|
+
ErrorCode.ENHANCE_YOUR_CALM,
|
|
506
|
+
`stream 0 payload of ${bytes.length} bytes exceeds the ${MAX_STREAM_ZERO_PAYLOAD} byte control-channel limit`
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
let text;
|
|
510
|
+
if (typeof raw === "string") {
|
|
511
|
+
text = raw;
|
|
512
|
+
} else {
|
|
513
|
+
try {
|
|
514
|
+
text = utf8Decoder2.decode(raw);
|
|
515
|
+
} catch (e) {
|
|
516
|
+
throw connErr(`malformed UTF-8 on stream 0: ${e.message}`);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
let top;
|
|
520
|
+
try {
|
|
521
|
+
top = JSON.parse(text);
|
|
522
|
+
} catch (e) {
|
|
523
|
+
throw connErr(`malformed JSON on stream 0: ${e.message}`);
|
|
524
|
+
}
|
|
525
|
+
if (!isPlainObject(top)) {
|
|
526
|
+
throw connErr(`control message must be a JSON object, got ${jsonKind(top)}`);
|
|
527
|
+
}
|
|
528
|
+
if (!("t" in top)) throw connErr(`control message missing required field "t"`);
|
|
529
|
+
const t = fieldString(top.t, "t");
|
|
530
|
+
switch (t) {
|
|
531
|
+
case T_HELLO:
|
|
532
|
+
return parseHello(top);
|
|
533
|
+
case T_WELCOME:
|
|
534
|
+
return parseWelcome(top);
|
|
535
|
+
case T_PING:
|
|
536
|
+
return parsePingPong(top, "ping");
|
|
537
|
+
case T_PONG:
|
|
538
|
+
return parsePingPong(top, "pong");
|
|
539
|
+
case T_DRAIN:
|
|
540
|
+
return parseDrain(top);
|
|
541
|
+
case T_ERROR:
|
|
542
|
+
return parseErrorMsg(top);
|
|
543
|
+
case T_APP:
|
|
544
|
+
return parseApp(top);
|
|
545
|
+
default:
|
|
546
|
+
throw connErr(`unknown control message type "${t}"`);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
var utf8Encoder2 = new TextEncoder();
|
|
550
|
+
var utf8Decoder2 = new TextDecoder("utf-8", { fatal: true });
|
|
551
|
+
function encodeControl(msg) {
|
|
552
|
+
return utf8Encoder2.encode(JSON.stringify(msg));
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// src/stream.ts
|
|
556
|
+
var import_node_stream = require("stream");
|
|
557
|
+
|
|
558
|
+
// src/util.ts
|
|
559
|
+
function truncateUtf8(message, maxBytes) {
|
|
560
|
+
const bytes = new TextEncoder().encode(message);
|
|
561
|
+
if (bytes.length <= maxBytes) return message;
|
|
562
|
+
let end = maxBytes;
|
|
563
|
+
while (end > 0 && (bytes[end] & 192) === 128) end--;
|
|
564
|
+
return new TextDecoder().decode(bytes.subarray(0, end));
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// src/stream.ts
|
|
568
|
+
var NOOP_ERROR_LISTENER = () => {
|
|
569
|
+
};
|
|
570
|
+
var MixerStream = class extends import_node_stream.Duplex {
|
|
571
|
+
id;
|
|
572
|
+
host;
|
|
573
|
+
state = "open";
|
|
574
|
+
sendWindow;
|
|
575
|
+
recvWindow;
|
|
576
|
+
initialWindow;
|
|
577
|
+
unacked = 0;
|
|
578
|
+
remoteClosed = false;
|
|
579
|
+
terminalError = null;
|
|
580
|
+
/** Resolved when sendWindow becomes > 0 or the stream can no longer send. */
|
|
581
|
+
sendWaiters = [];
|
|
582
|
+
/** Bytes queued via _write, waiting on credit; drives backpressure. */
|
|
583
|
+
writeQueue = [];
|
|
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. */
|
|
586
|
+
heldWriteCallbacks = null;
|
|
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
|
+
resetCode;
|
|
589
|
+
constructor(id, host, initialRecvWindow, initialSendWindow) {
|
|
590
|
+
super({ autoDestroy: false, emitClose: true, readableHighWaterMark: initialRecvWindow });
|
|
591
|
+
this.id = id;
|
|
592
|
+
this.host = host;
|
|
593
|
+
this.initialWindow = initialRecvWindow;
|
|
594
|
+
this.recvWindow = initialRecvWindow;
|
|
595
|
+
this.sendWindow = initialSendWindow;
|
|
596
|
+
}
|
|
597
|
+
getState() {
|
|
598
|
+
return this.state;
|
|
599
|
+
}
|
|
600
|
+
getSendWindow() {
|
|
601
|
+
return this.sendWindow;
|
|
602
|
+
}
|
|
603
|
+
getRecvWindow() {
|
|
604
|
+
return this.recvWindow;
|
|
605
|
+
}
|
|
606
|
+
// --- Duplex plumbing ------------------------------------------------------
|
|
607
|
+
_read(_size) {
|
|
608
|
+
}
|
|
609
|
+
_write(chunk, _encoding, callback) {
|
|
610
|
+
if (this.terminalError) {
|
|
611
|
+
if (this.heldWriteCallbacks) {
|
|
612
|
+
this.heldWriteCallbacks.push(callback);
|
|
613
|
+
} else {
|
|
614
|
+
callback(this.terminalError);
|
|
615
|
+
}
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
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"));
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
this.writeQueue.push({ chunk, callback });
|
|
623
|
+
this.pumpWriteQueue();
|
|
624
|
+
}
|
|
625
|
+
_final(callback) {
|
|
626
|
+
this.closeWrite();
|
|
627
|
+
callback();
|
|
628
|
+
}
|
|
629
|
+
_destroy(err, callback) {
|
|
630
|
+
if (this.state !== "closed") {
|
|
631
|
+
this.reset(ErrorCode.CANCEL, err ? String(err.message) : "local destroy");
|
|
632
|
+
}
|
|
633
|
+
if (this.heldWriteCallbacks) {
|
|
634
|
+
const held = this.heldWriteCallbacks;
|
|
635
|
+
this.heldWriteCallbacks = null;
|
|
636
|
+
for (const cb of held) cb(this.terminalError ?? err ?? void 0);
|
|
637
|
+
}
|
|
638
|
+
callback(err);
|
|
639
|
+
}
|
|
640
|
+
// --- outbound: send credit + chunking -------------------------------------
|
|
641
|
+
pumpWriteQueue() {
|
|
642
|
+
if (this.draining) return;
|
|
643
|
+
this.draining = true;
|
|
644
|
+
void this.drainWriteQueue().finally(() => {
|
|
645
|
+
this.draining = false;
|
|
646
|
+
if (this.writeQueue.length > 0) this.pumpWriteQueue();
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
async drainWriteQueue() {
|
|
650
|
+
while (this.writeQueue.length > 0) {
|
|
651
|
+
const item = this.writeQueue[0];
|
|
652
|
+
let { chunk } = item;
|
|
653
|
+
const { callback } = item;
|
|
654
|
+
try {
|
|
655
|
+
while (chunk.length > 0) {
|
|
656
|
+
const n = await this.reserveSendCredit(chunk.length);
|
|
657
|
+
const piece = chunk.subarray(0, n);
|
|
658
|
+
chunk = chunk.subarray(n);
|
|
659
|
+
await this.host.sendData(this.id, piece);
|
|
660
|
+
}
|
|
661
|
+
this.writeQueue.shift();
|
|
662
|
+
callback();
|
|
663
|
+
} catch (e) {
|
|
664
|
+
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);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
/** Waits until send credit is available, then reserves and returns min(want, sendWindow, MAX_CHUNK). */
|
|
702
|
+
reserveSendCredit(want) {
|
|
703
|
+
return new Promise((resolve, reject) => {
|
|
704
|
+
const attempt = () => {
|
|
705
|
+
if (this.terminalError) {
|
|
706
|
+
reject(this.terminalError);
|
|
707
|
+
return true;
|
|
708
|
+
}
|
|
709
|
+
if (this.state !== "open" && this.state !== "half_closed_remote") {
|
|
710
|
+
reject(new StreamError(ErrorCode.STREAM_CLOSED, this.id, "write on a stream that is not open for sending"));
|
|
711
|
+
return true;
|
|
712
|
+
}
|
|
713
|
+
if (this.sendWindow > 0) {
|
|
714
|
+
const n = Math.min(want, this.sendWindow, MAX_CHUNK);
|
|
715
|
+
this.sendWindow -= n;
|
|
716
|
+
resolve(n);
|
|
717
|
+
return true;
|
|
718
|
+
}
|
|
719
|
+
return false;
|
|
720
|
+
};
|
|
721
|
+
if (attempt()) return;
|
|
722
|
+
this.sendWaiters.push(() => {
|
|
723
|
+
if (attempt()) return;
|
|
724
|
+
});
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
wakeSendWaiters() {
|
|
728
|
+
const waiters = this.sendWaiters;
|
|
729
|
+
this.sendWaiters = [];
|
|
730
|
+
for (const w of waiters) w();
|
|
731
|
+
}
|
|
732
|
+
// --- inbound: DATA / credit-on-consume -------------------------------------
|
|
733
|
+
/**
|
|
734
|
+
* Applies a received DATA frame. Throws a ConnError (FLOW_CONTROL_ERROR) if it exceeds recv credit.
|
|
735
|
+
* @internal
|
|
736
|
+
*/
|
|
737
|
+
handleData(payload) {
|
|
738
|
+
if (this.state === "half_closed_remote") {
|
|
739
|
+
throw new StreamError(ErrorCode.STREAM_CLOSED, this.id, "DATA received after this stream's CLOSE");
|
|
740
|
+
}
|
|
741
|
+
if (this.state === "closed") {
|
|
742
|
+
throw new StreamError(ErrorCode.STREAM_CLOSED, this.id, "DATA received on a closed stream");
|
|
743
|
+
}
|
|
744
|
+
const n = payload.length;
|
|
745
|
+
if (n > this.recvWindow) {
|
|
746
|
+
throw new ConnError(
|
|
747
|
+
ErrorCode.FLOW_CONTROL_ERROR,
|
|
748
|
+
`stream ${this.id}: received ${n} DATA bytes with ${this.recvWindow} credit remaining`
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
this.recvWindow -= n;
|
|
752
|
+
const before = this.readableLength;
|
|
753
|
+
this.push(Buffer.from(payload));
|
|
754
|
+
const buffered = this.readableLength - before;
|
|
755
|
+
const bypassed = n - buffered;
|
|
756
|
+
if (bypassed > 0) this.creditConsumed(bypassed);
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* Credits the peer back once bytes leave the internal buffer, mirroring Go
|
|
760
|
+
* `Stream.creditConsumed` (stream.go) and its half-window threshold.
|
|
761
|
+
*/
|
|
762
|
+
creditConsumed(n) {
|
|
763
|
+
this.unacked += n;
|
|
764
|
+
this.maybeDeliverEOF();
|
|
765
|
+
const threshold = this.initialWindow / 2;
|
|
766
|
+
if (this.unacked >= threshold && this.state !== "closed") {
|
|
767
|
+
const increment = this.unacked;
|
|
768
|
+
this.recvWindow += increment;
|
|
769
|
+
this.unacked = 0;
|
|
770
|
+
this.host.sendControlFrame(encodeWindow(this.id, increment));
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
eofDelivered = false;
|
|
774
|
+
internalBufferEmpty() {
|
|
775
|
+
return this.readableLength === 0;
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* Covers every consumption mode that goes through the buffer: explicit
|
|
779
|
+
* paused-mode `.read()` calls, and Node's internal flow loop -- which
|
|
780
|
+
* calls this same public `read()` method to back `on('data')`, `pipe()`
|
|
781
|
+
* and `for await` once bytes are actually sitting in the buffer (as
|
|
782
|
+
* opposed to handleData()'s synchronous fast-path bypass above, which
|
|
783
|
+
* never reaches the buffer at all). Between the two, every byte is
|
|
784
|
+
* credited exactly once: whatever left the buffer here, plus whatever
|
|
785
|
+
* never entered it there.
|
|
786
|
+
*/
|
|
787
|
+
read(size) {
|
|
788
|
+
const before = this.readableLength;
|
|
789
|
+
const result = super.read(size);
|
|
790
|
+
const consumed = before - this.readableLength;
|
|
791
|
+
if (consumed > 0) this.creditConsumed(consumed);
|
|
792
|
+
return result;
|
|
793
|
+
}
|
|
794
|
+
// --- inbound: WINDOW / CLOSE / RESET ---------------------------------------
|
|
795
|
+
/**
|
|
796
|
+
* Applies a received WINDOW frame's credit increment. Throws ConnError on overflow past 2^31-1.
|
|
797
|
+
* @internal
|
|
798
|
+
*/
|
|
799
|
+
handleWindow(increment) {
|
|
800
|
+
const newWindow = this.sendWindow + increment;
|
|
801
|
+
if (newWindow > MAX_SEND_WINDOW) {
|
|
802
|
+
throw new ConnError(ErrorCode.FLOW_CONTROL_ERROR, `stream ${this.id}: WINDOW would push the send window to ${newWindow}, past 2^31-1`);
|
|
803
|
+
}
|
|
804
|
+
if (this.state === "closed") return;
|
|
805
|
+
this.sendWindow = newWindow;
|
|
806
|
+
this.wakeSendWaiters();
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* Applies a received CLOSE frame: half-close-remote (peer will send no more DATA).
|
|
810
|
+
* @internal
|
|
811
|
+
*/
|
|
812
|
+
handleClose() {
|
|
813
|
+
switch (this.state) {
|
|
814
|
+
case "open":
|
|
815
|
+
this.state = "half_closed_remote";
|
|
816
|
+
break;
|
|
817
|
+
case "half_closed_local":
|
|
818
|
+
this.state = "closed";
|
|
819
|
+
this.host.retireStream(this.id);
|
|
820
|
+
break;
|
|
821
|
+
case "half_closed_remote":
|
|
822
|
+
throw new StreamError(ErrorCode.STREAM_CLOSED, this.id, "duplicate CLOSE received");
|
|
823
|
+
case "closed":
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
this.remoteClosed = true;
|
|
827
|
+
this.maybeDeliverEOF();
|
|
828
|
+
}
|
|
829
|
+
// CLOSE preserves buffered data; EOF (push(null)) only follows once every
|
|
830
|
+
// buffered byte has been delivered to the application (WIRE.md section
|
|
831
|
+
// 2.5: "deliver those bytes and *then* EOF"). Called both right after
|
|
832
|
+
// handleClose() (buffer may already be empty) and from creditConsumed() as
|
|
833
|
+
// the application keeps draining the buffer.
|
|
834
|
+
maybeDeliverEOF() {
|
|
835
|
+
if (!this.eofDelivered && this.remoteClosed && this.internalBufferEmpty()) {
|
|
836
|
+
this.eofDelivered = true;
|
|
837
|
+
this.push(null);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
/** True once something is listening for `'error'`: gates attaching the internal no-op `'error'` listener (terminateNoThrow, drainWriteQueue). */
|
|
841
|
+
hasErrorListener() {
|
|
842
|
+
return this.listenerCount("error") > 0;
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* Applies a received RESET frame: any state, discard buffered data, go to
|
|
846
|
+
* closed (WIRE.md: RESET discards buffered data, unlike CLOSE). Node
|
|
847
|
+
* throws synchronously out of destroy()/a write callback when an 'error'
|
|
848
|
+
* is emitted with no listener -- terminateNoThrow's internal no-op
|
|
849
|
+
* listener is what keeps a peer RESET (including CANCEL, the ordinary path
|
|
850
|
+
* for a client-initiated `close()`) from crashing a consumer that never
|
|
851
|
+
* attached its own `'error'`, while still surfacing the code via
|
|
852
|
+
* `resetCode`/`'reset'`/`stream.errored` instead of a false clean end.
|
|
853
|
+
* @internal
|
|
854
|
+
*/
|
|
855
|
+
handleReset(code, message) {
|
|
856
|
+
this.state = "closed";
|
|
857
|
+
const err = new StreamError(code, this.id, message);
|
|
858
|
+
this.terminalError = err;
|
|
859
|
+
this.resetCode = code;
|
|
860
|
+
this.wakeSendWaiters();
|
|
861
|
+
this.emit("reset", { code, message });
|
|
862
|
+
this.terminateNoThrow(err, { discardBuffered: true });
|
|
863
|
+
this.host.retireStream(this.id);
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* Tears this stream down with `err`. Used by handleReset()/applyReset() (a
|
|
867
|
+
* stream-level RESET, peer-sent or app-initiated -- both pass
|
|
868
|
+
* `{discardBuffered: true}`, per WIRE.md: "RESET discards buffered data")
|
|
869
|
+
* and by MixerConn.rejectOutstanding() (a connection-level failure aborts
|
|
870
|
+
* every stream still live when it hit -- no `discardBuffered`).
|
|
871
|
+
*
|
|
872
|
+
* Two different endings, per CLIENT-SDK.md's "Stream teardown on
|
|
873
|
+
* disconnect" row:
|
|
874
|
+
*
|
|
875
|
+
* Node's own 'error' event throws synchronously and crashes the process
|
|
876
|
+
* when nothing is listening for it -- an internal no-op 'error' listener is
|
|
877
|
+
* attached first, unconditionally, whenever nothing else is listening
|
|
878
|
+
* (harmless -- the stream is terminating either way): needed by the
|
|
879
|
+
* destroy(err) branch below directly, and by the push(null) branch too,
|
|
880
|
+
* since even there the WRITE side is still live from Node's own Writable
|
|
881
|
+
* machinery's point of view, and a later write's `_write` callback error
|
|
882
|
+
* (`terminalError`, set above) still makes Node itself try to emit
|
|
883
|
+
* 'error'.
|
|
884
|
+
*
|
|
885
|
+
* - `opts?.discardBuffered`, OR the peer's own CLOSE never arrived on this
|
|
886
|
+
* stream (`!this.remoteClosed`): destroy(err) -- this stream's read side
|
|
887
|
+
* never legitimately finished, so it ends with an error, never a clean
|
|
888
|
+
* end-of-stream. 'end' is never emitted from this branch, so a consumer
|
|
889
|
+
* with only 'data'/'end'/'close' listeners sees 'close' (and
|
|
890
|
+
* `stream.errored`) but never a false clean end; pipe()/pipeline()/
|
|
891
|
+
* for-await consumers see `err` exactly as they would with a real
|
|
892
|
+
* 'error' listener attached.
|
|
893
|
+
* - Otherwise (no `discardBuffered`, the peer's CLOSE already arrived --
|
|
894
|
+
* `this.remoteClosed`): this stream's READ side genuinely ended cleanly
|
|
895
|
+
* on the wire before the connection died, and CLIENT-SDK.md requires the
|
|
896
|
+
* bytes it preserved to be delivered first, followed by 'end', followed
|
|
897
|
+
* by 'close' -- never an 'error'. `push(null)` does NOT discard whatever
|
|
898
|
+
* Node's Duplex still has buffered -- it marks EOF, and Node delivers the
|
|
899
|
+
* buffered bytes to the consumer and only then emits 'end' (unlike
|
|
900
|
+
* `destroy(err)`, which WOULD discard them). No 'error' event fires: the
|
|
901
|
+
* response DID complete -- but `stream.errored` does NOT stay `null`
|
|
902
|
+
* forever: once `heldWriteCallbacks` is flushed (see below), a write
|
|
903
|
+
* callback is called with `err`, and Node's own Writable machinery marks
|
|
904
|
+
* `stream.errored` from that alone, same as any other write error. Once
|
|
905
|
+
* 'end' has actually been delivered (immediately, if the buffer was
|
|
906
|
+
* already empty; otherwise once the consumer has drained it), this
|
|
907
|
+
* stream is `destroy()`'d with no error -- required for 'close' to ever
|
|
908
|
+
* fire on an `autoDestroy: false` Duplex, which this stream is
|
|
909
|
+
* (`emitClose: true` alone only arms the event, it doesn't schedule it).
|
|
910
|
+
* `eofDelivered` guards against calling `push(null)` a second time if
|
|
911
|
+
* `maybeDeliverEOF()` already did (buffer was already empty when CLOSE
|
|
912
|
+
* arrived); in that case the buffer is still (and can only still be)
|
|
913
|
+
* empty here too -- nothing can add to it once `remoteClosed` is set --
|
|
914
|
+
* so this destroys right away rather than waiting on an 'end' that may
|
|
915
|
+
* already have fired, or may never (a consumer that never reads never
|
|
916
|
+
* sees Node's own 'end', the same way it never would on any other
|
|
917
|
+
* Readable).
|
|
918
|
+
*
|
|
919
|
+
* A write outstanding at this exact moment (`writeQueue` non-empty, one
|
|
920
|
+
* item mid-`drainWriteQueue`, or one issued via `_write` after this call)
|
|
921
|
+
* still needs its callback settled with `err` -- the caller is waiting on
|
|
922
|
+
* it -- but calling it with an error *before* 'end' has fired would make
|
|
923
|
+
* Node's own Writable/Duplex machinery mark the READABLE side `errored`
|
|
924
|
+
* too, permanently blocking 'end' regardless of whether an 'error'
|
|
925
|
+
* listener is attached. So instead of calling those callbacks here,
|
|
926
|
+
* `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`
|
|
929
|
+
* right after `destroy()` actually runs (whether that's the 'end'-
|
|
930
|
+
* triggered `destroy()` below, or the read side never gets there because
|
|
931
|
+
* something else destroys the stream first): by then the readable side
|
|
932
|
+
* is already finished or was never going to finish anyway, so marking it
|
|
933
|
+
* `errored` is no longer a concern.
|
|
934
|
+
*
|
|
935
|
+
* Accepted tradeoff: a held write callback is settled only once the read
|
|
936
|
+
* side has actually been drained ('end') or the stream is destroyed some
|
|
937
|
+
* other way (app destroy(), reset()). An app that awaits a write's
|
|
938
|
+
* callback without ever reading this stream will wait until it does one
|
|
939
|
+
* or the other.
|
|
940
|
+
*
|
|
941
|
+
* `this.state = "closed"` up front (not just `terminalError`) matters for
|
|
942
|
+
* more than bookkeeping: `_destroy()`'s own guard (`if (this.state !==
|
|
943
|
+
* "closed") this.reset(...)`) would otherwise re-enter here through
|
|
944
|
+
* reset()/applyReset()/terminateNoThrow() a second time, overwriting the
|
|
945
|
+
* real `err` with a generic CANCEL, in the destroy(err) branch above.
|
|
946
|
+
*
|
|
947
|
+
* Setting `terminalError` and waking every sendWaiter (not just calling
|
|
948
|
+
* destroy()) is what makes a write already blocked on send credit
|
|
949
|
+
* (drainWriteQueue's reserveSendCredit) -- and everything still queued
|
|
950
|
+
* behind it -- fail promptly with `err` too, in EITHER branch: each
|
|
951
|
+
* rejection drains the next queued item in turn via drainWriteQueue's own
|
|
952
|
+
* loop, so no separate queue-flushing step is needed here.
|
|
953
|
+
*
|
|
954
|
+
* 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.
|
|
958
|
+
*
|
|
959
|
+
* `resetCode` is set here too, but only when `err` is a `StreamError`
|
|
960
|
+
* (mirrors handleReset()/applyReset(), which construct one for exactly
|
|
961
|
+
* this): the drain-hand-over `streamErrorFactory` override (client.ts)
|
|
962
|
+
* hands a stream-scoped `StreamError(CANCEL, "connection drained")` for a
|
|
963
|
+
* CONNECTION teardown (no `discardBuffered`) -- deliberately NOT what
|
|
964
|
+
* gates the buffered-data decision above (that's `opts.discardBuffered`
|
|
965
|
+
* alone), since a `StreamError` here doesn't mean "discard": it would
|
|
966
|
+
* otherwise wrongly discard a response that had already completed on
|
|
967
|
+
* every drain hand-over. From the stream's own perspective a `StreamError`
|
|
968
|
+
* 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.
|
|
972
|
+
* @internal
|
|
973
|
+
*/
|
|
974
|
+
terminateNoThrow(err, opts) {
|
|
975
|
+
this.state = "closed";
|
|
976
|
+
this.terminalError = err;
|
|
977
|
+
if (err instanceof StreamError) this.resetCode = err.code;
|
|
978
|
+
const deferWriteErrors = !opts?.discardBuffered && this.remoteClosed;
|
|
979
|
+
if (deferWriteErrors) this.heldWriteCallbacks = this.heldWriteCallbacks ?? [];
|
|
980
|
+
this.wakeSendWaiters();
|
|
981
|
+
if (!this.hasErrorListener()) {
|
|
982
|
+
this.on("error", NOOP_ERROR_LISTENER);
|
|
983
|
+
}
|
|
984
|
+
if (deferWriteErrors) {
|
|
985
|
+
const hadBufferedData = !this.internalBufferEmpty();
|
|
986
|
+
if (!this.eofDelivered) {
|
|
987
|
+
this.eofDelivered = true;
|
|
988
|
+
this.push(null);
|
|
989
|
+
}
|
|
990
|
+
if (hadBufferedData) {
|
|
991
|
+
this.once("end", () => this.destroy());
|
|
992
|
+
} else {
|
|
993
|
+
this.destroy();
|
|
994
|
+
}
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
this.destroy(err);
|
|
998
|
+
}
|
|
999
|
+
/**
|
|
1000
|
+
* Reports whether this stream must no longer send DATA (CLOSE sent, or RESET).
|
|
1001
|
+
* @internal
|
|
1002
|
+
*/
|
|
1003
|
+
sendDone() {
|
|
1004
|
+
if (this.state === "half_closed_local" || this.state === "closed") {
|
|
1005
|
+
if (this.terminalError) return { done: true, error: this.terminalError };
|
|
1006
|
+
return { done: true, error: new StreamError(ErrorCode.STREAM_CLOSED, this.id, "DATA not sent: CLOSE already sent on this stream") };
|
|
1007
|
+
}
|
|
1008
|
+
return { done: false };
|
|
1009
|
+
}
|
|
1010
|
+
// --- public half-close / reset API -----------------------------------------
|
|
1011
|
+
/** Sends CLOSE: "I will send no more DATA on this stream." end() is sugar for this via _final. */
|
|
1012
|
+
closeWrite() {
|
|
1013
|
+
switch (this.state) {
|
|
1014
|
+
case "open":
|
|
1015
|
+
this.state = "half_closed_local";
|
|
1016
|
+
break;
|
|
1017
|
+
case "half_closed_remote":
|
|
1018
|
+
this.state = "closed";
|
|
1019
|
+
break;
|
|
1020
|
+
case "half_closed_local":
|
|
1021
|
+
case "closed":
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
const closedNow = this.state === "closed";
|
|
1025
|
+
this.host.sendControlFrame(encodeClose(this.id));
|
|
1026
|
+
if (closedNow) this.host.retireStream(this.id);
|
|
1027
|
+
}
|
|
1028
|
+
/**
|
|
1029
|
+
* Sends CLOSE (if not already sent) and stops delivering further reads. If
|
|
1030
|
+
* the peer has not yet half-closed its own send side, a plain closeWrite()
|
|
1031
|
+
* 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()).
|
|
1033
|
+
*/
|
|
1034
|
+
close() {
|
|
1035
|
+
if (!this.remoteClosed) {
|
|
1036
|
+
this.closeWrite();
|
|
1037
|
+
this.reset(ErrorCode.CANCEL, "local close: peer had not finished sending");
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
this.closeWrite();
|
|
1041
|
+
this.destroy();
|
|
1042
|
+
}
|
|
1043
|
+
/**
|
|
1044
|
+
* Aborts the stream in both directions with the given error code and
|
|
1045
|
+
* message, discarding buffered data. `code` may be a numeric ws-mixer.v1
|
|
1046
|
+
* error code or its wire name (e.g. `"CANCEL"`); `message` is truncated to
|
|
1047
|
+
* 256 UTF-8 bytes on a character boundary (WIRE.md section 2.3: a RESET
|
|
1048
|
+
* message SHOULD be <= 256 B).
|
|
1049
|
+
*
|
|
1050
|
+
* Same "don't throw with nobody listening" rule as handleReset(): an
|
|
1051
|
+
* internal no-op 'error' listener (attached by terminateNoThrow() when
|
|
1052
|
+
* nothing else is) keeps this from crashing a consumer with no 'error' of
|
|
1053
|
+
* its own, while still surfacing the code via `resetCode`/`stream.errored`
|
|
1054
|
+
* (buffered data discarded, per RESET's own semantics: terminateNoThrow()
|
|
1055
|
+
* is called with `{discardBuffered: true}`).
|
|
1056
|
+
*
|
|
1057
|
+
* This is the app-facing API: calling code already knows why it's
|
|
1058
|
+
* resetting its own stream, so unlike a peer-sent RESET (handleReset()) or
|
|
1059
|
+
* an SDK-detected violation (abort()), `reset()` does NOT emit `'reset'`.
|
|
1060
|
+
*/
|
|
1061
|
+
reset(code, message = "") {
|
|
1062
|
+
this.applyReset(code, message);
|
|
1063
|
+
}
|
|
1064
|
+
/**
|
|
1065
|
+
* Internal counterpart to reset(): used when the *connection* -- not
|
|
1066
|
+
* application code -- is what decided to tear this stream down (an
|
|
1067
|
+
* SDK-detected protocol violation surfaced as a `StreamError` from
|
|
1068
|
+
* MixerConn's dispatch loop). Identical effect to reset(), but also emits
|
|
1069
|
+
* `'reset'` the same way handleReset() does for a peer-sent RESET: in both
|
|
1070
|
+
* 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.
|
|
1072
|
+
* @internal
|
|
1073
|
+
*/
|
|
1074
|
+
abort(code, message = "") {
|
|
1075
|
+
if (this.applyReset(code, message)) {
|
|
1076
|
+
this.emit("reset", { code: this.resetCode, message: this.terminalError.message });
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
/** Shared implementation behind reset()/abort(); returns false (no-op) if the stream was already closed. */
|
|
1080
|
+
applyReset(code, message) {
|
|
1081
|
+
if (this.state === "closed") {
|
|
1082
|
+
if (this.heldWriteCallbacks && !this.destroyed) this.destroy();
|
|
1083
|
+
return false;
|
|
1084
|
+
}
|
|
1085
|
+
const numericCode = typeof code === "string" ? parseErrorCode(code) : code;
|
|
1086
|
+
if (numericCode === void 0) {
|
|
1087
|
+
throw new Error(`unknown ws-mixer error code name: ${code}`);
|
|
1088
|
+
}
|
|
1089
|
+
const truncated = truncateUtf8(message, 256);
|
|
1090
|
+
this.state = "closed";
|
|
1091
|
+
this.terminalError = new StreamError(numericCode, this.id, truncated);
|
|
1092
|
+
this.resetCode = numericCode;
|
|
1093
|
+
this.wakeSendWaiters();
|
|
1094
|
+
this.host.sendControlFrame(encodeReset(this.id, numericCode, truncated));
|
|
1095
|
+
this.host.retireStream(this.id);
|
|
1096
|
+
this.terminateNoThrow(this.terminalError, { discardBuffered: true });
|
|
1097
|
+
return true;
|
|
1098
|
+
}
|
|
1099
|
+
/**
|
|
1100
|
+
* Wraps this stream as Web Streams API `{ readable, writable }`, e.g. for
|
|
1101
|
+
* `fetch`'s `duplex` option or any other Web Streams consumer.
|
|
1102
|
+
*/
|
|
1103
|
+
toWeb() {
|
|
1104
|
+
return import_node_stream.Duplex.toWeb(this);
|
|
1105
|
+
}
|
|
1106
|
+
};
|
|
1107
|
+
|
|
1108
|
+
// src/conn.ts
|
|
1109
|
+
var DEFAULT_WINDOW = 262144;
|
|
1110
|
+
var DEFAULT_MAX_STREAMS = 64;
|
|
1111
|
+
var DEFAULT_HELLO_TIMEOUT_MS = 1e4;
|
|
1112
|
+
var DEFAULT_PING_INTERVAL_FLOOR_MS = 5e3;
|
|
1113
|
+
var STREAM0_RATE_PER_SEC = 50;
|
|
1114
|
+
var STREAM0_BURST = 100;
|
|
1115
|
+
var DELIVERY_QUEUE_MIN = 128;
|
|
1116
|
+
var DELIVERY_QUEUE_MARGIN = 64;
|
|
1117
|
+
function wireCloseCode(wsCode) {
|
|
1118
|
+
return wsCode === 1e3 || wsCode >= 4e3 && wsCode <= 4999 ? wsCode : 4e3 + ErrorCode.INTERNAL_ERROR;
|
|
1119
|
+
}
|
|
1120
|
+
var TokenBucket = class {
|
|
1121
|
+
constructor(capacity, ratePerSecond) {
|
|
1122
|
+
this.capacity = capacity;
|
|
1123
|
+
this.ratePerSecond = ratePerSecond;
|
|
1124
|
+
this.tokens = capacity;
|
|
1125
|
+
}
|
|
1126
|
+
capacity;
|
|
1127
|
+
ratePerSecond;
|
|
1128
|
+
tokens;
|
|
1129
|
+
last = Date.now();
|
|
1130
|
+
/** Reports whether one token is available and, if so, consumes it. */
|
|
1131
|
+
allow() {
|
|
1132
|
+
const now = Date.now();
|
|
1133
|
+
this.tokens = Math.min(this.capacity, this.tokens + (now - this.last) / 1e3 * this.ratePerSecond);
|
|
1134
|
+
this.last = now;
|
|
1135
|
+
if (this.tokens < 1) return false;
|
|
1136
|
+
this.tokens -= 1;
|
|
1137
|
+
return true;
|
|
1138
|
+
}
|
|
1139
|
+
};
|
|
1140
|
+
var MixerConn = class extends import_node_events.EventEmitter {
|
|
1141
|
+
ws;
|
|
1142
|
+
opts;
|
|
1143
|
+
closed = false;
|
|
1144
|
+
handshakeDone = false;
|
|
1145
|
+
session = "";
|
|
1146
|
+
peerWindow = DEFAULT_WINDOW;
|
|
1147
|
+
ourWindow;
|
|
1148
|
+
maxStreams = DEFAULT_MAX_STREAMS;
|
|
1149
|
+
pingIntervalMs = 3e4;
|
|
1150
|
+
pingTimeoutMs = 9e4;
|
|
1151
|
+
welcomeMeta;
|
|
1152
|
+
draining = false;
|
|
1153
|
+
/** The `last_stream_id` from the most recently received `drain`; set only once draining. */
|
|
1154
|
+
lastStreamId;
|
|
1155
|
+
streams = /* @__PURE__ */ new Map();
|
|
1156
|
+
highestOpened = 0;
|
|
1157
|
+
/**
|
|
1158
|
+
* Minimal "ignore and count" counters (item 11 of the review: full
|
|
1159
|
+
* escalation/STREAM_LIMIT bucketing is intentionally out of scope for v1 --
|
|
1160
|
+
* see README.md and docs/DESIGN.md). Exposed via `stats()`.
|
|
1161
|
+
*/
|
|
1162
|
+
counters = {
|
|
1163
|
+
unknownFrameTypes: 0,
|
|
1164
|
+
staleFrames: 0,
|
|
1165
|
+
duplicatePongs: 0,
|
|
1166
|
+
refusedOpens: 0,
|
|
1167
|
+
/**
|
|
1168
|
+
* OPENs received above the drain's `last_stream_id` (WIRE.md section
|
|
1169
|
+
* 2.9 / the 2026-08-27 decision log): connection-fatal `PROTOCOL_ERROR`,
|
|
1170
|
+
* not a stream-scoped refusal -- the server already promised not to send
|
|
1171
|
+
* one, so this is it breaking that promise, not a benign race.
|
|
1172
|
+
*/
|
|
1173
|
+
drainViolations: 0,
|
|
1174
|
+
/** `drain.reason` values outside the closed enum, normalized to "maintenance" (WIRE.md section 2.7). */
|
|
1175
|
+
unknownDrainReasons: 0,
|
|
1176
|
+
/** A `'stream'`/`'app'`/`'drain'` listener that threw or rejected; delivery continued with the next event regardless. */
|
|
1177
|
+
handlerErrors: 0,
|
|
1178
|
+
/** ConnError-triggered connection failures: malformed/out-of-sequence control traffic (CLIENT-SDK.md's "Stats / counters" row). */
|
|
1179
|
+
protocolViolations: 0,
|
|
1180
|
+
/**
|
|
1181
|
+
* Raw WS message bytes received/sent, cumulative (CLIENT-SDK.md's
|
|
1182
|
+
* "Stats / counters" row): the full ws-mixer frame on the wire, header included, for every
|
|
1183
|
+
* message -- not just DATA payload bytes. This differs from Go's
|
|
1184
|
+
* `BytesTransferred` metric, which counts payload only; do not compare
|
|
1185
|
+
* the two directly.
|
|
1186
|
+
*/
|
|
1187
|
+
bytesIn: 0,
|
|
1188
|
+
bytesOut: 0
|
|
1189
|
+
};
|
|
1190
|
+
/** Snapshot of the counters above. */
|
|
1191
|
+
stats() {
|
|
1192
|
+
return { ...this.counters };
|
|
1193
|
+
}
|
|
1194
|
+
/**
|
|
1195
|
+
* The `StreamHost` a `MixerStream` actually talks to: a plain object of
|
|
1196
|
+
* 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).
|
|
1199
|
+
*/
|
|
1200
|
+
#streamHost = {
|
|
1201
|
+
sendData: (streamId, chunk) => this.sendData(streamId, chunk),
|
|
1202
|
+
sendControlFrame: (frame) => this.sendControlFrame(frame),
|
|
1203
|
+
retireStream: (streamId) => this.retireStream(streamId)
|
|
1204
|
+
};
|
|
1205
|
+
// --- write scheduler state ---
|
|
1206
|
+
controlQueue = [];
|
|
1207
|
+
rotation = [];
|
|
1208
|
+
inRotation = /* @__PURE__ */ new Set();
|
|
1209
|
+
outbox = /* @__PURE__ */ new Map();
|
|
1210
|
+
wakeWaiters = [];
|
|
1211
|
+
writerRunning = false;
|
|
1212
|
+
/** Resolved once the stream table is empty; used by close() instead of polling. */
|
|
1213
|
+
idleWaiters = [];
|
|
1214
|
+
// --- ordered async delivery (item 2): stream/app/drain handlers fire in wire
|
|
1215
|
+
// order, off one queue, so a slow handler cannot stall frame parsing. ---
|
|
1216
|
+
deliveryQueue = [];
|
|
1217
|
+
deliveryRunning = false;
|
|
1218
|
+
// --- stream-0 flood limit (item 4), mirrors go/wsmixer's Conn.stream0Bucket ---
|
|
1219
|
+
stream0Bucket = new TokenBucket(STREAM0_BURST, STREAM0_RATE_PER_SEC);
|
|
1220
|
+
// --- keepalive state ---
|
|
1221
|
+
nextPingId = 0;
|
|
1222
|
+
/** Watermark: every id below this has been acked (or pruned as stale) at least once. Mirrors go/wsmixer's Conn.lowestUnacked. */
|
|
1223
|
+
lowestUnacked = 0;
|
|
1224
|
+
lastPongAt = 0;
|
|
1225
|
+
outstandingPings = /* @__PURE__ */ new Map();
|
|
1226
|
+
pingTimer = null;
|
|
1227
|
+
watchdogTimer = null;
|
|
1228
|
+
handshakeTimer = null;
|
|
1229
|
+
jitterTimer = null;
|
|
1230
|
+
/**
|
|
1231
|
+
* The most recent pre-welcome `'error'` event's message, recorded so
|
|
1232
|
+
* `onSocketClose`'s report -- the sole reporter, see its own doc comment
|
|
1233
|
+
* -- can use it as `message` when `ws`'s own close carries no reason of
|
|
1234
|
+
* its own.
|
|
1235
|
+
*/
|
|
1236
|
+
pendingHandshakeErrorMessage;
|
|
1237
|
+
constructor(ws, opts) {
|
|
1238
|
+
super();
|
|
1239
|
+
this.ws = ws;
|
|
1240
|
+
this.opts = opts;
|
|
1241
|
+
this.ourWindow = opts.window ?? DEFAULT_WINDOW;
|
|
1242
|
+
this.maxStreams = opts.maxStreams ?? DEFAULT_MAX_STREAMS;
|
|
1243
|
+
ws.on("message", (data) => this.onMessage(data));
|
|
1244
|
+
ws.on("close", (code, reason) => this.onSocketClose(code, reason));
|
|
1245
|
+
ws.on("error", (err) => this.onSocketError(err));
|
|
1246
|
+
}
|
|
1247
|
+
// --- handshake --------------------------------------------------------------
|
|
1248
|
+
/** Sends `hello` and resolves once `welcome` is validated, or rejects (a WsMixerError, `fatal` set for UNSUPPORTED). */
|
|
1249
|
+
handshake() {
|
|
1250
|
+
return new Promise((resolve, reject) => {
|
|
1251
|
+
const hello = {
|
|
1252
|
+
t: "hello",
|
|
1253
|
+
v: 1,
|
|
1254
|
+
token: this.opts.token,
|
|
1255
|
+
agent: this.opts.agent,
|
|
1256
|
+
window: this.ourWindow,
|
|
1257
|
+
max_streams: this.maxStreams,
|
|
1258
|
+
...this.opts.capabilities ? { capabilities: this.opts.capabilities } : {},
|
|
1259
|
+
...this.opts.meta !== void 0 ? { meta: this.opts.meta } : {}
|
|
1260
|
+
};
|
|
1261
|
+
const timeoutMs = this.opts.helloTimeoutMs ?? this.opts._timing?.helloTimeout ?? DEFAULT_HELLO_TIMEOUT_MS;
|
|
1262
|
+
this.handshakeTimer = setTimeout(() => {
|
|
1263
|
+
this.handshakeTimer = null;
|
|
1264
|
+
const err = new ConnError(ErrorCode.PROTOCOL_ERROR, `no welcome within ${timeoutMs}ms of hello`);
|
|
1265
|
+
this.fail(err);
|
|
1266
|
+
reject(err);
|
|
1267
|
+
}, timeoutMs);
|
|
1268
|
+
this.once("__welcome_internal", (welcome) => {
|
|
1269
|
+
if (this.handshakeTimer) {
|
|
1270
|
+
clearTimeout(this.handshakeTimer);
|
|
1271
|
+
this.handshakeTimer = null;
|
|
1272
|
+
}
|
|
1273
|
+
resolve(welcome);
|
|
1274
|
+
});
|
|
1275
|
+
this.once("__handshake_failed_internal", (err) => {
|
|
1276
|
+
if (this.handshakeTimer) {
|
|
1277
|
+
clearTimeout(this.handshakeTimer);
|
|
1278
|
+
this.handshakeTimer = null;
|
|
1279
|
+
}
|
|
1280
|
+
reject(err);
|
|
1281
|
+
});
|
|
1282
|
+
void this.enqueueControl(encodeData(0, encodeControl(hello))).catch(() => {
|
|
1283
|
+
});
|
|
1284
|
+
this.startWriter();
|
|
1285
|
+
});
|
|
1286
|
+
}
|
|
1287
|
+
applyWelcome(welcome) {
|
|
1288
|
+
const minInterval = this.opts._timing?.minPingInterval ?? DEFAULT_PING_INTERVAL_FLOOR_MS;
|
|
1289
|
+
if (welcome.ping_interval < minInterval) {
|
|
1290
|
+
throw new ConnError(ErrorCode.PROTOCOL_ERROR, `welcome.ping_interval ${welcome.ping_interval} is below the ${minInterval}ms floor`);
|
|
1291
|
+
}
|
|
1292
|
+
const minTimeout = this.opts._timing?.minPingTimeout ?? 2 * welcome.ping_interval;
|
|
1293
|
+
if (welcome.ping_timeout < minTimeout) {
|
|
1294
|
+
throw new ConnError(
|
|
1295
|
+
ErrorCode.PROTOCOL_ERROR,
|
|
1296
|
+
`welcome.ping_timeout (${welcome.ping_timeout}) must be at least ${minTimeout}ms`
|
|
1297
|
+
);
|
|
1298
|
+
}
|
|
1299
|
+
this.session = welcome.session;
|
|
1300
|
+
this.peerWindow = welcome.window;
|
|
1301
|
+
this.maxStreams = this.opts.maxStreams ? Math.min(this.opts.maxStreams, welcome.max_streams) : welcome.max_streams;
|
|
1302
|
+
this.pingIntervalMs = welcome.ping_interval;
|
|
1303
|
+
this.pingTimeoutMs = welcome.ping_timeout;
|
|
1304
|
+
this.welcomeMeta = welcome.meta;
|
|
1305
|
+
this.handshakeDone = true;
|
|
1306
|
+
this.lastPongAt = Date.now();
|
|
1307
|
+
this.emit("welcome", welcome);
|
|
1308
|
+
this.emit("__welcome_internal", welcome);
|
|
1309
|
+
this.startKeepalive();
|
|
1310
|
+
}
|
|
1311
|
+
// --- read dispatch (never blocks on application code) ------------------------
|
|
1312
|
+
onMessage(data) {
|
|
1313
|
+
this.counters.bytesIn += data.length;
|
|
1314
|
+
try {
|
|
1315
|
+
this.dispatchFrame(data instanceof Uint8Array ? data : new Uint8Array(data));
|
|
1316
|
+
} catch (e) {
|
|
1317
|
+
this.handleDispatchError(e);
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
handleDispatchError(e) {
|
|
1321
|
+
if (e instanceof ConnError) {
|
|
1322
|
+
this.counters.protocolViolations++;
|
|
1323
|
+
this.fail(e);
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
if (e instanceof StreamError) {
|
|
1327
|
+
const stream = this.streams.get(e.streamId);
|
|
1328
|
+
if (stream) {
|
|
1329
|
+
stream.abort(e.code, e.message);
|
|
1330
|
+
} else {
|
|
1331
|
+
this.sendControlFrame(encodeReset(e.streamId, e.code, e.message));
|
|
1332
|
+
}
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
this.fail(new ConnError(ErrorCode.INTERNAL_ERROR, e instanceof Error ? e.message : String(e)));
|
|
1336
|
+
}
|
|
1337
|
+
dispatchFrame(msg) {
|
|
1338
|
+
const frame = decodeFrame(msg);
|
|
1339
|
+
const known = Object.values(FrameType).includes(frame.type);
|
|
1340
|
+
if (!known) {
|
|
1341
|
+
this.counters.unknownFrameTypes++;
|
|
1342
|
+
return;
|
|
1343
|
+
}
|
|
1344
|
+
if (frame.streamId === 0) {
|
|
1345
|
+
if (frame.type !== FrameType.DATA) {
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
this.dispatchControl(frame.payload);
|
|
1349
|
+
return;
|
|
1350
|
+
}
|
|
1351
|
+
if (!this.handshakeDone) {
|
|
1352
|
+
throw new ConnError(ErrorCode.PROTOCOL_ERROR, `${frameTypeName(frame.type)} frame received before welcome completed the handshake`);
|
|
1353
|
+
}
|
|
1354
|
+
const id = frame.streamId;
|
|
1355
|
+
if (frame.type === FrameType.OPEN) {
|
|
1356
|
+
if (id <= this.highestOpened) {
|
|
1357
|
+
throw new ConnError(ErrorCode.PROTOCOL_ERROR, `duplicate or out-of-order OPEN for stream ${id}`);
|
|
1358
|
+
}
|
|
1359
|
+
this.highestOpened = id;
|
|
1360
|
+
if (this.draining && this.lastStreamId !== void 0 && id > this.lastStreamId) {
|
|
1361
|
+
this.counters.drainViolations++;
|
|
1362
|
+
throw new ConnError(
|
|
1363
|
+
ErrorCode.PROTOCOL_ERROR,
|
|
1364
|
+
`OPEN for stream ${id} received after drain (last_stream_id=${this.lastStreamId})`
|
|
1365
|
+
);
|
|
1366
|
+
}
|
|
1367
|
+
if (this.streams.size >= this.maxStreams) {
|
|
1368
|
+
this.counters.refusedOpens++;
|
|
1369
|
+
this.sendControlFrame(encodeReset(id, ErrorCode.STREAM_LIMIT, `max_streams=${this.maxStreams} exceeded by stream ${id}`));
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
const stream2 = new MixerStream(id, this.#streamHost, this.ourWindow, this.peerWindow);
|
|
1373
|
+
this.streams.set(id, stream2);
|
|
1374
|
+
this.enqueueDelivery({ kind: "stream", stream: stream2 });
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1377
|
+
if (id > this.highestOpened) {
|
|
1378
|
+
throw new ConnError(ErrorCode.PROTOCOL_ERROR, `${frame.type === FrameType.DATA ? "DATA" : "frame"} for stream ${id}, which was never opened`);
|
|
1379
|
+
}
|
|
1380
|
+
const stream = this.streams.get(id);
|
|
1381
|
+
if (!stream) {
|
|
1382
|
+
this.counters.staleFrames++;
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
switch (frame.type) {
|
|
1386
|
+
case FrameType.DATA:
|
|
1387
|
+
stream.handleData(frame.payload);
|
|
1388
|
+
break;
|
|
1389
|
+
case FrameType.WINDOW:
|
|
1390
|
+
stream.handleWindow(windowIncrement(frame));
|
|
1391
|
+
break;
|
|
1392
|
+
case FrameType.CLOSE:
|
|
1393
|
+
stream.handleClose();
|
|
1394
|
+
break;
|
|
1395
|
+
case FrameType.RESET:
|
|
1396
|
+
stream.handleReset(resetCode(frame), resetMessage(frame));
|
|
1397
|
+
break;
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
dispatchControl(payload) {
|
|
1401
|
+
if (!this.stream0Bucket.allow()) {
|
|
1402
|
+
throw new ConnError(ErrorCode.ENHANCE_YOUR_CALM, `stream-0 message rate exceeded ${STREAM0_RATE_PER_SEC}/s (burst ${STREAM0_BURST})`);
|
|
1403
|
+
}
|
|
1404
|
+
const msg = parseControl(payload);
|
|
1405
|
+
switch (msg.t) {
|
|
1406
|
+
case "welcome":
|
|
1407
|
+
if (this.handshakeDone) throw new ConnError(ErrorCode.PROTOCOL_ERROR, "duplicate welcome after handshake completed");
|
|
1408
|
+
this.applyWelcome(msg);
|
|
1409
|
+
return;
|
|
1410
|
+
case "hello":
|
|
1411
|
+
throw new ConnError(ErrorCode.PROTOCOL_ERROR, "unexpected hello: only the client sends hello");
|
|
1412
|
+
case "ping":
|
|
1413
|
+
if (!this.handshakeDone) {
|
|
1414
|
+
throw new ConnError(ErrorCode.PROTOCOL_ERROR, "ping received before welcome completed the handshake");
|
|
1415
|
+
}
|
|
1416
|
+
void this.sendControlPriority({ t: "pong", id: msg.id, ...msg.ts !== void 0 ? { ts: msg.ts } : {} }).catch(() => {
|
|
1417
|
+
});
|
|
1418
|
+
return;
|
|
1419
|
+
case "pong":
|
|
1420
|
+
this.handlePong(msg.id);
|
|
1421
|
+
return;
|
|
1422
|
+
case "drain": {
|
|
1423
|
+
if (!this.handshakeDone) {
|
|
1424
|
+
throw new ConnError(ErrorCode.PROTOCOL_ERROR, "drain received before welcome completed the handshake");
|
|
1425
|
+
}
|
|
1426
|
+
let reason = msg.reason;
|
|
1427
|
+
if (!knownDrainReason(reason)) {
|
|
1428
|
+
this.counters.unknownDrainReasons++;
|
|
1429
|
+
reason = "maintenance";
|
|
1430
|
+
}
|
|
1431
|
+
const normalized = reason === msg.reason ? msg : { ...msg, reason };
|
|
1432
|
+
this.draining = true;
|
|
1433
|
+
this.lastStreamId = this.lastStreamId === void 0 ? normalized.last_stream_id : Math.min(this.lastStreamId, normalized.last_stream_id);
|
|
1434
|
+
this.enqueueDelivery({ kind: "drain", msg: normalized });
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1437
|
+
case "error":
|
|
1438
|
+
this.handlePeerError(msg.code, msg.message, msg.stream_id);
|
|
1439
|
+
return;
|
|
1440
|
+
case "app":
|
|
1441
|
+
if (!this.handshakeDone) throw new ConnError(ErrorCode.PROTOCOL_ERROR, "app message received before welcome");
|
|
1442
|
+
this.enqueueDelivery({ kind: "app", body: msg.body });
|
|
1443
|
+
return;
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
// --- ordered async delivery (item 2) -------------------------------------
|
|
1447
|
+
/**
|
|
1448
|
+
* Queues one `'stream'`/`'app'`/`'drain'` event for in-order, async
|
|
1449
|
+
* delivery, mirroring go/wsmixer's deliveryLoop: the read/dispatch path
|
|
1450
|
+
* above never blocks on application code, but all three still fire in
|
|
1451
|
+
* wire order, off a single loop, one at a time. Handlers registered via
|
|
1452
|
+
* `on('stream'|'app'|'drain', ...)` must not block for long -- a handler
|
|
1453
|
+
* that never returns/resolves permanently wedges this loop, exactly like
|
|
1454
|
+
* the Go side. Throws ConnError(ENHANCE_YOUR_CALM) if the bounded queue
|
|
1455
|
+
* (`max(128, max_streams + 64)`) is full, which the caller (dispatchFrame/
|
|
1456
|
+
* dispatchControl, inside onMessage's try/catch) turns into fail().
|
|
1457
|
+
*
|
|
1458
|
+
* Refuses once `this.closed` (CLIENT-SDK.md's "Handler delivery" row):
|
|
1459
|
+
* `dispatchFrame`/`dispatchControl` -- the only callers -- always run
|
|
1460
|
+
* before whatever ends the connection sets `this.closed` (every
|
|
1461
|
+
* fail()/close()/handlePeerError path runs teardownConn synchronously,
|
|
1462
|
+
* `error` is always the last message on the wire), so nothing legitimate
|
|
1463
|
+
* should ever reach here once closed; this is the defensive backstop that
|
|
1464
|
+
* makes runDeliveryLoop's post-close drain below provably bounded and
|
|
1465
|
+
* terminating regardless.
|
|
1466
|
+
*/
|
|
1467
|
+
enqueueDelivery(ev) {
|
|
1468
|
+
if (this.closed) return;
|
|
1469
|
+
const capacity = Math.max(DELIVERY_QUEUE_MIN, this.maxStreams + DELIVERY_QUEUE_MARGIN);
|
|
1470
|
+
if (this.deliveryQueue.length >= capacity) {
|
|
1471
|
+
throw new ConnError(ErrorCode.ENHANCE_YOUR_CALM, `application delivery queue full (>${capacity} pending stream/app/drain callbacks)`);
|
|
1472
|
+
}
|
|
1473
|
+
this.deliveryQueue.push(ev);
|
|
1474
|
+
if (!this.deliveryRunning) {
|
|
1475
|
+
this.deliveryRunning = true;
|
|
1476
|
+
void this.runDeliveryLoop();
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
/**
|
|
1480
|
+
* Drains `deliveryQueue` until empty -- deliberately NOT gated on
|
|
1481
|
+
* `!this.closed` (CLIENT-SDK.md's "Handler delivery" row): an event
|
|
1482
|
+
* already queued when the connection ends is still owed to its handler
|
|
1483
|
+
* (`error` is the last message on the wire, so a stream OPEN/`app`/`drain`
|
|
1484
|
+
* queued ahead of it arrived before the connection ended), so this loop
|
|
1485
|
+
* finishes flushing that backlog even after `teardownConn` has already set
|
|
1486
|
+
* `this.closed` and emitted `'close'` -- a handler MAY therefore run
|
|
1487
|
+
* shortly after `MixerClient.close()`/`MixerConn.close()`'s own promise has
|
|
1488
|
+
* already resolved (see those methods' doc comments and README). `if
|
|
1489
|
+
* (!ev) break` is what actually ends the loop; since `enqueueDelivery`
|
|
1490
|
+
* above refuses once closed, the queue can only shrink from here, so this
|
|
1491
|
+
* always terminates.
|
|
1492
|
+
*/
|
|
1493
|
+
async runDeliveryLoop() {
|
|
1494
|
+
for (; ; ) {
|
|
1495
|
+
const ev = this.deliveryQueue.shift();
|
|
1496
|
+
if (!ev) break;
|
|
1497
|
+
switch (ev.kind) {
|
|
1498
|
+
case "stream":
|
|
1499
|
+
await this.emitOrdered("stream", ev.stream);
|
|
1500
|
+
break;
|
|
1501
|
+
case "app":
|
|
1502
|
+
await this.emitOrdered("app", ev.body);
|
|
1503
|
+
break;
|
|
1504
|
+
case "drain":
|
|
1505
|
+
await this.emitOrdered("drain", ev.msg);
|
|
1506
|
+
break;
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
this.deliveryRunning = false;
|
|
1510
|
+
}
|
|
1511
|
+
/**
|
|
1512
|
+
* Invokes every listener for `event` in registration order, awaiting each
|
|
1513
|
+
* one's return value before starting the next -- unlike EventEmitter's own
|
|
1514
|
+
* synchronous `emit`, which fires listeners back to back without waiting
|
|
1515
|
+
* for a returned Promise to settle.
|
|
1516
|
+
*
|
|
1517
|
+
* A listener that throws synchronously or returns a rejected promise is
|
|
1518
|
+
* caught here, counted (stats().handlerErrors) and surfaced via a guarded
|
|
1519
|
+
* `'handlerError'` emit -- never an unhandled rejection, and never fatal
|
|
1520
|
+
* to delivery: the next queued stream/app/drain event still runs.
|
|
1521
|
+
*/
|
|
1522
|
+
async emitOrdered(event, arg) {
|
|
1523
|
+
for (const listener of this.listeners(event)) {
|
|
1524
|
+
try {
|
|
1525
|
+
const result = listener.call(this, arg);
|
|
1526
|
+
if (result instanceof Promise) await result;
|
|
1527
|
+
} catch (e) {
|
|
1528
|
+
this.counters.handlerErrors++;
|
|
1529
|
+
const error = e instanceof Error ? e : new Error(String(e));
|
|
1530
|
+
if (this.listenerCount("handlerError") > 0) this.emit("handlerError", { event, error });
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
// --- outbound: control priority + round-robin DATA scheduler ------------------
|
|
1535
|
+
/**
|
|
1536
|
+
* Queues one control-channel frame and returns a promise that resolves
|
|
1537
|
+
* once `ws.send`'s callback confirms it was actually written (or rejects
|
|
1538
|
+
* with the connection's terminal error if the conn fails first) --
|
|
1539
|
+
* mirroring how `sendData()`'s per-stream outbox already works.
|
|
1540
|
+
*/
|
|
1541
|
+
enqueueControl(frame, priority = false) {
|
|
1542
|
+
return new Promise((resolve, reject) => {
|
|
1543
|
+
if (this.closed) {
|
|
1544
|
+
reject(new ConnError(ErrorCode.INTERNAL_ERROR, "connection closed"));
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
const item = { frame, resolve, reject };
|
|
1548
|
+
if (priority) this.controlQueue.unshift(item);
|
|
1549
|
+
else this.controlQueue.push(item);
|
|
1550
|
+
this.wake();
|
|
1551
|
+
});
|
|
1552
|
+
}
|
|
1553
|
+
/**
|
|
1554
|
+
* Sends a control message (hello/ping/pong/drain/app/error) as stream-0
|
|
1555
|
+
* DATA, queued ahead of any DATA. Resolves once the frame is actually
|
|
1556
|
+
* written to the socket.
|
|
1557
|
+
*/
|
|
1558
|
+
sendControl(msg) {
|
|
1559
|
+
return this.enqueueControl(encodeData(0, encodeControl(msg)));
|
|
1560
|
+
}
|
|
1561
|
+
/**
|
|
1562
|
+
* Sends a control message at the head of the control queue, ahead of
|
|
1563
|
+
* anything already queued (item 6: "A pong MUST ... jump ahead of queued
|
|
1564
|
+
* DATA" -- and ahead of other queued control frames too).
|
|
1565
|
+
*/
|
|
1566
|
+
sendControlPriority(msg) {
|
|
1567
|
+
return this.enqueueControl(encodeData(0, encodeControl(msg)), true);
|
|
1568
|
+
}
|
|
1569
|
+
/** Backs `streamHost.sendControlFrame`: send a control-priority frame (WINDOW/CLOSE/RESET) immediately. */
|
|
1570
|
+
sendControlFrame(frame) {
|
|
1571
|
+
void this.enqueueControl(frame).catch(() => {
|
|
1572
|
+
});
|
|
1573
|
+
}
|
|
1574
|
+
/** Backs `streamHost.sendData`: enqueue a DATA chunk for round-robin transmission. */
|
|
1575
|
+
sendData(streamId, chunk) {
|
|
1576
|
+
return new Promise((resolve, reject) => {
|
|
1577
|
+
if (this.closed) {
|
|
1578
|
+
reject(new ConnError(ErrorCode.INTERNAL_ERROR, "connection closed"));
|
|
1579
|
+
return;
|
|
1580
|
+
}
|
|
1581
|
+
let q = this.outbox.get(streamId);
|
|
1582
|
+
if (!q) {
|
|
1583
|
+
q = [];
|
|
1584
|
+
this.outbox.set(streamId, q);
|
|
1585
|
+
}
|
|
1586
|
+
q.push({ chunk, resolve, reject });
|
|
1587
|
+
this.markReady(streamId);
|
|
1588
|
+
});
|
|
1589
|
+
}
|
|
1590
|
+
/** Backs `streamHost.retireStream`: retire a fully-closed stream's id. */
|
|
1591
|
+
retireStream(streamId) {
|
|
1592
|
+
this.streams.delete(streamId);
|
|
1593
|
+
this.outbox.delete(streamId);
|
|
1594
|
+
if (this.streams.size === 0) this.notifyIdle();
|
|
1595
|
+
}
|
|
1596
|
+
/** Resolves every pending close()'s wait for the stream table to empty, instead of polling. */
|
|
1597
|
+
notifyIdle() {
|
|
1598
|
+
const waiters = this.idleWaiters;
|
|
1599
|
+
this.idleWaiters = [];
|
|
1600
|
+
for (const w of waiters) w();
|
|
1601
|
+
}
|
|
1602
|
+
waitForEmpty() {
|
|
1603
|
+
return new Promise((resolve) => {
|
|
1604
|
+
if (this.streams.size === 0) {
|
|
1605
|
+
resolve();
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
this.idleWaiters.push(resolve);
|
|
1609
|
+
});
|
|
1610
|
+
}
|
|
1611
|
+
markReady(streamId) {
|
|
1612
|
+
if (!this.inRotation.has(streamId)) {
|
|
1613
|
+
this.inRotation.add(streamId);
|
|
1614
|
+
this.rotation.push(streamId);
|
|
1615
|
+
}
|
|
1616
|
+
this.wake();
|
|
1617
|
+
}
|
|
1618
|
+
wake() {
|
|
1619
|
+
const waiters = this.wakeWaiters;
|
|
1620
|
+
this.wakeWaiters = [];
|
|
1621
|
+
for (const w of waiters) w();
|
|
1622
|
+
}
|
|
1623
|
+
waitForWork() {
|
|
1624
|
+
return new Promise((resolve) => this.wakeWaiters.push(resolve));
|
|
1625
|
+
}
|
|
1626
|
+
nextChunk() {
|
|
1627
|
+
while (this.rotation.length > 0) {
|
|
1628
|
+
const id = this.rotation.shift();
|
|
1629
|
+
this.inRotation.delete(id);
|
|
1630
|
+
const q = this.outbox.get(id);
|
|
1631
|
+
if (!q || q.length === 0) continue;
|
|
1632
|
+
const item = q.shift();
|
|
1633
|
+
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
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
if (q.length > 0) this.markReady(id);
|
|
1642
|
+
return { streamId: id, item };
|
|
1643
|
+
}
|
|
1644
|
+
return null;
|
|
1645
|
+
}
|
|
1646
|
+
startWriter() {
|
|
1647
|
+
if (this.writerRunning) return;
|
|
1648
|
+
this.writerRunning = true;
|
|
1649
|
+
void this.runWriter();
|
|
1650
|
+
}
|
|
1651
|
+
async runWriter() {
|
|
1652
|
+
while (!this.closed) {
|
|
1653
|
+
while (this.controlQueue.length > 0) {
|
|
1654
|
+
const item = this.controlQueue.shift();
|
|
1655
|
+
try {
|
|
1656
|
+
await this.writeRaw(item.frame);
|
|
1657
|
+
item.resolve();
|
|
1658
|
+
} catch (e) {
|
|
1659
|
+
item.reject(e);
|
|
1660
|
+
return;
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
const next = this.nextChunk();
|
|
1664
|
+
if (next) {
|
|
1665
|
+
try {
|
|
1666
|
+
await this.writeRaw(encodeData(next.streamId, next.item.chunk));
|
|
1667
|
+
next.item.resolve();
|
|
1668
|
+
} catch (e) {
|
|
1669
|
+
next.item.reject(e);
|
|
1670
|
+
return;
|
|
1671
|
+
}
|
|
1672
|
+
continue;
|
|
1673
|
+
}
|
|
1674
|
+
await this.waitForWork();
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
/**
|
|
1678
|
+
* Writes one frame and resolves once `ws.send`'s callback reports it
|
|
1679
|
+
* flushed. This is how rule 2 (§2.6, "gate the write loop on the socket
|
|
1680
|
+
* send buffer") is satisfied without polling `bufferedAmount` on a timer:
|
|
1681
|
+
* `ws`'s `send(data, cb)` callback fires only once the chunk has actually
|
|
1682
|
+
* been handed to the socket, so awaiting it before writing the next frame
|
|
1683
|
+
* already keeps exactly one write in flight and applies backpressure for
|
|
1684
|
+
* free (runWriter's loop awaits writeRaw() before picking the next chunk).
|
|
1685
|
+
*/
|
|
1686
|
+
writeRaw(frame) {
|
|
1687
|
+
return new Promise((resolve, reject) => {
|
|
1688
|
+
if (this.closed) {
|
|
1689
|
+
reject(new Error("connection closed"));
|
|
1690
|
+
return;
|
|
1691
|
+
}
|
|
1692
|
+
try {
|
|
1693
|
+
this.ws.send(frame, (err) => {
|
|
1694
|
+
if (err) {
|
|
1695
|
+
reject(err);
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
this.counters.bytesOut += frame.length;
|
|
1699
|
+
resolve();
|
|
1700
|
+
});
|
|
1701
|
+
} catch (e) {
|
|
1702
|
+
reject(e);
|
|
1703
|
+
}
|
|
1704
|
+
});
|
|
1705
|
+
}
|
|
1706
|
+
// --- app messages -------------------------------------------------------------
|
|
1707
|
+
/** Resolves once the `app` frame is actually written to the socket, or rejects with the connection's terminal error if it fails first. */
|
|
1708
|
+
sendApp(body) {
|
|
1709
|
+
return this.sendControl({ t: "app", body });
|
|
1710
|
+
}
|
|
1711
|
+
// --- keepalive ------------------------------------------------------------------
|
|
1712
|
+
startKeepalive() {
|
|
1713
|
+
const jitter = Math.random() * this.pingIntervalMs;
|
|
1714
|
+
this.jitterTimer = setTimeout(() => {
|
|
1715
|
+
this.jitterTimer = null;
|
|
1716
|
+
if (this.closed) return;
|
|
1717
|
+
this.sendPing();
|
|
1718
|
+
this.pingTimer = setInterval(() => this.sendPing(), this.pingIntervalMs);
|
|
1719
|
+
}, jitter);
|
|
1720
|
+
this.watchdogTimer = setInterval(() => this.checkWatchdog(), Math.min(1e3, this.pingTimeoutMs));
|
|
1721
|
+
}
|
|
1722
|
+
sendPing() {
|
|
1723
|
+
if (this.closed) return;
|
|
1724
|
+
const id = this.nextPingId++;
|
|
1725
|
+
this.outstandingPings.set(id, Date.now());
|
|
1726
|
+
void this.sendControl({ t: "ping", id, ts: Date.now() }).catch(() => {
|
|
1727
|
+
});
|
|
1728
|
+
}
|
|
1729
|
+
/**
|
|
1730
|
+
* Pong watermark scheme, mirroring go/wsmixer/dispatch.go's handlePong:
|
|
1731
|
+
* every id below lowestUnacked has been acked (or pruned as stale) at
|
|
1732
|
+
* least once, and no id >= nextPingId has ever been sent.
|
|
1733
|
+
* - id >= nextPingId: never sent -> PROTOCOL_ERROR, connection-fatal.
|
|
1734
|
+
* - id < lowestUnacked, or in-range but already absent from the map
|
|
1735
|
+
* (already acked, or pruned by the watchdog as stale/late): a
|
|
1736
|
+
* duplicate or late pong -- tolerated, just counted.
|
|
1737
|
+
*/
|
|
1738
|
+
handlePong(id) {
|
|
1739
|
+
if (id >= this.nextPingId) {
|
|
1740
|
+
throw new ConnError(ErrorCode.PROTOCOL_ERROR, `pong for id ${id} that was never sent`);
|
|
1741
|
+
}
|
|
1742
|
+
if (id < this.lowestUnacked) {
|
|
1743
|
+
this.counters.duplicatePongs++;
|
|
1744
|
+
return;
|
|
1745
|
+
}
|
|
1746
|
+
const sentAt = this.outstandingPings.get(id);
|
|
1747
|
+
if (sentAt === void 0) {
|
|
1748
|
+
this.counters.duplicatePongs++;
|
|
1749
|
+
return;
|
|
1750
|
+
}
|
|
1751
|
+
this.outstandingPings.delete(id);
|
|
1752
|
+
if (id === this.lowestUnacked) {
|
|
1753
|
+
this.lowestUnacked++;
|
|
1754
|
+
while (this.lowestUnacked < this.nextPingId && !this.outstandingPings.has(this.lowestUnacked)) {
|
|
1755
|
+
this.lowestUnacked++;
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
this.lastPongAt = Date.now();
|
|
1759
|
+
this.emit("pong", { id, rttMs: Date.now() - sentAt });
|
|
1760
|
+
}
|
|
1761
|
+
checkWatchdog() {
|
|
1762
|
+
if (this.closed) return;
|
|
1763
|
+
const elapsed = Date.now() - this.lastPongAt;
|
|
1764
|
+
for (const [id, sentAt] of this.outstandingPings) {
|
|
1765
|
+
if (Date.now() - sentAt > this.pingTimeoutMs) this.outstandingPings.delete(id);
|
|
1766
|
+
}
|
|
1767
|
+
if (elapsed > this.pingTimeoutMs) {
|
|
1768
|
+
this.fail(new ConnError(ErrorCode.KEEPALIVE_TIMEOUT, `no pong received for ${elapsed}ms`));
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
// --- shutdown -----------------------------------------------------------------
|
|
1772
|
+
/**
|
|
1773
|
+
* Shared teardown: WS close `4000 + code`, reject everything outstanding,
|
|
1774
|
+
* emit `'fatal'`/`'error'` (guarded) then `'close'`, synchronously and in
|
|
1775
|
+
* that order every time. `sendErrorFrame` controls whether an
|
|
1776
|
+
* `error{code,message}` control frame is sent first -- true for a
|
|
1777
|
+
* locally-detected failure (fail()), false when the peer already sent its
|
|
1778
|
+
* own `error` and WIRE.md section 2.7 forbids replying with another
|
|
1779
|
+
* one (handlePeerError()). Either way `this.closed` is set *before*
|
|
1780
|
+
* `ws.close()` is called, so the peer's echo of this close (or any close
|
|
1781
|
+
* frame it happens to send around the same time) is ignored by
|
|
1782
|
+
* onSocketClose below rather than reported as `closeReason` -- per
|
|
1783
|
+
* CLIENT-SDK.md's `closeReason` row, an outgoing reason this side sent is
|
|
1784
|
+
* never legitimate `closeReason` data, and there is nothing to gain by
|
|
1785
|
+
* waiting for the peer's own close frame here: WIRE.md section 2.7
|
|
1786
|
+
* already allows closing on `error` "without reading the close frame that
|
|
1787
|
+
* followed".
|
|
1788
|
+
*/
|
|
1789
|
+
teardownConn(err, sendErrorFrame, streamErrorFactory) {
|
|
1790
|
+
if (this.closed) return;
|
|
1791
|
+
this.closed = true;
|
|
1792
|
+
this.stopTimers();
|
|
1793
|
+
if (sendErrorFrame) {
|
|
1794
|
+
try {
|
|
1795
|
+
const frame = encodeData(0, encodeControl({ t: "error", code: err.code, message: err.message }));
|
|
1796
|
+
this.ws.send(frame);
|
|
1797
|
+
this.counters.bytesOut += frame.length;
|
|
1798
|
+
} catch {
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
const wsCode = closeCode(err.code);
|
|
1802
|
+
const reason = truncateUtf8(err.message, 123);
|
|
1803
|
+
try {
|
|
1804
|
+
this.ws.close(wireCloseCode(wsCode), reason);
|
|
1805
|
+
} catch {
|
|
1806
|
+
this.ws.terminate?.();
|
|
1807
|
+
}
|
|
1808
|
+
this.rejectOutstanding(err, streamErrorFactory);
|
|
1809
|
+
if (!this.handshakeDone) this.emit("__handshake_failed_internal", err);
|
|
1810
|
+
const fatal = err.code === ErrorCode.UNSUPPORTED || err.code === ErrorCode.UNAUTHORIZED;
|
|
1811
|
+
const evt = fatal ? "fatal" : "error";
|
|
1812
|
+
if (this.listenerCount(evt) > 0) this.emit(evt, err);
|
|
1813
|
+
this.emit("close", { wsCode, errorCode: err.code, message: err.message });
|
|
1814
|
+
}
|
|
1815
|
+
/**
|
|
1816
|
+
* Connection-fatal failure path: error{code,message} on stream 0, WS close
|
|
1817
|
+
* 4000+code, teardown. Three steps, in order (WIRE.md section 2.8).
|
|
1818
|
+
*
|
|
1819
|
+
* `streamErrorFactory`, when given, overrides the error every live stream
|
|
1820
|
+
* is torn down with (`err` is still used for the connection-level frame/
|
|
1821
|
+
* close/rejects) -- used by MixerClient when it fails a *superseded*
|
|
1822
|
+
* (drained) conn: the streams on it aren't erroring, the conn they were
|
|
1823
|
+
* riding on is being replaced, so each gets a stream-scoped CANCEL
|
|
1824
|
+
* ("connection drained") instead of the connection's own NO_ERROR.
|
|
1825
|
+
*
|
|
1826
|
+
* Synchronous, but any stream/app/drain event still queued for delivery at
|
|
1827
|
+
* this point is not: runDeliveryLoop keeps flushing it after this returns
|
|
1828
|
+
* (see close()'s doc comment above for why).
|
|
1829
|
+
*/
|
|
1830
|
+
fail(err, streamErrorFactory) {
|
|
1831
|
+
this.teardownConn(err, true, streamErrorFactory);
|
|
1832
|
+
}
|
|
1833
|
+
/**
|
|
1834
|
+
* The peer sent `error{code,message}`: record it and close with
|
|
1835
|
+
* `4000 + code` immediately, without waiting for the peer to do anything
|
|
1836
|
+
* else -- mirrors go/wsmixer/dispatch.go's handlePeerError. `error` is
|
|
1837
|
+
* always the last message on the wire (WIRE.md section 2.7), so
|
|
1838
|
+
* there is nothing left to negotiate.
|
|
1839
|
+
*/
|
|
1840
|
+
handlePeerError(code, message, streamId) {
|
|
1841
|
+
const err = new WsMixerError(code, message, { streamId });
|
|
1842
|
+
this.teardownConn(err, false);
|
|
1843
|
+
}
|
|
1844
|
+
/**
|
|
1845
|
+
* Graceful client shutdown: drain{client_requested}, brief grace period,
|
|
1846
|
+
* then close(NO_ERROR). This resolves once teardownConn's synchronous
|
|
1847
|
+
* steps are done (frame/WS close/rejects/'close' emitted) -- it does NOT
|
|
1848
|
+
* wait for runDeliveryLoop to finish flushing whatever stream/app/drain
|
|
1849
|
+
* backlog was still queued at that point. A handler for an event received
|
|
1850
|
+
* before this close MAY therefore still be invoked shortly after this
|
|
1851
|
+
* promise (and 'close') has already resolved/fired (CLIENT-SDK.md's
|
|
1852
|
+
* "Handler delivery" row).
|
|
1853
|
+
*/
|
|
1854
|
+
async close(graceMs = 5e3) {
|
|
1855
|
+
if (this.closed) return;
|
|
1856
|
+
if (this.handshakeDone) {
|
|
1857
|
+
void this.sendControl({ t: "drain", reason: "client_requested", last_stream_id: this.highestOpened }).catch(() => {
|
|
1858
|
+
});
|
|
1859
|
+
if (this.streams.size > 0) {
|
|
1860
|
+
let graceTimer;
|
|
1861
|
+
let onClose;
|
|
1862
|
+
try {
|
|
1863
|
+
await Promise.race([
|
|
1864
|
+
this.waitForEmpty(),
|
|
1865
|
+
new Promise((resolve) => {
|
|
1866
|
+
graceTimer = setTimeout(resolve, graceMs);
|
|
1867
|
+
}),
|
|
1868
|
+
new Promise((resolve) => {
|
|
1869
|
+
onClose = () => resolve();
|
|
1870
|
+
this.once("close", onClose);
|
|
1871
|
+
})
|
|
1872
|
+
]);
|
|
1873
|
+
} finally {
|
|
1874
|
+
if (graceTimer) clearTimeout(graceTimer);
|
|
1875
|
+
if (onClose) this.off("close", onClose);
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
if (this.closed) return;
|
|
1880
|
+
this.fail(new WsMixerError(ErrorCode.NO_ERROR, "client closing"));
|
|
1881
|
+
}
|
|
1882
|
+
/**
|
|
1883
|
+
* A close frame this side actually *received* from `ws`'s 'close' event.
|
|
1884
|
+
* `code`/`reason` here are always the peer's, per `ws`'s own contract
|
|
1885
|
+
* (`receiverOnConclude` in `ws/lib/websocket.js` only ever updates
|
|
1886
|
+
* `_closeCode`/`_closeMessage` from a frame it parsed off the wire, never
|
|
1887
|
+
* from what this side sent via `.close()`) -- so whenever `this.closed` is
|
|
1888
|
+
* already `true`, this is a close frame arriving after teardownConn()
|
|
1889
|
+
* already ran and reported (never our own echoed close winning a race),
|
|
1890
|
+
* and is correctly ignored rather than folded in after the fact.
|
|
1891
|
+
*
|
|
1892
|
+
* Pre-welcome, this is also the SOLE reporter for a transport failure
|
|
1893
|
+
* before `welcome` -- measured against real `ws` (8.21): a TCP reset,
|
|
1894
|
+
* half-close or peer close frame after the 101 delivers a bare 'close'
|
|
1895
|
+
* with no 'error' at all, and a protocol-level failure `ws` itself
|
|
1896
|
+
* detects (an invalid frame: 1002, an oversized message: 1009, ...)
|
|
1897
|
+
* delivers 'error' immediately followed by 'close' a macrotask or more
|
|
1898
|
+
* later -- 'close' is never racing 'error' for which one wins; it always
|
|
1899
|
+
* arrives after it, if it arrives at all. `onSocketError` below therefore
|
|
1900
|
+
* never finalizes anything itself, only records the error's message here
|
|
1901
|
+
* for `message` to fall back on when the close carries no reason of its
|
|
1902
|
+
* own -- so this always reports the *actual* close code `ws` delivers
|
|
1903
|
+
* (1006, or whatever real close code it sent), never a wsCode fabricated
|
|
1904
|
+
* for a close that never happened.
|
|
1905
|
+
*/
|
|
1906
|
+
onSocketClose(code, reason) {
|
|
1907
|
+
if (this.closed) return;
|
|
1908
|
+
this.closed = true;
|
|
1909
|
+
this.stopTimers();
|
|
1910
|
+
const reasonStr = reason.toString();
|
|
1911
|
+
const handshakePhase = !this.handshakeDone;
|
|
1912
|
+
const message = reasonStr || (handshakePhase ? this.pendingHandshakeErrorMessage : void 0) || `socket closed with code ${code}`;
|
|
1913
|
+
const err = new WsMixerError(ErrorCode.INTERNAL_ERROR, message, {
|
|
1914
|
+
wsCode: code,
|
|
1915
|
+
closeReason: reasonStr || void 0
|
|
1916
|
+
});
|
|
1917
|
+
this.rejectOutstanding(err);
|
|
1918
|
+
if (handshakePhase) this.emit("__handshake_failed_internal", err);
|
|
1919
|
+
this.emit("close", { wsCode: code, message, closeReason: reasonStr || void 0 });
|
|
1920
|
+
}
|
|
1921
|
+
/**
|
|
1922
|
+
* Pre-welcome, `'error'` never arrives in the same microtask as (or after)
|
|
1923
|
+
* a 'close' that never comes -- see onSocketClose's own doc comment for
|
|
1924
|
+
* what real `ws` actually does. So this deliberately does NOT reject or
|
|
1925
|
+
* finalize the handshake itself; it just records the error's message for
|
|
1926
|
+
* onSocketClose (the sole reporter) to use as `message` when the close
|
|
1927
|
+
* that follows carries no reason of its own. If `'close'` somehow never
|
|
1928
|
+
* follows at all (a transport that violates `ws`'s own contract), nothing
|
|
1929
|
+
* here is left waiting on it: `handshake()`'s own hello/welcome timeout
|
|
1930
|
+
* (`handshakeTimer`, 10s default) still fires and produces the one report
|
|
1931
|
+
* (`wsCode` 4001) regardless -- deliberately the only fallback for that
|
|
1932
|
+
* case, not a second mechanism grafted on here.
|
|
1933
|
+
*/
|
|
1934
|
+
onSocketError(err) {
|
|
1935
|
+
if (!this.handshakeDone && !this.closed) {
|
|
1936
|
+
this.pendingHandshakeErrorMessage = err.message;
|
|
1937
|
+
}
|
|
1938
|
+
if (this.listenerCount("error") > 0) this.emit("error", new WsMixerError(ErrorCode.INTERNAL_ERROR, err.message));
|
|
1939
|
+
}
|
|
1940
|
+
stopTimers() {
|
|
1941
|
+
if (this.pingTimer) clearInterval(this.pingTimer);
|
|
1942
|
+
if (this.watchdogTimer) clearInterval(this.watchdogTimer);
|
|
1943
|
+
if (this.handshakeTimer) clearTimeout(this.handshakeTimer);
|
|
1944
|
+
if (this.jitterTimer) clearTimeout(this.jitterTimer);
|
|
1945
|
+
this.pingTimer = null;
|
|
1946
|
+
this.watchdogTimer = null;
|
|
1947
|
+
this.handshakeTimer = null;
|
|
1948
|
+
this.jitterTimer = null;
|
|
1949
|
+
}
|
|
1950
|
+
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();
|
|
1955
|
+
for (const item of this.controlQueue.splice(0, this.controlQueue.length)) {
|
|
1956
|
+
item.reject(err);
|
|
1957
|
+
}
|
|
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
|
+
);
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
isDraining() {
|
|
1966
|
+
return this.draining;
|
|
1967
|
+
}
|
|
1968
|
+
liveStreamCount() {
|
|
1969
|
+
return this.streams.size;
|
|
1970
|
+
}
|
|
1971
|
+
};
|
|
1972
|
+
|
|
1973
|
+
// src/client.ts
|
|
1974
|
+
var SUBPROTOCOL = "ws-mixer.v1";
|
|
1975
|
+
var SDK_VERSION = "0.6.0";
|
|
1976
|
+
async function resolveToken(token) {
|
|
1977
|
+
return typeof token === "function" ? await token() : token;
|
|
1978
|
+
}
|
|
1979
|
+
var DEFAULT_RECONNECT = {
|
|
1980
|
+
base: 1e3,
|
|
1981
|
+
cap: 6e4,
|
|
1982
|
+
connectTimeout: 1e4,
|
|
1983
|
+
maxAttempts: Infinity,
|
|
1984
|
+
stableAfter: 1e4
|
|
1985
|
+
};
|
|
1986
|
+
var FATAL_WS_CODES = /* @__PURE__ */ new Set([4e3 + ErrorCode.UNSUPPORTED, 4e3 + ErrorCode.UNAUTHORIZED]);
|
|
1987
|
+
var GOING_AWAY_WS_CODE = 4e3 + ErrorCode.GOING_AWAY;
|
|
1988
|
+
var KEEPALIVE_TIMEOUT_WS_CODE = 4e3 + ErrorCode.KEEPALIVE_TIMEOUT;
|
|
1989
|
+
var ENHANCE_YOUR_CALM_WS_CODE = 4e3 + ErrorCode.ENHANCE_YOUR_CALM;
|
|
1990
|
+
var APPLICATION_CLOSE_WS_CODE = 4e3 + ErrorCode.APPLICATION_CLOSE;
|
|
1991
|
+
var ABNORMAL_CLOSURE_WS_CODE = 1001;
|
|
1992
|
+
var PROTOCOL_BUG_WS_CODES = /* @__PURE__ */ new Set([
|
|
1993
|
+
4e3 + ErrorCode.PROTOCOL_ERROR,
|
|
1994
|
+
// 4001
|
|
1995
|
+
4e3 + ErrorCode.FLOW_CONTROL_ERROR,
|
|
1996
|
+
// 4003
|
|
1997
|
+
4e3 + ErrorCode.FRAME_SIZE_ERROR
|
|
1998
|
+
// 4004
|
|
1999
|
+
]);
|
|
2000
|
+
function deriveErrorCodeName(wsCode, knownErrorCode) {
|
|
2001
|
+
const errorCode = knownErrorCode ?? (wsCode !== void 0 && wsCode >= 4001 && wsCode <= 4999 ? wsCode - 4e3 : void 0);
|
|
2002
|
+
return { errorCode, errorName: errorCode !== void 0 ? codeName(errorCode) : void 0 };
|
|
2003
|
+
}
|
|
2004
|
+
var MixerClient = class extends import_node_events2.EventEmitter {
|
|
2005
|
+
url;
|
|
2006
|
+
opts;
|
|
2007
|
+
reconnectOpts;
|
|
2008
|
+
state = "idle";
|
|
2009
|
+
/** The confirmed-live connection (welcome received). Null while dialing/backing off. */
|
|
2010
|
+
conn = null;
|
|
2011
|
+
/** A MixerConn mid-handshake, not yet promoted to `conn`. Tracked so close() can tear it down too. */
|
|
2012
|
+
dialingConn = null;
|
|
2013
|
+
/**
|
|
2014
|
+
* Set only while a `drain`-triggered parallel reconnect is in flight: the
|
|
2015
|
+
* connection that sent `drain`, still alive and finishing in-flight
|
|
2016
|
+
* streams. Torn down (fail()) as soon as its replacement's `welcome`
|
|
2017
|
+
* lands, so a superseded connection never lingers past that point.
|
|
2018
|
+
*/
|
|
2019
|
+
retiringConn = null;
|
|
2020
|
+
attempt = 0;
|
|
2021
|
+
everConnected = false;
|
|
2022
|
+
closing = false;
|
|
2023
|
+
fatal = false;
|
|
2024
|
+
/** Set when `drain` already started a parallel reconnect, so the close that follows it doesn't schedule a second one. */
|
|
2025
|
+
drainReconnectScheduled = false;
|
|
2026
|
+
/**
|
|
2027
|
+
* Set when `drain` arrives with reconnect disabled (`maxAttempts:0`):
|
|
2028
|
+
* WIRE.md section 2.9 says in-flight streams finish normally until the
|
|
2029
|
+
* server's own deadline, at which point it closes with 4012 -- so the conn
|
|
2030
|
+
* stays up and this flag just tells the eventual close handler to report
|
|
2031
|
+
* that close as the one fatal "drained; reconnect disabled" disconnect
|
|
2032
|
+
* instead of treating 4012 as an ordinary going-away reconnect trigger.
|
|
2033
|
+
*/
|
|
2034
|
+
drainedNoReconnect = false;
|
|
2035
|
+
/** WIRE.md section 2.9: close 4013 gets exactly one immediate retry before falling back to normal backoff. Re-armed at stability, not at welcome -- see armStabilityTimer. */
|
|
2036
|
+
keepaliveImmediateRetryUsed = false;
|
|
2037
|
+
/**
|
|
2038
|
+
* CLIENT-SDK.md's "Rejected token" row: a pre-welcome token rejection (an HTTP 401 on
|
|
2039
|
+
* the upgrade, or a handshake-phase UNAUTHORIZED/4011, with or without a
|
|
2040
|
+
* preceding `error{}`) gets exactly one immediate provider refresh-retry
|
|
2041
|
+
* -- ONE budget shared across both rejection shapes, and across every
|
|
2042
|
+
* `connectOnce()` call, not reset on every dial/redial. Re-armed only at
|
|
2043
|
+
* stability (armStabilityTimer), same as `keepaliveImmediateRetryUsed`:
|
|
2044
|
+
* without that, a server that welcomes and then closes shortly after could
|
|
2045
|
+
* make the client hit the token endpoint again on every single reconnect
|
|
2046
|
+
* cycle forever, instead of only once per genuinely-unstable run. After a
|
|
2047
|
+
* long healthy (stable) session, the budget is available again -- an
|
|
2048
|
+
* ordinary token expiry on some later reconnect still gets its retry.
|
|
2049
|
+
*/
|
|
2050
|
+
unauthorizedRetryUsed = false;
|
|
2051
|
+
reconnectTimer = null;
|
|
2052
|
+
/** Resolves connectOnce's backoff-delay await; settled directly by clearReconnectTimer() so close()/goFatal()/giveUp() during backoff don't leave that await dangling forever. */
|
|
2053
|
+
reconnectTimerResolve = null;
|
|
2054
|
+
/**
|
|
2055
|
+
* One-shot timer armed on `welcome` (armStabilityTimer): fires once the
|
|
2056
|
+
* *current* conn has stayed up for `reconnectOpts.stableAfter` ms, at
|
|
2057
|
+
* which point `attempt` and the once-only retry budgets it gates
|
|
2058
|
+
* (`keepaliveImmediateRetryUsed`, `unauthorizedRetryUsed`) reset.
|
|
2059
|
+
* `unref()`'d so it never keeps the process alive, and cleared on every
|
|
2060
|
+
* path that ends a conn or the client itself (conn close, close()/
|
|
2061
|
+
* close({message}), goFatal, a drain hand-over's retired conn) so a stale
|
|
2062
|
+
* timer can never fire against a conn that's no longer the active one --
|
|
2063
|
+
* though the `this.conn === conn` check inside it is also always
|
|
2064
|
+
* re-verified regardless, belt and suspenders. Per-connection: a
|
|
2065
|
+
* drain-superseded predecessor closing well after its replacement's own
|
|
2066
|
+
* `welcome` must never clear the replacement's still-ticking timer (see
|
|
2067
|
+
* the `wasActive` guard in wireConn's 'close' handler below).
|
|
2068
|
+
*/
|
|
2069
|
+
stabilityTimer = null;
|
|
2070
|
+
startResolve = null;
|
|
2071
|
+
startReject = null;
|
|
2072
|
+
constructor(url, opts) {
|
|
2073
|
+
super();
|
|
2074
|
+
this.url = url;
|
|
2075
|
+
this.opts = opts;
|
|
2076
|
+
this.reconnectOpts = { ...DEFAULT_RECONNECT, ...opts.reconnect };
|
|
2077
|
+
if (!Number.isFinite(this.reconnectOpts.stableAfter) || this.reconnectOpts.stableAfter < 0) {
|
|
2078
|
+
throw new RangeError(
|
|
2079
|
+
`ws-mixer: reconnect.stableAfter must be a finite number >= 0; got ${this.reconnectOpts.stableAfter}`
|
|
2080
|
+
);
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
/** Starts the first connection attempt and resolves once `welcome` completes (or rejects if it never gets there and reconnecting is pointless: a fatal auth failure, or maxAttempts exhausted before the first success). */
|
|
2084
|
+
async start() {
|
|
2085
|
+
return new Promise((resolve, reject) => {
|
|
2086
|
+
this.startResolve = resolve;
|
|
2087
|
+
this.startReject = reject;
|
|
2088
|
+
void this.connectOnce(0, "initial");
|
|
2089
|
+
});
|
|
2090
|
+
}
|
|
2091
|
+
/** The currently active MixerConn, if connected. */
|
|
2092
|
+
currentConn() {
|
|
2093
|
+
return this.conn;
|
|
2094
|
+
}
|
|
2095
|
+
/** Current reconnect state machine position: idle/dialing/connected/backoff/closed. */
|
|
2096
|
+
currentState() {
|
|
2097
|
+
return this.state;
|
|
2098
|
+
}
|
|
2099
|
+
/**
|
|
2100
|
+
* Writes an `app` frame; resolves once `ws.send`'s callback confirms it
|
|
2101
|
+
* actually reached the socket (docs/DESIGN.md), or rejects with the
|
|
2102
|
+
* connection's terminal error if it fails first -- conn.ts's control queue
|
|
2103
|
+
* carries the same per-write resolver `sendData()` already uses for stream
|
|
2104
|
+
* bytes.
|
|
2105
|
+
*/
|
|
2106
|
+
sendApp(body) {
|
|
2107
|
+
if (!this.conn) return Promise.reject(new Error("ws-mixer: sendApp called with no active connection"));
|
|
2108
|
+
return this.conn.sendApp(body);
|
|
2109
|
+
}
|
|
2110
|
+
/** "Ignore and count" counters for the active connection (item 11); `undefined` when there is none. */
|
|
2111
|
+
stats() {
|
|
2112
|
+
return this.conn?.stats();
|
|
2113
|
+
}
|
|
2114
|
+
/**
|
|
2115
|
+
* Graceful client shutdown: stops reconnecting for good and closes every
|
|
2116
|
+
* live/in-flight connection. A dial already in flight when this is called
|
|
2117
|
+
* is closed as soon as it resolves (see connectOnce); nothing reconnects
|
|
2118
|
+
* after this returns.
|
|
2119
|
+
*
|
|
2120
|
+
* With `opts.message`, this is instead an application-initiated close
|
|
2121
|
+
* (CLIENT-SDK.md's "Application close" row): `error{code:
|
|
2122
|
+
* ErrorCode.APPLICATION_CLOSE, message}` on stream 0, WS close `4014`
|
|
2123
|
+
* (message truncated to 123 UTF-8 bytes on a character boundary), then
|
|
2124
|
+
* the socket -- `MixerConn.fail()`'s `teardownConn` already performs
|
|
2125
|
+
* exactly those three steps in order. No `drain`, no grace period.
|
|
2126
|
+
* `APPLICATION_CLOSE` is the only code an application may close a
|
|
2127
|
+
* *connection* with (WIRE.md section 2.8), so there is no caller-chosen
|
|
2128
|
+
* code here (D-2026-09-25-01): an `opts` with a `code` key (a plain-JS
|
|
2129
|
+
* caller still on the old `close({code, message})` shape) throws a
|
|
2130
|
+
* `TypeError` synchronously, before either connection is touched.
|
|
2131
|
+
*
|
|
2132
|
+
* A callback for a stream/`app`/`drain` event received before this close
|
|
2133
|
+
* can still fire shortly after this promise resolves: it doesn't wait for
|
|
2134
|
+
* `onStream`/`onApp`/`onDrain`'s delivery loop to finish flushing whatever
|
|
2135
|
+
* was already queued (CLIENT-SDK.md's "Handler delivery" row) -- see those
|
|
2136
|
+
* options' own doc comments.
|
|
2137
|
+
*/
|
|
2138
|
+
close(opts) {
|
|
2139
|
+
if (typeof opts === "object" && opts !== null && Object.prototype.hasOwnProperty.call(opts, "code")) {
|
|
2140
|
+
throw new TypeError(
|
|
2141
|
+
"ws-mixer: close() no longer takes a code (D-2026-09-25-01); use close({message}) -- it always closes with APPLICATION_CLOSE"
|
|
2142
|
+
);
|
|
2143
|
+
}
|
|
2144
|
+
return this.closeImpl(opts);
|
|
2145
|
+
}
|
|
2146
|
+
async closeImpl(opts) {
|
|
2147
|
+
this.closing = true;
|
|
2148
|
+
this.state = "closed";
|
|
2149
|
+
this.drainedNoReconnect = false;
|
|
2150
|
+
this.rejectStartIfNeverConnected(new Error("ws-mixer: closed before the first connection completed"));
|
|
2151
|
+
this.clearReconnectTimer();
|
|
2152
|
+
this.clearStabilityTimer();
|
|
2153
|
+
const closeErr = opts?.message !== void 0 ? new WsMixerError(ErrorCode.APPLICATION_CLOSE, opts.message) : new WsMixerError(ErrorCode.NO_ERROR, "client closing");
|
|
2154
|
+
if (this.retiringConn) {
|
|
2155
|
+
const old = this.retiringConn;
|
|
2156
|
+
this.retiringConn = null;
|
|
2157
|
+
old.fail(closeErr);
|
|
2158
|
+
}
|
|
2159
|
+
if (this.dialingConn) {
|
|
2160
|
+
const dialing = this.dialingConn;
|
|
2161
|
+
this.dialingConn = null;
|
|
2162
|
+
dialing.fail(closeErr);
|
|
2163
|
+
}
|
|
2164
|
+
if (this.conn) {
|
|
2165
|
+
if (opts?.message !== void 0) {
|
|
2166
|
+
this.conn.fail(closeErr);
|
|
2167
|
+
} else {
|
|
2168
|
+
await this.conn.close();
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
// this.state is a plain string-literal-union property, and TS's control
|
|
2173
|
+
// flow narrowing does *not* invalidate a narrowed `this.state === "x"`
|
|
2174
|
+
// across an `await` (even though the awaited call can, and here does,
|
|
2175
|
+
// reassign it) -- so connectOnce below reads it through this indirection
|
|
2176
|
+
// to force a fresh, unnarrowed check every time.
|
|
2177
|
+
isClosed() {
|
|
2178
|
+
return this.state === "closed";
|
|
2179
|
+
}
|
|
2180
|
+
/** Cancels a pending backoff timer, if any, and settles connectOnce's awaited delay promise so it doesn't dangle. */
|
|
2181
|
+
clearReconnectTimer() {
|
|
2182
|
+
if (this.reconnectTimer) {
|
|
2183
|
+
clearTimeout(this.reconnectTimer);
|
|
2184
|
+
this.reconnectTimer = null;
|
|
2185
|
+
}
|
|
2186
|
+
if (this.reconnectTimerResolve) {
|
|
2187
|
+
const resolve = this.reconnectTimerResolve;
|
|
2188
|
+
this.reconnectTimerResolve = null;
|
|
2189
|
+
resolve();
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
/** Cancels the pending stability timer, if any (see the field's own doc comment). Idempotent. */
|
|
2193
|
+
clearStabilityTimer() {
|
|
2194
|
+
if (this.stabilityTimer) {
|
|
2195
|
+
clearTimeout(this.stabilityTimer);
|
|
2196
|
+
this.stabilityTimer = null;
|
|
2197
|
+
}
|
|
2198
|
+
}
|
|
2199
|
+
/**
|
|
2200
|
+
* Arms the one-shot stability timer for `conn`, replacing (and so
|
|
2201
|
+
* implicitly clearing) whatever timer was pending before -- there is only
|
|
2202
|
+
* ever one conn worth timing at once: a drain-superseded predecessor is
|
|
2203
|
+
* torn down as soon as the replacement's own `welcome` lands (connectOnce),
|
|
2204
|
+
* which is exactly the moment this is called for the replacement, so the
|
|
2205
|
+
* predecessor's own now-irrelevant timer never outlives this call.
|
|
2206
|
+
*/
|
|
2207
|
+
armStabilityTimer(conn) {
|
|
2208
|
+
this.clearStabilityTimer();
|
|
2209
|
+
const timer = setTimeout(() => {
|
|
2210
|
+
this.stabilityTimer = null;
|
|
2211
|
+
if (this.conn === conn) {
|
|
2212
|
+
this.attempt = 0;
|
|
2213
|
+
this.keepaliveImmediateRetryUsed = false;
|
|
2214
|
+
this.unauthorizedRetryUsed = false;
|
|
2215
|
+
}
|
|
2216
|
+
}, this.reconnectOpts.stableAfter);
|
|
2217
|
+
timer.unref?.();
|
|
2218
|
+
this.stabilityTimer = timer;
|
|
2219
|
+
}
|
|
2220
|
+
async connectOnce(delayMs, cause) {
|
|
2221
|
+
if (this.closing || this.isClosed()) return;
|
|
2222
|
+
if (delayMs > 0) {
|
|
2223
|
+
this.state = "backoff";
|
|
2224
|
+
this.emit("reconnecting", { attempt: this.attempt, delayMs, cause });
|
|
2225
|
+
await new Promise((resolve) => {
|
|
2226
|
+
this.reconnectTimerResolve = resolve;
|
|
2227
|
+
this.reconnectTimer = setTimeout(resolve, delayMs);
|
|
2228
|
+
});
|
|
2229
|
+
this.reconnectTimer = null;
|
|
2230
|
+
this.reconnectTimerResolve = null;
|
|
2231
|
+
if (this.closing || this.isClosed()) return;
|
|
2232
|
+
}
|
|
2233
|
+
this.state = "dialing";
|
|
2234
|
+
const isProvider = typeof this.opts.token === "function";
|
|
2235
|
+
let attempt;
|
|
2236
|
+
for (; ; ) {
|
|
2237
|
+
attempt = await this.dialAndHandshakeOnce(this.reconnectOpts.connectTimeout);
|
|
2238
|
+
if (attempt.ok || attempt.cancelled) break;
|
|
2239
|
+
if (!this.unauthorizedRetryUsed && isProvider && attempt.unauthorized) {
|
|
2240
|
+
if (this.closing || this.isClosed()) break;
|
|
2241
|
+
this.unauthorizedRetryUsed = true;
|
|
2242
|
+
continue;
|
|
2243
|
+
}
|
|
2244
|
+
break;
|
|
2245
|
+
}
|
|
2246
|
+
if (attempt.ok) {
|
|
2247
|
+
const { conn, welcome } = attempt;
|
|
2248
|
+
if (this.closing || this.isClosed()) {
|
|
2249
|
+
if (this.dialingConn === conn) this.dialingConn = null;
|
|
2250
|
+
conn.fail(new WsMixerError(ErrorCode.NO_ERROR, "client closing"));
|
|
2251
|
+
return;
|
|
2252
|
+
}
|
|
2253
|
+
this.armStabilityTimer(conn);
|
|
2254
|
+
this.everConnected = true;
|
|
2255
|
+
this.dialingConn = null;
|
|
2256
|
+
this.conn = conn;
|
|
2257
|
+
this.state = "connected";
|
|
2258
|
+
if (this.retiringConn && this.retiringConn !== conn) {
|
|
2259
|
+
const old = this.retiringConn;
|
|
2260
|
+
this.retiringConn = null;
|
|
2261
|
+
this.drainReconnectScheduled = false;
|
|
2262
|
+
old.fail(
|
|
2263
|
+
new WsMixerError(ErrorCode.NO_ERROR, "superseded by a new connection"),
|
|
2264
|
+
(streamId) => new StreamError(ErrorCode.CANCEL, streamId, "connection drained")
|
|
2265
|
+
);
|
|
2266
|
+
}
|
|
2267
|
+
this.opts.onConnect?.(welcome);
|
|
2268
|
+
if (this.startResolve) {
|
|
2269
|
+
const resolve = this.startResolve;
|
|
2270
|
+
this.startResolve = null;
|
|
2271
|
+
this.startReject = null;
|
|
2272
|
+
resolve();
|
|
2273
|
+
}
|
|
2274
|
+
return;
|
|
2275
|
+
}
|
|
2276
|
+
if (attempt.cancelled) return;
|
|
2277
|
+
if (attempt.ctx.phase === "dial") {
|
|
2278
|
+
if (this.closing || this.isClosed()) return;
|
|
2279
|
+
if (attempt.fatal) {
|
|
2280
|
+
this.goFatal(attempt.ctx);
|
|
2281
|
+
return;
|
|
2282
|
+
}
|
|
2283
|
+
if (attempt.retryAfterMs !== void 0) {
|
|
2284
|
+
this.reportAndSchedule(attempt.ctx, attempt.ctx.message, () => attempt.retryAfterMs);
|
|
2285
|
+
return;
|
|
2286
|
+
}
|
|
2287
|
+
this.scheduleReconnect(attempt.ctx, attempt.ctx.message);
|
|
2288
|
+
return;
|
|
2289
|
+
}
|
|
2290
|
+
if (attempt.fatal) {
|
|
2291
|
+
this.goFatal(attempt.ctx);
|
|
2292
|
+
return;
|
|
2293
|
+
}
|
|
2294
|
+
if (this.closing || this.isClosed()) return;
|
|
2295
|
+
this.scheduleReconnect(attempt.ctx, "handshake failed: " + attempt.ctx.message);
|
|
2296
|
+
}
|
|
2297
|
+
/**
|
|
2298
|
+
* One dial + handshake attempt: resolves the token provider (fresh, per
|
|
2299
|
+
* CLIENT-SDK.md's "Token provider" row), opens the socket, and -- if that succeeds --
|
|
2300
|
+
* builds a `MixerConn` and waits for `welcome`. Returns a discriminated
|
|
2301
|
+
* result rather than throwing, so connectOnce's retry loop above can
|
|
2302
|
+
* decide what to do with a failure (retry once, or finalize it) without a
|
|
2303
|
+
* second round of reclassifying the same error: `unauthorized` is true for
|
|
2304
|
+
* exactly the two pre-welcome rejection shapes eligible for that retry (an
|
|
2305
|
+
* HTTP 401 on the upgrade, or a handshake-phase UNAUTHORIZED, 4011, with
|
|
2306
|
+
* or without a preceding `error{}`); `cancelled` is true when `close()`
|
|
2307
|
+
* raced the dial/handshake and there is nothing left to report.
|
|
2308
|
+
*/
|
|
2309
|
+
async dialAndHandshakeOnce(connectTimeoutMs) {
|
|
2310
|
+
let token;
|
|
2311
|
+
try {
|
|
2312
|
+
token = await resolveToken(this.opts.token);
|
|
2313
|
+
} catch (err) {
|
|
2314
|
+
const e = providerDialError(err);
|
|
2315
|
+
return {
|
|
2316
|
+
ok: false,
|
|
2317
|
+
ctx: { phase: "dial", cause: e.cause, message: e.message },
|
|
2318
|
+
// Fatal by default (CLIENT-SDK.md's "Provider failure" row) -- UNLESS the provider
|
|
2319
|
+
// explicitly marked this as a temporary failure to obtain a token
|
|
2320
|
+
// (TokenUnavailableError, thrown directly or reachable via `cause`),
|
|
2321
|
+
// in which case it's treated exactly like a failed dial: normal
|
|
2322
|
+
// backoff via the ordinary phase-"dial" path below, same as every
|
|
2323
|
+
// other non-auth dial failure.
|
|
2324
|
+
fatal: !isTokenUnavailable(err),
|
|
2325
|
+
unauthorized: false
|
|
2326
|
+
};
|
|
2327
|
+
}
|
|
2328
|
+
let ws;
|
|
2329
|
+
try {
|
|
2330
|
+
ws = await dialWebSocket(this.url, token, this.opts, connectTimeoutMs);
|
|
2331
|
+
} catch (e) {
|
|
2332
|
+
if (this.closing || this.isClosed()) return { ok: false, cancelled: true };
|
|
2333
|
+
const err = e;
|
|
2334
|
+
return {
|
|
2335
|
+
ok: false,
|
|
2336
|
+
ctx: {
|
|
2337
|
+
phase: "dial",
|
|
2338
|
+
httpStatus: err.httpStatus,
|
|
2339
|
+
cause: err.cause,
|
|
2340
|
+
message: err.message,
|
|
2341
|
+
errorCode: err.errorCode,
|
|
2342
|
+
errorName: err.errorCode !== void 0 ? codeName(err.errorCode) : void 0
|
|
2343
|
+
},
|
|
2344
|
+
fatal: err.fatal,
|
|
2345
|
+
// Only an HTTP 401 is eligible for the refresh-retry -- 403/404 are
|
|
2346
|
+
// fatal always, provider or not (dialWebSocket already marks both
|
|
2347
|
+
// `fatal`, this just decides which of them also gets one retry).
|
|
2348
|
+
unauthorized: err.httpStatus === 401,
|
|
2349
|
+
retryAfterMs: err.retryAfterMs
|
|
2350
|
+
};
|
|
2351
|
+
}
|
|
2352
|
+
if (this.closing || this.isClosed()) {
|
|
2353
|
+
try {
|
|
2354
|
+
ws.close(1e3, "client closing");
|
|
2355
|
+
} catch {
|
|
2356
|
+
ws.terminate?.();
|
|
2357
|
+
}
|
|
2358
|
+
return { ok: false, cancelled: true };
|
|
2359
|
+
}
|
|
2360
|
+
const conn = new MixerConn(ws, {
|
|
2361
|
+
token,
|
|
2362
|
+
agent: {
|
|
2363
|
+
sdk: "ws-mixer-js",
|
|
2364
|
+
sdk_version: SDK_VERSION,
|
|
2365
|
+
runtime: `node/${process.version.replace(/^v/, "")}`,
|
|
2366
|
+
os: `${process.platform}/${process.arch}`,
|
|
2367
|
+
...this.opts.agent
|
|
2368
|
+
},
|
|
2369
|
+
meta: this.opts.meta,
|
|
2370
|
+
window: this.opts.window,
|
|
2371
|
+
maxStreams: this.opts.maxStreams,
|
|
2372
|
+
capabilities: this.opts.capabilities,
|
|
2373
|
+
_timing: this.opts._timing
|
|
2374
|
+
});
|
|
2375
|
+
this.dialingConn = conn;
|
|
2376
|
+
this.wireConn(conn);
|
|
2377
|
+
try {
|
|
2378
|
+
const welcome = await conn.handshake();
|
|
2379
|
+
return { ok: true, conn, welcome };
|
|
2380
|
+
} catch (e) {
|
|
2381
|
+
this.dialingConn = null;
|
|
2382
|
+
const err = e;
|
|
2383
|
+
let wsCode = err.wsCode;
|
|
2384
|
+
let knownErrorCode;
|
|
2385
|
+
if (wsCode === void 0) {
|
|
2386
|
+
knownErrorCode = err.code;
|
|
2387
|
+
wsCode = closeCode(err.code);
|
|
2388
|
+
}
|
|
2389
|
+
const { errorCode, errorName } = deriveErrorCodeName(wsCode, knownErrorCode);
|
|
2390
|
+
return {
|
|
2391
|
+
ok: false,
|
|
2392
|
+
ctx: {
|
|
2393
|
+
phase: "handshake",
|
|
2394
|
+
wsCode,
|
|
2395
|
+
errorCode,
|
|
2396
|
+
errorName,
|
|
2397
|
+
message: err.message,
|
|
2398
|
+
closeReason: err.closeReason
|
|
2399
|
+
},
|
|
2400
|
+
fatal: err.fatal || errorCode === ErrorCode.UNSUPPORTED || errorCode === ErrorCode.UNAUTHORIZED,
|
|
2401
|
+
unauthorized: errorCode === ErrorCode.UNAUTHORIZED
|
|
2402
|
+
};
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
wireConn(conn) {
|
|
2406
|
+
conn.on("welcome", (w) => this.emit("welcome", w));
|
|
2407
|
+
conn.on("stream", (s) => {
|
|
2408
|
+
this.emit("stream", s);
|
|
2409
|
+
return this.opts.onStream?.(s);
|
|
2410
|
+
});
|
|
2411
|
+
conn.on("app", (b) => {
|
|
2412
|
+
this.emit("app", b);
|
|
2413
|
+
return this.opts.onApp?.(b);
|
|
2414
|
+
});
|
|
2415
|
+
conn.on("pong", (p) => this.emit("pong", p));
|
|
2416
|
+
conn.on("drain", (d) => {
|
|
2417
|
+
if (this.conn === conn && !this.closing && this.state !== "closed" && !this.drainReconnectScheduled) {
|
|
2418
|
+
if (this.reconnectOpts.maxAttempts > 0) {
|
|
2419
|
+
this.drainReconnectScheduled = true;
|
|
2420
|
+
this.retiringConn = conn;
|
|
2421
|
+
void this.connectOnce(Math.random() * 2e3, "drain");
|
|
2422
|
+
} else {
|
|
2423
|
+
this.drainedNoReconnect = true;
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
this.emit("drain", d);
|
|
2427
|
+
return this.opts.onDrain?.({ reason: d.reason, deadlineMs: d.deadline_ms, message: d.message, lastStreamId: d.last_stream_id });
|
|
2428
|
+
});
|
|
2429
|
+
conn.on("error", (err) => {
|
|
2430
|
+
if (this.listenerCount("error") > 0) this.emit("error", err);
|
|
2431
|
+
});
|
|
2432
|
+
conn.on("close", (info) => {
|
|
2433
|
+
const wasActive = this.conn === conn;
|
|
2434
|
+
if (this.conn === conn) this.conn = null;
|
|
2435
|
+
if (this.dialingConn === conn) this.dialingConn = null;
|
|
2436
|
+
if (this.retiringConn === conn) this.retiringConn = null;
|
|
2437
|
+
if (wasActive) this.clearStabilityTimer();
|
|
2438
|
+
if (!wasActive) return;
|
|
2439
|
+
const fatal = info.wsCode !== void 0 && FATAL_WS_CODES.has(info.wsCode);
|
|
2440
|
+
const protocolBug = info.wsCode !== void 0 && PROTOCOL_BUG_WS_CODES.has(info.wsCode);
|
|
2441
|
+
const ctx = protocolBug ? (
|
|
2442
|
+
// errorCode is only set when MixerConn itself detected the failure
|
|
2443
|
+
// (fail()'s teardownConn); a raw WS-level close (onSocketClose)
|
|
2444
|
+
// never carries one, even though wsCode = 4000 + code still tells
|
|
2445
|
+
// us exactly which one it was -- deriveErrorCodeName() derives it
|
|
2446
|
+
// rather than falling back to something less informative than the
|
|
2447
|
+
// wire already told us (PROTOCOL_BUG_WS_CODES is always a bare
|
|
2448
|
+
// ws-mixer code, so this always agrees with `code`/`name` below).
|
|
2449
|
+
(() => {
|
|
2450
|
+
const { errorCode, errorName } = deriveErrorCodeName(info.wsCode, info.errorCode);
|
|
2451
|
+
const code = errorCode;
|
|
2452
|
+
return {
|
|
2453
|
+
phase: "connected",
|
|
2454
|
+
wsCode: info.wsCode,
|
|
2455
|
+
errorCode,
|
|
2456
|
+
errorName,
|
|
2457
|
+
message: info.message,
|
|
2458
|
+
closeReason: info.closeReason,
|
|
2459
|
+
protocolError: true,
|
|
2460
|
+
code,
|
|
2461
|
+
name: codeName(code)
|
|
2462
|
+
};
|
|
2463
|
+
})()
|
|
2464
|
+
) : {
|
|
2465
|
+
phase: "connected",
|
|
2466
|
+
wsCode: info.wsCode,
|
|
2467
|
+
// Same derivation as the protocolBug branch above: a raw
|
|
2468
|
+
// ws-mixer close with no preceding `error` control frame (e.g.
|
|
2469
|
+
// 4011 from onSocketClose) still tells us exactly which one it
|
|
2470
|
+
// was, so deriveErrorCodeName() fills both errorCode/errorName
|
|
2471
|
+
// from it instead of leaving them undefined just because
|
|
2472
|
+
// MixerConn itself never set one.
|
|
2473
|
+
...deriveErrorCodeName(info.wsCode, info.errorCode),
|
|
2474
|
+
message: info.message,
|
|
2475
|
+
closeReason: info.closeReason
|
|
2476
|
+
};
|
|
2477
|
+
if (fatal) {
|
|
2478
|
+
this.notifyDisconnect(this.buildPayload(ctx, true));
|
|
2479
|
+
if (this.closing || this.fatal || this.state === "closed") return;
|
|
2480
|
+
this.state = "closed";
|
|
2481
|
+
this.fatal = true;
|
|
2482
|
+
this.closing = true;
|
|
2483
|
+
this.clearReconnectTimer();
|
|
2484
|
+
const err = new WsMixerError(ctx.errorCode ?? ErrorCode.INTERNAL_ERROR, info.message, { fatal: true });
|
|
2485
|
+
if (this.dialingConn && this.dialingConn !== conn) {
|
|
2486
|
+
const dialing = this.dialingConn;
|
|
2487
|
+
this.dialingConn = null;
|
|
2488
|
+
dialing.fail(new WsMixerError(ErrorCode.NO_ERROR, "client closing"));
|
|
2489
|
+
}
|
|
2490
|
+
this.emit("fatal", err);
|
|
2491
|
+
this.rejectStartIfNeverConnected(err);
|
|
2492
|
+
return;
|
|
2493
|
+
}
|
|
2494
|
+
if (this.fatal) {
|
|
2495
|
+
return;
|
|
2496
|
+
}
|
|
2497
|
+
if (this.closing || this.state === "closed") {
|
|
2498
|
+
this.notifyDisconnect(this.buildPayload(ctx, false));
|
|
2499
|
+
return;
|
|
2500
|
+
}
|
|
2501
|
+
if (this.drainedNoReconnect) {
|
|
2502
|
+
this.drainedNoReconnect = false;
|
|
2503
|
+
this.state = "closed";
|
|
2504
|
+
this.fatal = true;
|
|
2505
|
+
this.closing = true;
|
|
2506
|
+
this.clearReconnectTimer();
|
|
2507
|
+
this.notifyDisconnect(this.buildPayload(ctx, true, "drained; reconnect disabled"));
|
|
2508
|
+
const err = new WsMixerError(ctx.errorCode ?? ErrorCode.GOING_AWAY, "drained; reconnect disabled", { fatal: true });
|
|
2509
|
+
if (this.dialingConn && this.dialingConn !== conn) {
|
|
2510
|
+
const dialing = this.dialingConn;
|
|
2511
|
+
this.dialingConn = null;
|
|
2512
|
+
dialing.fail(new WsMixerError(ErrorCode.NO_ERROR, "client closing"));
|
|
2513
|
+
}
|
|
2514
|
+
this.emit("fatal", err);
|
|
2515
|
+
this.rejectStartIfNeverConnected(err);
|
|
2516
|
+
return;
|
|
2517
|
+
}
|
|
2518
|
+
if (this.drainReconnectScheduled) {
|
|
2519
|
+
this.notifyDisconnect(this.buildPayload(ctx, false));
|
|
2520
|
+
this.drainReconnectScheduled = false;
|
|
2521
|
+
return;
|
|
2522
|
+
}
|
|
2523
|
+
if (info.wsCode === GOING_AWAY_WS_CODE || info.wsCode === ABNORMAL_CLOSURE_WS_CODE) {
|
|
2524
|
+
this.reconnectImmediately(ctx, "going_away");
|
|
2525
|
+
return;
|
|
2526
|
+
}
|
|
2527
|
+
if (info.wsCode === KEEPALIVE_TIMEOUT_WS_CODE) {
|
|
2528
|
+
this.scheduleReconnectAfterKeepaliveTimeout(ctx, "keepalive_timeout");
|
|
2529
|
+
return;
|
|
2530
|
+
}
|
|
2531
|
+
if (info.wsCode === ENHANCE_YOUR_CALM_WS_CODE) {
|
|
2532
|
+
this.scheduleReconnectAtCap(ctx, "enhance_your_calm");
|
|
2533
|
+
return;
|
|
2534
|
+
}
|
|
2535
|
+
if (info.wsCode === APPLICATION_CLOSE_WS_CODE) {
|
|
2536
|
+
this.scheduleReconnectAtCap(ctx, "application_close");
|
|
2537
|
+
return;
|
|
2538
|
+
}
|
|
2539
|
+
this.scheduleReconnect(ctx, info.message);
|
|
2540
|
+
});
|
|
2541
|
+
}
|
|
2542
|
+
notifyDisconnect(payload) {
|
|
2543
|
+
this.opts.onDisconnect?.(payload);
|
|
2544
|
+
this.emit("close", payload);
|
|
2545
|
+
}
|
|
2546
|
+
/** Builds the final `DisconnectPayload` for a `DisconnectContext`, filling in `fatal` and, when present, the exhaustion message override. */
|
|
2547
|
+
buildPayload(ctx, fatal, messageOverride) {
|
|
2548
|
+
const base = {
|
|
2549
|
+
phase: ctx.phase,
|
|
2550
|
+
wsCode: ctx.wsCode,
|
|
2551
|
+
errorCode: ctx.errorCode,
|
|
2552
|
+
errorName: ctx.errorName,
|
|
2553
|
+
httpStatus: ctx.httpStatus,
|
|
2554
|
+
cause: ctx.cause,
|
|
2555
|
+
message: messageOverride ?? ctx.message,
|
|
2556
|
+
closeReason: ctx.closeReason,
|
|
2557
|
+
fatal
|
|
2558
|
+
};
|
|
2559
|
+
if (ctx.protocolError) {
|
|
2560
|
+
return { ...base, protocolError: true, code: ctx.code, name: ctx.name };
|
|
2561
|
+
}
|
|
2562
|
+
return { ...base, protocolError: false };
|
|
2563
|
+
}
|
|
2564
|
+
rejectStartIfNeverConnected(err) {
|
|
2565
|
+
if (!this.everConnected && this.startReject) {
|
|
2566
|
+
const reject = this.startReject;
|
|
2567
|
+
this.startResolve = null;
|
|
2568
|
+
this.startReject = null;
|
|
2569
|
+
reject(err);
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2572
|
+
/** Fatal per WIRE.md section 2.9: never reconnect, surface loudly, and fail start() if it never got a first connection. One report only. */
|
|
2573
|
+
goFatal(ctx) {
|
|
2574
|
+
if (this.state === "closed") return;
|
|
2575
|
+
this.state = "closed";
|
|
2576
|
+
this.fatal = true;
|
|
2577
|
+
this.closing = true;
|
|
2578
|
+
this.clearReconnectTimer();
|
|
2579
|
+
this.clearStabilityTimer();
|
|
2580
|
+
const conn = this.conn;
|
|
2581
|
+
this.conn = null;
|
|
2582
|
+
const err = new WsMixerError(ctx.errorCode ?? ErrorCode.INTERNAL_ERROR, ctx.message, { fatal: true });
|
|
2583
|
+
if (conn) conn.fail(err);
|
|
2584
|
+
this.emit("fatal", err);
|
|
2585
|
+
this.notifyDisconnect(this.buildPayload(ctx, true));
|
|
2586
|
+
this.rejectStartIfNeverConnected(err);
|
|
2587
|
+
}
|
|
2588
|
+
/**
|
|
2589
|
+
* maxAttempts exhausted: stop, but connect() must reject if it never
|
|
2590
|
+
* succeeded once. Reports exactly one `DisconnectReason` -- the merged
|
|
2591
|
+
* exhaustion report, `fatal: true` with the exhaustion `message`, but
|
|
2592
|
+
* carrying the underlying failure's `wsCode`/`errorCode`/`httpStatus`/
|
|
2593
|
+
* `cause` from `ctx` -- never a second report on top of the one the
|
|
2594
|
+
* failure itself would otherwise have gotten (blocker 1).
|
|
2595
|
+
*
|
|
2596
|
+
* Mirrors goFatal: clears the stability timer too (a live connection can
|
|
2597
|
+
* still have one ticking, e.g. a drain hand-over's parallel dial failing
|
|
2598
|
+
* while the old connection it was replacing is still up), and detaches
|
|
2599
|
+
* every live/in-flight conn -- `conn`, `dialingConn`, `retiringConn`,
|
|
2600
|
+
* which a drain hand-over can leave all pointing at the very same live
|
|
2601
|
+
* connection, hence the reference-dedup below -- before failing it, so
|
|
2602
|
+
* each one's own 'close' handler sees `this.conn`/`this.retiringConn`/
|
|
2603
|
+
* `this.dialingConn` already cleared and produces no report of its own;
|
|
2604
|
+
* this call is still the one report. Without this, an exhausted client
|
|
2605
|
+
* left a still-live socket (and its ping/watchdog timers, and the
|
|
2606
|
+
* now-orphaned stability timer) running past "closed".
|
|
2607
|
+
*/
|
|
2608
|
+
giveUp(ctx, message) {
|
|
2609
|
+
if (this.state === "closed") return;
|
|
2610
|
+
this.state = "closed";
|
|
2611
|
+
this.closing = true;
|
|
2612
|
+
this.clearReconnectTimer();
|
|
2613
|
+
this.clearStabilityTimer();
|
|
2614
|
+
const conn = this.conn;
|
|
2615
|
+
const dialing = this.dialingConn;
|
|
2616
|
+
const retiring = this.retiringConn;
|
|
2617
|
+
this.conn = null;
|
|
2618
|
+
this.dialingConn = null;
|
|
2619
|
+
this.retiringConn = null;
|
|
2620
|
+
const err = new WsMixerError(ctx.errorCode ?? ErrorCode.INTERNAL_ERROR, message, { fatal: true });
|
|
2621
|
+
if (conn) conn.fail(err);
|
|
2622
|
+
if (dialing && dialing !== conn) dialing.fail(err);
|
|
2623
|
+
if (retiring && retiring !== conn && retiring !== dialing) retiring.fail(err);
|
|
2624
|
+
this.notifyDisconnect(this.buildPayload(ctx, true, message));
|
|
2625
|
+
this.rejectStartIfNeverConnected(new Error(message));
|
|
2626
|
+
}
|
|
2627
|
+
/**
|
|
2628
|
+
* The single point where a recoverable disconnect either gets its one
|
|
2629
|
+
* report and a scheduled retry, or -- if `maxAttempts` is already
|
|
2630
|
+
* exhausted -- gets folded into `giveUp`'s one merged exhaustion report
|
|
2631
|
+
* instead. Exhaustion is checked *before* any report goes out, which is
|
|
2632
|
+
* what makes "exactly one `DisconnectReason` per disconnect" (blocker 1)
|
|
2633
|
+
* hold: the two outcomes are mutually exclusive, never both.
|
|
2634
|
+
*/
|
|
2635
|
+
reportAndSchedule(ctx, cause, computeDelay) {
|
|
2636
|
+
if (this.closing || this.fatal || this.state === "closed") return;
|
|
2637
|
+
if (this.attempt >= this.reconnectOpts.maxAttempts) {
|
|
2638
|
+
this.giveUp(ctx, `max reconnect attempts (${this.reconnectOpts.maxAttempts}) exhausted: ${cause}`);
|
|
2639
|
+
return;
|
|
2640
|
+
}
|
|
2641
|
+
this.notifyDisconnect(this.buildPayload(ctx, false));
|
|
2642
|
+
this.attempt++;
|
|
2643
|
+
const delay = computeDelay();
|
|
2644
|
+
void this.connectOnce(delay, cause);
|
|
2645
|
+
}
|
|
2646
|
+
/**
|
|
2647
|
+
* Close 4012 / close 1001 with no preceding `drain`: reconnect
|
|
2648
|
+
* immediately, jitter random(0, 2000)ms only -- but still counts toward
|
|
2649
|
+
* `attempt`/`maxAttempts`, so a 4012 flap loop (a server stuck
|
|
2650
|
+
* accepting-then-immediately-draining) still respects the ceiling instead
|
|
2651
|
+
* of retrying forever. (The `drain`-triggered parallel reconnect is a
|
|
2652
|
+
* separate, direct connectOnce() call in the `drain` handler above and
|
|
2653
|
+
* does not bump `attempt` -- it is the server telling us to move, not a
|
|
2654
|
+
* failure being retried.)
|
|
2655
|
+
*/
|
|
2656
|
+
reconnectImmediately(ctx, cause) {
|
|
2657
|
+
this.reportAndSchedule(ctx, cause, () => Math.random() * 2e3);
|
|
2658
|
+
}
|
|
2659
|
+
/**
|
|
2660
|
+
* Close 4013 KEEPALIVE_TIMEOUT: one immediate attempt, then normal backoff
|
|
2661
|
+
* (WIRE.md section 2.9). The immediate attempt goes through
|
|
2662
|
+
* reportAndSchedule -- same as the 4012 path (reconnectImmediately) -- so
|
|
2663
|
+
* it respects the maxAttempts ceiling too: a 4013 flap loop with no budget
|
|
2664
|
+
* left gives up (one merged `fatal: true` report) instead of dialing a
|
|
2665
|
+
* socket reportAndSchedule would otherwise have refused to allow.
|
|
2666
|
+
*
|
|
2667
|
+
* `keepaliveImmediateRetryUsed` is a once-only budget re-armed ONLY at
|
|
2668
|
+
* stability (armStabilityTimer) -- deliberately never un-set here on the
|
|
2669
|
+
* second (or any later) 4013: doing that would let the budget "spend then
|
|
2670
|
+
* immediately un-spend itself" on the very next 4013, so four 4013s in a
|
|
2671
|
+
* row with no intervening stable connection would go immediate, backoff,
|
|
2672
|
+
* immediate, backoff... forever, instead of immediate once and backoff
|
|
2673
|
+
* every time after that until the client actually goes stable.
|
|
2674
|
+
*/
|
|
2675
|
+
scheduleReconnectAfterKeepaliveTimeout(ctx, cause) {
|
|
2676
|
+
if (!this.keepaliveImmediateRetryUsed) {
|
|
2677
|
+
this.keepaliveImmediateRetryUsed = true;
|
|
2678
|
+
this.reportAndSchedule(ctx, cause, () => 0);
|
|
2679
|
+
return;
|
|
2680
|
+
}
|
|
2681
|
+
this.scheduleReconnect(ctx, cause);
|
|
2682
|
+
}
|
|
2683
|
+
/** Close 4009 ENHANCE_YOUR_CALM: start backoff at the cap, not at base. */
|
|
2684
|
+
scheduleReconnectAtCap(ctx, cause) {
|
|
2685
|
+
this.reportAndSchedule(ctx, cause, () => Math.random() * this.reconnectOpts.cap);
|
|
2686
|
+
}
|
|
2687
|
+
/** Normal AWS full-jitter backoff: everything not covered by a more specific rule above. */
|
|
2688
|
+
scheduleReconnect(ctx, cause) {
|
|
2689
|
+
this.reportAndSchedule(ctx, cause, () => fullJitterDelay(this.attempt, this.reconnectOpts.base, this.reconnectOpts.cap));
|
|
2690
|
+
}
|
|
2691
|
+
};
|
|
2692
|
+
function fullJitterDelay(attempt, base, cap) {
|
|
2693
|
+
const exp = Math.min(cap, base * 2 ** attempt);
|
|
2694
|
+
return Math.random() * exp;
|
|
2695
|
+
}
|
|
2696
|
+
function dialError(message, fatal, opts) {
|
|
2697
|
+
const err = new Error(message);
|
|
2698
|
+
err.fatal = fatal;
|
|
2699
|
+
if (opts?.errorCode !== void 0) err.errorCode = opts.errorCode;
|
|
2700
|
+
if (opts?.retryAfterMs !== void 0) err.retryAfterMs = opts.retryAfterMs;
|
|
2701
|
+
if (opts?.httpStatus !== void 0) err.httpStatus = opts.httpStatus;
|
|
2702
|
+
if (opts?.cause !== void 0) err.cause = opts.cause;
|
|
2703
|
+
return err;
|
|
2704
|
+
}
|
|
2705
|
+
function providerDialError(err) {
|
|
2706
|
+
let message;
|
|
2707
|
+
try {
|
|
2708
|
+
message = err instanceof Error ? err.message : String(err);
|
|
2709
|
+
} catch {
|
|
2710
|
+
message = "token provider failed";
|
|
2711
|
+
}
|
|
2712
|
+
return dialError(
|
|
2713
|
+
message,
|
|
2714
|
+
/* overridden by dialAndHandshakeOnce's isTokenUnavailable check */
|
|
2715
|
+
true,
|
|
2716
|
+
{ cause: err }
|
|
2717
|
+
);
|
|
2718
|
+
}
|
|
2719
|
+
var MAX_CAUSE_CHAIN_DEPTH = 10;
|
|
2720
|
+
function isTokenUnavailable(err) {
|
|
2721
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2722
|
+
let cur = err;
|
|
2723
|
+
for (let depth = 0; depth < MAX_CAUSE_CHAIN_DEPTH && cur != null && !seen.has(cur); depth++) {
|
|
2724
|
+
if (cur instanceof TokenUnavailableError) return true;
|
|
2725
|
+
seen.add(cur);
|
|
2726
|
+
try {
|
|
2727
|
+
cur = cur instanceof Error ? cur.cause : void 0;
|
|
2728
|
+
} catch {
|
|
2729
|
+
return false;
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
return false;
|
|
2733
|
+
}
|
|
2734
|
+
function parseRetryAfter(value) {
|
|
2735
|
+
const v = Array.isArray(value) ? value[0] : value;
|
|
2736
|
+
if (v === void 0) return void 0;
|
|
2737
|
+
const seconds = Number(v);
|
|
2738
|
+
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1e3);
|
|
2739
|
+
const dateMs = Date.parse(v);
|
|
2740
|
+
if (!Number.isNaN(dateMs)) return Math.max(0, dateMs - Date.now());
|
|
2741
|
+
return void 0;
|
|
2742
|
+
}
|
|
2743
|
+
function defaultWsFactory(url, protocols, options) {
|
|
2744
|
+
return new import_ws.WebSocket(url, protocols, options);
|
|
2745
|
+
}
|
|
2746
|
+
function dialWebSocket(url, token, opts, connectTimeoutMs) {
|
|
2747
|
+
return new Promise((resolve, reject) => {
|
|
2748
|
+
const factory = opts._wsFactory ?? defaultWsFactory;
|
|
2749
|
+
const ws = factory(url, [SUBPROTOCOL], {
|
|
2750
|
+
perMessageDeflate: false,
|
|
2751
|
+
maxPayload: MAX_MESSAGE_SIZE,
|
|
2752
|
+
headers: {
|
|
2753
|
+
Authorization: `Bearer ${token}`,
|
|
2754
|
+
"User-Agent": `ws-mixer-js/${SDK_VERSION} (node/${process.version.replace(/^v/, "")} ${process.platform}/${process.arch})`,
|
|
2755
|
+
...opts.headers
|
|
2756
|
+
}
|
|
2757
|
+
});
|
|
2758
|
+
const timer = setTimeout(() => {
|
|
2759
|
+
ws.terminate?.();
|
|
2760
|
+
reject(dialError(`connect timed out after ${connectTimeoutMs}ms`, false));
|
|
2761
|
+
}, connectTimeoutMs);
|
|
2762
|
+
let settled = false;
|
|
2763
|
+
ws.once("unexpected-response", (req, res) => {
|
|
2764
|
+
if (settled) return;
|
|
2765
|
+
settled = true;
|
|
2766
|
+
clearTimeout(timer);
|
|
2767
|
+
try {
|
|
2768
|
+
res?.resume?.();
|
|
2769
|
+
req?.destroy?.();
|
|
2770
|
+
res?.destroy?.();
|
|
2771
|
+
ws.terminate?.();
|
|
2772
|
+
const status = res?.statusCode;
|
|
2773
|
+
if (status === 429) {
|
|
2774
|
+
reject(
|
|
2775
|
+
dialError(`HTTP 429 (rate limited) during handshake`, false, {
|
|
2776
|
+
retryAfterMs: parseRetryAfter(res?.headers?.["retry-after"]),
|
|
2777
|
+
httpStatus: 429
|
|
2778
|
+
})
|
|
2779
|
+
);
|
|
2780
|
+
return;
|
|
2781
|
+
}
|
|
2782
|
+
if (status === 401 || status === 403) {
|
|
2783
|
+
reject(
|
|
2784
|
+
dialError(`unexpected HTTP response during handshake: ${status}`, true, {
|
|
2785
|
+
httpStatus: status
|
|
2786
|
+
})
|
|
2787
|
+
);
|
|
2788
|
+
return;
|
|
2789
|
+
}
|
|
2790
|
+
if (status === 404) {
|
|
2791
|
+
reject(dialError(`unexpected HTTP response during handshake: ${status}`, true, { httpStatus: status }));
|
|
2792
|
+
return;
|
|
2793
|
+
}
|
|
2794
|
+
reject(dialError(`unexpected HTTP response during handshake: ${status}`, false, { httpStatus: status }));
|
|
2795
|
+
} catch (e) {
|
|
2796
|
+
reject(dialError(e instanceof Error ? e.message : String(e), false));
|
|
2797
|
+
}
|
|
2798
|
+
});
|
|
2799
|
+
ws.once("error", (err) => {
|
|
2800
|
+
if (settled) return;
|
|
2801
|
+
settled = true;
|
|
2802
|
+
clearTimeout(timer);
|
|
2803
|
+
reject(dialError(err.message, false));
|
|
2804
|
+
});
|
|
2805
|
+
ws.once("open", () => {
|
|
2806
|
+
if (settled) return;
|
|
2807
|
+
if (ws.protocol !== SUBPROTOCOL) {
|
|
2808
|
+
settled = true;
|
|
2809
|
+
clearTimeout(timer);
|
|
2810
|
+
ws.close(1002);
|
|
2811
|
+
reject(
|
|
2812
|
+
dialError(`server did not echo the ${SUBPROTOCOL} subprotocol; failing fatally, do not retry`, true, {
|
|
2813
|
+
errorCode: ErrorCode.UNSUPPORTED
|
|
2814
|
+
})
|
|
2815
|
+
);
|
|
2816
|
+
return;
|
|
2817
|
+
}
|
|
2818
|
+
settled = true;
|
|
2819
|
+
clearTimeout(timer);
|
|
2820
|
+
resolve(ws);
|
|
2821
|
+
});
|
|
2822
|
+
});
|
|
2823
|
+
}
|
|
2824
|
+
async function connect(url, opts) {
|
|
2825
|
+
const client = new MixerClient(url, opts);
|
|
2826
|
+
await client.start();
|
|
2827
|
+
return client;
|
|
2828
|
+
}
|
|
2829
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
2830
|
+
0 && (module.exports = {
|
|
2831
|
+
ConnError,
|
|
2832
|
+
ErrorCode,
|
|
2833
|
+
FrameType,
|
|
2834
|
+
MAX_CHUNK,
|
|
2835
|
+
MAX_MESSAGE_SIZE,
|
|
2836
|
+
MAX_SEND_WINDOW,
|
|
2837
|
+
MAX_STREAM_ZERO_PAYLOAD,
|
|
2838
|
+
MixerClient,
|
|
2839
|
+
MixerConn,
|
|
2840
|
+
MixerStream,
|
|
2841
|
+
SDK_VERSION,
|
|
2842
|
+
SUBPROTOCOL,
|
|
2843
|
+
StreamError,
|
|
2844
|
+
TokenUnavailableError,
|
|
2845
|
+
WsMixerError,
|
|
2846
|
+
closeCode,
|
|
2847
|
+
codeName,
|
|
2848
|
+
connect,
|
|
2849
|
+
decodeFrame,
|
|
2850
|
+
encodeClose,
|
|
2851
|
+
encodeControl,
|
|
2852
|
+
encodeData,
|
|
2853
|
+
encodeFrame,
|
|
2854
|
+
encodeOpen,
|
|
2855
|
+
encodeReset,
|
|
2856
|
+
encodeWindow,
|
|
2857
|
+
frameTypeName,
|
|
2858
|
+
fullJitterDelay,
|
|
2859
|
+
knownDrainReason,
|
|
2860
|
+
parseControl,
|
|
2861
|
+
parseErrorCode,
|
|
2862
|
+
resetCode,
|
|
2863
|
+
resetMessage,
|
|
2864
|
+
windowIncrement
|
|
2865
|
+
});
|
|
2866
|
+
//# sourceMappingURL=index.cjs.map
|