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