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