@mcpwarp/ws-mixer 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +357 -0
- package/LICENSE +202 -0
- package/README.md +431 -0
- package/dist/client.d.ts +453 -0
- package/dist/conn.d.ts +328 -0
- package/dist/control.d.ts +74 -0
- package/dist/errors.d.ts +96 -0
- package/dist/frame.d.ts +52 -0
- package/dist/index.cjs +2866 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +2806 -0
- package/dist/index.js.map +1 -0
- package/dist/stream.d.ts +124 -0
- package/dist/util.d.ts +3 -0
- package/package.json +59 -0
package/dist/client.d.ts
ADDED
|
@@ -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>;
|