@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.
@@ -0,0 +1,453 @@
1
+ /**
2
+ * Public client entry point: `connect(url, opts)`. Owns the WebSocket
3
+ * handshake (subprotocol, headers, size limits), and the reconnect/backoff
4
+ * policy from docs/research/2026-08-26-control-channel-and-connection-lifecycle.md
5
+ * and WIRE.md section 2.9's reconnect table. `MixerConn` (conn.ts) owns
6
+ * everything about one already-connected socket; this file owns the loop
7
+ * that replaces it.
8
+ *
9
+ * State machine (single source of truth for "are we allowed to dial /
10
+ * reconnect right now"): idle -> dialing -> connected -> backoff -> ...,
11
+ * terminating in closed (either `close()` was called, a fatal close code
12
+ * was hit, or maxAttempts was exhausted). `dialing` and `backoff` both loop
13
+ * back to `dialing` on success; every terminal transition happens exactly
14
+ * once, in `terminal()`/`goFatal()`/`giveUp()`.
15
+ */
16
+ import { EventEmitter } from "node:events";
17
+ import type { AgentInfo, DrainMsg, WelcomeMsg } from "./control.js";
18
+ import { WsMixerError } from "./errors.js";
19
+ import { MixerConn, type WSLike } from "./conn.js";
20
+ import type { MixerStream } from "./stream.js";
21
+ export declare const SUBPROTOCOL = "ws-mixer.v1";
22
+ export declare const SDK_VERSION = "0.6.0";
23
+ export type ClientState = "idle" | "dialing" | "connected" | "backoff" | "closed";
24
+ export interface ReconnectOptions {
25
+ /** Base delay for exponential backoff, ms. Default 1000. */
26
+ base?: number;
27
+ /** Backoff cap, ms. Default 60000. */
28
+ cap?: number;
29
+ /** Per-attempt connect timeout, ms. Default 10000. */
30
+ connectTimeout?: number;
31
+ /**
32
+ * Max reconnect attempts before giving up. Default Infinity. Counts
33
+ * consecutive reconnects *without a stable connection in between*
34
+ * (`stableAfter` below) -- since `attempt` itself only resets at
35
+ * stability now, a server that welcomes and then always disconnects
36
+ * before a connection ever proves stable exhausts this ceiling and goes
37
+ * fatal exactly like a server that never welcomes at all.
38
+ */
39
+ maxAttempts?: number;
40
+ /**
41
+ * How long (ms) a connection must stay up past `welcome` before it's
42
+ * considered stable (WIRE.md section 2.9's `stable`). The backoff attempt
43
+ * counter, and every once-only retry budget re-armed alongside it (e.g.
44
+ * 4013 KEEPALIVE_TIMEOUT's one immediate retry, and the one-time
45
+ * token-provider refresh-retry for a pre-welcome auth rejection), are
46
+ * reset only once a connection has stayed up this long -- not on
47
+ * `welcome` itself -- so a server that welcomes and then immediately
48
+ * closes can't reset them every cycle and turn backoff (or a refresh-retry
49
+ * loop against the token endpoint) into a redial-roughly-once-a-second
50
+ * loop forever. Default 10000.
51
+ */
52
+ stableAfter?: number;
53
+ }
54
+ /** Structural subset of `ws`'s WebSocket needed during the dial (before a MixerConn exists). Lets tests inject a fake transport via `_wsFactory`. */
55
+ /**
56
+ * Structural subset of Node's `http.ClientRequest`, as seen on `ws`'s
57
+ * `unexpected-response` event. `ws` only calls its own `abortHandshake`
58
+ * cleanup when nothing is listening for `unexpected-response` -- since this
59
+ * SDK does listen (to read `statusCode`/`Retry-After`), it owns draining and
60
+ * destroying `req`/`res` itself, or the request/socket leaks.
61
+ */
62
+ export interface DialRequest {
63
+ destroy?(err?: Error): void;
64
+ }
65
+ /** Structural subset of Node's `http.IncomingMessage`, as seen on `ws`'s `unexpected-response` event. */
66
+ export interface DialResponse {
67
+ statusCode?: number;
68
+ headers: Record<string, string | string[] | undefined>;
69
+ resume?(): void;
70
+ destroy?(err?: Error): void;
71
+ }
72
+ export interface DialSocket extends WSLike {
73
+ readonly protocol: string;
74
+ once(event: "open", listener: () => void): this;
75
+ once(event: "unexpected-response", listener: (req: DialRequest, res: DialResponse) => void): this;
76
+ once(event: "error", listener: (err: Error) => void): this;
77
+ }
78
+ export interface WSFactoryOptions {
79
+ perMessageDeflate: boolean;
80
+ maxPayload: number;
81
+ headers: Record<string, string>;
82
+ }
83
+ /** Test-only dial hook: replaces `new WebSocket(url, [SUBPROTOCOL], options)`. */
84
+ export type WSFactory = (url: string, protocols: string[], options: WSFactoryOptions) => DialSocket;
85
+ /**
86
+ * A ws-mixer token: either a static string or a callback returning a fresh
87
+ * token (sync or `Promise`). Per CLIENT-SDK.md's "Token provider" and "Provider failure" rows, the callback MUST
88
+ * be invoked on every dial, never cached across reconnects, and a
89
+ * throw/rejection is fatal -- surfaced verbatim as `DisconnectReason.cause` --
90
+ * UNLESS it's a `TokenUnavailableError` (thrown directly, or reachable by
91
+ * following `.cause`): that marks a temporary failure to OBTAIN a token
92
+ * (network still down, auth server briefly unreachable) and is instead
93
+ * treated like a failed dial -- normal backoff, no fatal.
94
+ */
95
+ export type TokenProvider = string | (() => Promise<string> | string);
96
+ /** Where a disconnect originated, per CLIENT-SDK.md's "Disconnect reason shape" row. */
97
+ export type DisconnectPhase = "dial" | "handshake" | "connected";
98
+ /**
99
+ * The shape every disconnect (recoverable or fatal) is reported with, per
100
+ * CLIENT-SDK.md's "Disconnect reason shape" row. `httpStatus` is set only for a dial failure whose
101
+ * response was an HTTP status (401/403/404/429); `cause` is set only when a
102
+ * token provider threw/rejected.
103
+ */
104
+ export interface DisconnectReason {
105
+ phase: DisconnectPhase;
106
+ /**
107
+ * The WebSocket close code, when a WS close occurred. When derived from a
108
+ * ws-mixer error (this side's own `WsMixerError`/`ConnError`, or a peer's
109
+ * `error{code}`), this is always the semantic `4000+error_code` -- for an
110
+ * `error_code` outside the legal WS close range (>= 0x1000_0000, an
111
+ * application-layer code that is legal for a stream RESET but never was
112
+ * for a connection close), that can differ from the *actual* bytes this
113
+ * side puts on the wire, which are clamped to `4000+INTERNAL_ERROR` (4002)
114
+ * instead (conn.ts's `wireCloseCode`, mirroring go/wsmixer's
115
+ * `wsCloseCode`) -- `errorCode` always keeps the real, unclamped code
116
+ * either way. When instead observed directly from a bare close frame
117
+ * (`onSocketClose`, no preceding `error{}`), `wsCode` is exactly what was
118
+ * on the wire, since there is nothing else it could be.
119
+ */
120
+ wsCode?: number;
121
+ errorCode?: number;
122
+ errorName?: string;
123
+ httpStatus?: number;
124
+ fatal: boolean;
125
+ message: string;
126
+ /**
127
+ * The reason field of the close frame *received from the peer*, verbatim
128
+ * -- never this side's own outgoing reason (CLIENT-SDK.md's `closeReason`
129
+ * row). Absent or empty whenever no reason was received from the peer:
130
+ * this includes an abnormal closure (no close frame at all), this side
131
+ * having initiated the close itself (a peer's echo carries no information
132
+ * and RFC 6455 doesn't require it to copy the reason), and the SDK closing
133
+ * on a peer's `error{}` without reading whatever close frame follows it,
134
+ * as WIRE.md section 2.7 allows ("logs, surfaces and closes"). The
135
+ * human-readable text is in `message` for all of those cases instead --
136
+ * consumers SHOULD prefer `closeReason` and fall back to `message`.
137
+ */
138
+ closeReason?: string;
139
+ cause?: unknown;
140
+ }
141
+ /**
142
+ * The actual runtime payload for every disconnect: `DisconnectReason` plus
143
+ * the pre-existing loud "SDK bug" fields (item 7) for a protocol-bug close
144
+ * (4001/4003/4004) -- kept alongside `errorCode`/`errorName` rather than
145
+ * replacing them, so existing `protocolError`/`code`/`name` consumers keep
146
+ * working.
147
+ */
148
+ export type DisconnectPayload = DisconnectReason & ({
149
+ protocolError: true;
150
+ code: number;
151
+ name: string;
152
+ } | {
153
+ protocolError?: false;
154
+ });
155
+ export interface ConnectOptions {
156
+ token: TokenProvider;
157
+ meta?: unknown;
158
+ agent?: Partial<AgentInfo>;
159
+ window?: number;
160
+ maxStreams?: number;
161
+ capabilities?: string[];
162
+ headers?: Record<string, string>;
163
+ /**
164
+ * Delivered, along with `onApp`/`onDrain`, in wire order from one delivery
165
+ * loop per connection, one at a time -- never concurrently with each other
166
+ * or with themselves (CLIENT-SDK.md's "Handler delivery" row). An event
167
+ * already received when the connection ends is still owed to this
168
+ * callback: it MAY therefore still fire shortly after `close()`/
169
+ * `close({message})`'s own promise has resolved, for a stream/message/drain
170
+ * that arrived before whatever ended the connection.
171
+ */
172
+ onStream?: (stream: MixerStream) => void;
173
+ /** See `onStream`'s doc comment: same wire-order/post-close-flush guarantees. */
174
+ onApp?: (body: Record<string, unknown>) => void;
175
+ /** See `onStream`'s doc comment: same wire-order/post-close-flush guarantees. */
176
+ onDrain?: (msg: {
177
+ reason: string;
178
+ deadlineMs?: number;
179
+ message?: string;
180
+ lastStreamId: number;
181
+ }) => void;
182
+ onConnect?: (welcome: WelcomeMsg) => void;
183
+ /**
184
+ * Fires on every disconnect, recoverable or not (see stats() note in
185
+ * docs/DESIGN.md). This is the SDK's loud, always-invoked channel
186
+ * -- unlike `'error'`, it is not gated on a listener being attached, so a
187
+ * `4001`/`4003`/`4004` close (WIRE.md section 2.9: "these mean an SDK
188
+ * bug and a silent retry loop hides it") always reaches an `onDisconnect`
189
+ * the caller supplied, with `protocolError: true` plus the offending
190
+ * `code`/`name` to make it impossible to miss.
191
+ */
192
+ onDisconnect?: (reason: DisconnectPayload) => void;
193
+ reconnect?: ReconnectOptions;
194
+ /** Test-only: injects a fake WebSocket-like transport for the dial. Never used in production. */
195
+ _wsFactory?: WSFactory;
196
+ }
197
+ /** Options for an application-initiated `MixerClient.close()` (CLIENT-SDK.md's "Application close" row). See `close()`'s doc comment. */
198
+ export interface CloseOptions {
199
+ /**
200
+ * The mode is chosen by presence, not value: supplying `message` (even
201
+ * `""`) closes the connection with `error{code: ErrorCode.APPLICATION_CLOSE,
202
+ * message}` then WS close `4014`, instead of the default graceful drain --
203
+ * WIRE.md section 2.8 makes `APPLICATION_CLOSE` the only code an
204
+ * application may close a *connection* with, so there is no caller-chosen
205
+ * code (D-2026-09-25-01). Truncated to 123 UTF-8 bytes on a character
206
+ * boundary for the WS close reason. Leaving `message` undefined -- `close()`
207
+ * or `close({})` -- takes the graceful path instead. A `code` key (e.g. a
208
+ * plain-JS caller still passing `{code: 14}`) makes `close()` throw a
209
+ * `TypeError` synchronously, with or without `message`.
210
+ */
211
+ message?: string;
212
+ }
213
+ export declare interface MixerClient {
214
+ on(event: "welcome", listener: (welcome: WelcomeMsg) => void): this;
215
+ on(event: "stream", listener: (stream: MixerStream) => void): this;
216
+ on(event: "app", listener: (body: Record<string, unknown>) => void): this;
217
+ on(event: "drain", listener: (msg: DrainMsg) => void): this;
218
+ on(event: "reconnecting", listener: (info: {
219
+ attempt: number;
220
+ delayMs: number;
221
+ cause: string;
222
+ }) => void): this;
223
+ on(event: "close", listener: (info: DisconnectPayload) => void): this;
224
+ on(event: "error", listener: (err: WsMixerError) => void): this;
225
+ on(event: "fatal", listener: (err: WsMixerError) => void): this;
226
+ on(event: "pong", listener: (info: {
227
+ id: number;
228
+ rttMs: number;
229
+ }) => void): this;
230
+ }
231
+ /**
232
+ * MixerClient owns the reconnect loop: it replaces `conn` with a fresh
233
+ * MixerConn on every disconnect, per the policy in
234
+ * docs/research/2026-08-26-control-channel-and-connection-lifecycle.md.
235
+ *
236
+ * In-flight streams are lost on reconnect -- there is no resumption
237
+ * (WIRE.md section 2.9). A handler must tell "the response ended" (EOF)
238
+ * from "the tunnel died" (its stream is destroyed with an error) itself;
239
+ * this SDK does not paper over the difference.
240
+ */
241
+ export declare class MixerClient extends EventEmitter {
242
+ private readonly url;
243
+ private readonly opts;
244
+ private readonly reconnectOpts;
245
+ private state;
246
+ /** The confirmed-live connection (welcome received). Null while dialing/backing off. */
247
+ private conn;
248
+ /** A MixerConn mid-handshake, not yet promoted to `conn`. Tracked so close() can tear it down too. */
249
+ private dialingConn;
250
+ /**
251
+ * Set only while a `drain`-triggered parallel reconnect is in flight: the
252
+ * connection that sent `drain`, still alive and finishing in-flight
253
+ * streams. Torn down (fail()) as soon as its replacement's `welcome`
254
+ * lands, so a superseded connection never lingers past that point.
255
+ */
256
+ private retiringConn;
257
+ private attempt;
258
+ private everConnected;
259
+ private closing;
260
+ private fatal;
261
+ /** Set when `drain` already started a parallel reconnect, so the close that follows it doesn't schedule a second one. */
262
+ private drainReconnectScheduled;
263
+ /**
264
+ * Set when `drain` arrives with reconnect disabled (`maxAttempts:0`):
265
+ * WIRE.md section 2.9 says in-flight streams finish normally until the
266
+ * server's own deadline, at which point it closes with 4012 -- so the conn
267
+ * stays up and this flag just tells the eventual close handler to report
268
+ * that close as the one fatal "drained; reconnect disabled" disconnect
269
+ * instead of treating 4012 as an ordinary going-away reconnect trigger.
270
+ */
271
+ private drainedNoReconnect;
272
+ /** 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. */
273
+ private keepaliveImmediateRetryUsed;
274
+ /**
275
+ * CLIENT-SDK.md's "Rejected token" row: a pre-welcome token rejection (an HTTP 401 on
276
+ * the upgrade, or a handshake-phase UNAUTHORIZED/4011, with or without a
277
+ * preceding `error{}`) gets exactly one immediate provider refresh-retry
278
+ * -- ONE budget shared across both rejection shapes, and across every
279
+ * `connectOnce()` call, not reset on every dial/redial. Re-armed only at
280
+ * stability (armStabilityTimer), same as `keepaliveImmediateRetryUsed`:
281
+ * without that, a server that welcomes and then closes shortly after could
282
+ * make the client hit the token endpoint again on every single reconnect
283
+ * cycle forever, instead of only once per genuinely-unstable run. After a
284
+ * long healthy (stable) session, the budget is available again -- an
285
+ * ordinary token expiry on some later reconnect still gets its retry.
286
+ */
287
+ private unauthorizedRetryUsed;
288
+ private reconnectTimer;
289
+ /** Resolves connectOnce's backoff-delay await; settled directly by clearReconnectTimer() so close()/goFatal()/giveUp() during backoff don't leave that await dangling forever. */
290
+ private reconnectTimerResolve;
291
+ /**
292
+ * One-shot timer armed on `welcome` (armStabilityTimer): fires once the
293
+ * *current* conn has stayed up for `reconnectOpts.stableAfter` ms, at
294
+ * which point `attempt` and the once-only retry budgets it gates
295
+ * (`keepaliveImmediateRetryUsed`, `unauthorizedRetryUsed`) reset.
296
+ * `unref()`'d so it never keeps the process alive, and cleared on every
297
+ * path that ends a conn or the client itself (conn close, close()/
298
+ * close({message}), goFatal, a drain hand-over's retired conn) so a stale
299
+ * timer can never fire against a conn that's no longer the active one --
300
+ * though the `this.conn === conn` check inside it is also always
301
+ * re-verified regardless, belt and suspenders. Per-connection: a
302
+ * drain-superseded predecessor closing well after its replacement's own
303
+ * `welcome` must never clear the replacement's still-ticking timer (see
304
+ * the `wasActive` guard in wireConn's 'close' handler below).
305
+ */
306
+ private stabilityTimer;
307
+ private startResolve;
308
+ private startReject;
309
+ constructor(url: string, opts: ConnectOptions);
310
+ /** 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). */
311
+ start(): Promise<void>;
312
+ /** The currently active MixerConn, if connected. */
313
+ currentConn(): MixerConn | null;
314
+ /** Current reconnect state machine position: idle/dialing/connected/backoff/closed. */
315
+ currentState(): ClientState;
316
+ /**
317
+ * Writes an `app` frame; resolves once `ws.send`'s callback confirms it
318
+ * actually reached the socket (docs/DESIGN.md), or rejects with the
319
+ * connection's terminal error if it fails first -- conn.ts's control queue
320
+ * carries the same per-write resolver `sendData()` already uses for stream
321
+ * bytes.
322
+ */
323
+ sendApp(body: Record<string, unknown>): Promise<void>;
324
+ /** "Ignore and count" counters for the active connection (item 11); `undefined` when there is none. */
325
+ stats(): ReturnType<MixerConn["stats"]> | undefined;
326
+ /**
327
+ * Graceful client shutdown: stops reconnecting for good and closes every
328
+ * live/in-flight connection. A dial already in flight when this is called
329
+ * is closed as soon as it resolves (see connectOnce); nothing reconnects
330
+ * after this returns.
331
+ *
332
+ * With `opts.message`, this is instead an application-initiated close
333
+ * (CLIENT-SDK.md's "Application close" row): `error{code:
334
+ * ErrorCode.APPLICATION_CLOSE, message}` on stream 0, WS close `4014`
335
+ * (message truncated to 123 UTF-8 bytes on a character boundary), then
336
+ * the socket -- `MixerConn.fail()`'s `teardownConn` already performs
337
+ * exactly those three steps in order. No `drain`, no grace period.
338
+ * `APPLICATION_CLOSE` is the only code an application may close a
339
+ * *connection* with (WIRE.md section 2.8), so there is no caller-chosen
340
+ * code here (D-2026-09-25-01): an `opts` with a `code` key (a plain-JS
341
+ * caller still on the old `close({code, message})` shape) throws a
342
+ * `TypeError` synchronously, before either connection is touched.
343
+ *
344
+ * A callback for a stream/`app`/`drain` event received before this close
345
+ * can still fire shortly after this promise resolves: it doesn't wait for
346
+ * `onStream`/`onApp`/`onDrain`'s delivery loop to finish flushing whatever
347
+ * was already queued (CLIENT-SDK.md's "Handler delivery" row) -- see those
348
+ * options' own doc comments.
349
+ */
350
+ close(opts?: CloseOptions): Promise<void>;
351
+ private closeImpl;
352
+ private isClosed;
353
+ /** Cancels a pending backoff timer, if any, and settles connectOnce's awaited delay promise so it doesn't dangle. */
354
+ private clearReconnectTimer;
355
+ /** Cancels the pending stability timer, if any (see the field's own doc comment). Idempotent. */
356
+ private clearStabilityTimer;
357
+ /**
358
+ * Arms the one-shot stability timer for `conn`, replacing (and so
359
+ * implicitly clearing) whatever timer was pending before -- there is only
360
+ * ever one conn worth timing at once: a drain-superseded predecessor is
361
+ * torn down as soon as the replacement's own `welcome` lands (connectOnce),
362
+ * which is exactly the moment this is called for the replacement, so the
363
+ * predecessor's own now-irrelevant timer never outlives this call.
364
+ */
365
+ private armStabilityTimer;
366
+ private connectOnce;
367
+ /**
368
+ * One dial + handshake attempt: resolves the token provider (fresh, per
369
+ * CLIENT-SDK.md's "Token provider" row), opens the socket, and -- if that succeeds --
370
+ * builds a `MixerConn` and waits for `welcome`. Returns a discriminated
371
+ * result rather than throwing, so connectOnce's retry loop above can
372
+ * decide what to do with a failure (retry once, or finalize it) without a
373
+ * second round of reclassifying the same error: `unauthorized` is true for
374
+ * exactly the two pre-welcome rejection shapes eligible for that retry (an
375
+ * HTTP 401 on the upgrade, or a handshake-phase UNAUTHORIZED, 4011, with
376
+ * or without a preceding `error{}`); `cancelled` is true when `close()`
377
+ * raced the dial/handshake and there is nothing left to report.
378
+ */
379
+ private dialAndHandshakeOnce;
380
+ private wireConn;
381
+ private notifyDisconnect;
382
+ /** Builds the final `DisconnectPayload` for a `DisconnectContext`, filling in `fatal` and, when present, the exhaustion message override. */
383
+ private buildPayload;
384
+ private rejectStartIfNeverConnected;
385
+ /** Fatal per WIRE.md section 2.9: never reconnect, surface loudly, and fail start() if it never got a first connection. One report only. */
386
+ private goFatal;
387
+ /**
388
+ * maxAttempts exhausted: stop, but connect() must reject if it never
389
+ * succeeded once. Reports exactly one `DisconnectReason` -- the merged
390
+ * exhaustion report, `fatal: true` with the exhaustion `message`, but
391
+ * carrying the underlying failure's `wsCode`/`errorCode`/`httpStatus`/
392
+ * `cause` from `ctx` -- never a second report on top of the one the
393
+ * failure itself would otherwise have gotten (blocker 1).
394
+ *
395
+ * Mirrors goFatal: clears the stability timer too (a live connection can
396
+ * still have one ticking, e.g. a drain hand-over's parallel dial failing
397
+ * while the old connection it was replacing is still up), and detaches
398
+ * every live/in-flight conn -- `conn`, `dialingConn`, `retiringConn`,
399
+ * which a drain hand-over can leave all pointing at the very same live
400
+ * connection, hence the reference-dedup below -- before failing it, so
401
+ * each one's own 'close' handler sees `this.conn`/`this.retiringConn`/
402
+ * `this.dialingConn` already cleared and produces no report of its own;
403
+ * this call is still the one report. Without this, an exhausted client
404
+ * left a still-live socket (and its ping/watchdog timers, and the
405
+ * now-orphaned stability timer) running past "closed".
406
+ */
407
+ private giveUp;
408
+ /**
409
+ * The single point where a recoverable disconnect either gets its one
410
+ * report and a scheduled retry, or -- if `maxAttempts` is already
411
+ * exhausted -- gets folded into `giveUp`'s one merged exhaustion report
412
+ * instead. Exhaustion is checked *before* any report goes out, which is
413
+ * what makes "exactly one `DisconnectReason` per disconnect" (blocker 1)
414
+ * hold: the two outcomes are mutually exclusive, never both.
415
+ */
416
+ private reportAndSchedule;
417
+ /**
418
+ * Close 4012 / close 1001 with no preceding `drain`: reconnect
419
+ * immediately, jitter random(0, 2000)ms only -- but still counts toward
420
+ * `attempt`/`maxAttempts`, so a 4012 flap loop (a server stuck
421
+ * accepting-then-immediately-draining) still respects the ceiling instead
422
+ * of retrying forever. (The `drain`-triggered parallel reconnect is a
423
+ * separate, direct connectOnce() call in the `drain` handler above and
424
+ * does not bump `attempt` -- it is the server telling us to move, not a
425
+ * failure being retried.)
426
+ */
427
+ private reconnectImmediately;
428
+ /**
429
+ * Close 4013 KEEPALIVE_TIMEOUT: one immediate attempt, then normal backoff
430
+ * (WIRE.md section 2.9). The immediate attempt goes through
431
+ * reportAndSchedule -- same as the 4012 path (reconnectImmediately) -- so
432
+ * it respects the maxAttempts ceiling too: a 4013 flap loop with no budget
433
+ * left gives up (one merged `fatal: true` report) instead of dialing a
434
+ * socket reportAndSchedule would otherwise have refused to allow.
435
+ *
436
+ * `keepaliveImmediateRetryUsed` is a once-only budget re-armed ONLY at
437
+ * stability (armStabilityTimer) -- deliberately never un-set here on the
438
+ * second (or any later) 4013: doing that would let the budget "spend then
439
+ * immediately un-spend itself" on the very next 4013, so four 4013s in a
440
+ * row with no intervening stable connection would go immediate, backoff,
441
+ * immediate, backoff... forever, instead of immediate once and backoff
442
+ * every time after that until the client actually goes stable.
443
+ */
444
+ private scheduleReconnectAfterKeepaliveTimeout;
445
+ /** Close 4009 ENHANCE_YOUR_CALM: start backoff at the cap, not at base. */
446
+ private scheduleReconnectAtCap;
447
+ /** Normal AWS full-jitter backoff: everything not covered by a more specific rule above. */
448
+ private scheduleReconnect;
449
+ }
450
+ /** AWS "full jitter": random(0, min(cap, base * 2^attempt)). */
451
+ export declare function fullJitterDelay(attempt: number, base: number, cap: number): number;
452
+ /** Connects to a ws-mixer.v1 server and starts the reconnect-managed client. Resolves once the first `welcome` lands. */
453
+ export declare function connect(url: string, opts: ConnectOptions): Promise<MixerClient>;