@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 ADDED
@@ -0,0 +1,357 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@mcpwarp/ws-mixer` (the JS/TypeScript client SDK) are documented here.
4
+
5
+ ## 0.6.0 - 2026-09-26
6
+
7
+ - A stream write that fails outside stream termination now reports that error to its write callback,
8
+ so a promisified/awaited write rejects. Previously, with no `'error'` listener attached, the
9
+ callback was called with no error — the write reported success even though its bytes were never
10
+ sent.
11
+ - On a stream whose peer CLOSE already arrived, a write whose socket send fails before the
12
+ connection's teardown reaches the stream is held, the same way as 0.4.0's held writes: the
13
+ buffered data, `'end'` and `'close'` are still delivered, and the callback is settled once the
14
+ read side has emitted `'end'` (or the stream is destroyed first) — with the error of whatever
15
+ tore the stream down first (connection teardown, or the app's own `destroy()`/`reset()`),
16
+ otherwise the send's own error. This no longer depends on teardown reaching the stream, so it
17
+ also holds after the app's own `closeWrite()`. If `'end'` had already fired, the callback gets
18
+ the send's error immediately.
19
+ With an `'error'` listener attached, 0.5.0 instead failed the callback immediately, which
20
+ errored the read side and blocked `'end'`; that is fixed too.
21
+ - Otherwise the callback gets the error immediately. This includes a write rejected after the
22
+ app's own `closeWrite()`: for a stream with no `'error'` listener it now also marks the stream
23
+ errored (`stream.errored` carries the error), same as before with a listener. If nothing is
24
+ listening, the SDK's internal no-op `'error'` listener is attached first, so the failure never
25
+ crashes the process.
26
+ - CI: a new `pair-go-js` conformance job runs the go->js pair matrix (`--mode pair`) against
27
+ ws-mixer-go at `goserver.pin`, with a `go->js: 10` floor in `conformance/COUNTS.json` (all 10 pair
28
+ scenarios pass at spec v0.4.0 / ws-mixer-go v0.6.0). `spec.pin` is v0.4.1 in this release, whose
29
+ runner enforces COUNTS on `--sdk`/`--mode`-filtered runs, so the floor is live.
30
+ - Docs: nearly all comments (84 of 88 references) now cite the split spec docs (`WIRE.md` section
31
+ 2.x, `CLIENT-SDK.md` rows, and this repo's `docs/DESIGN.md`) instead of the old monorepo
32
+ `OVERVIEW.md` section numbers. The remaining four point at material that no longer exists in any
33
+ spec doc and are left as-is (`conn.ts`, `control.test.ts` ×2, `reconnect.test.ts`).
34
+ Comment-only; no behaviour change.
35
+
36
+ ## 0.5.0 - 2026-09-25
37
+
38
+ - **Breaking:** `MixerClient.close()`'s options no longer take a `code` — `close({message})` always
39
+ closes the connection with `APPLICATION_CLOSE` (`0x0e`/WS close `4014`), never a caller-chosen
40
+ code. WIRE.md section 2.8 makes `APPLICATION_CLOSE` the only code an application may close a
41
+ *connection* with, so a caller-supplied code could previously close a healthy connection with a
42
+ protocol-fault code and produce an `errorCode`/`errorName` pair outside that table (D-2026-09-25-01).
43
+ Which path runs is chosen by whether `message` is present, not by any `code`: `close({message})`
44
+ is the application close, and `close()`/`close({})`/`message: undefined` is the default graceful
45
+ drain-then-close. Passing a `code` key at all (e.g. a plain-JS caller still on
46
+ `close({code: 14})`, with or without `message`) now throws a `TypeError` synchronously, before
47
+ any close is attempted, instead of silently taking either path. The `[0, 999]` `RangeError`
48
+ validation is gone along with `code`.
49
+ - Conformance adapter: the `close` command's `code` now accepts only `0`/absent (graceful close) or
50
+ `14` (application close via the new `close({message})`) — any other value replies with a
51
+ command-error, `"close: only 0 or 14"`.
52
+
53
+ ## 0.4.0 - 2026-09-25
54
+
55
+ - New `TokenUnavailableError` (exported from the package root): a token provider that throws or
56
+ rejects with an instance of it (directly, or wrapped via `cause`) now gets treated like a failed
57
+ dial instead of the fatal-by-default verdict every other provider throw/reject still gets — a
58
+ non-fatal `{phase: "dial", fatal: false}` report, normal full-jitter backoff (it counts as a
59
+ failed attempt: `reconnect.maxAttempts` applies, the stability rule is unaffected), and the
60
+ provider's error surfaced verbatim as `cause`, same as before. Detection is `instanceof
61
+ TokenUnavailableError` only, deliberately not duck-typed on any property or method, so an
62
+ unrelated library's error can never accidentally turn a genuinely fatal provider failure into an
63
+ endless retry loop. On the one-time pre-`welcome` refresh-retry's own provider call, a marked
64
+ failure leaves the retry budget spent and takes this same non-fatal path rather than going fatal
65
+ or granting a second refresh. On the very first connect, a marked failure no longer rejects
66
+ `start()`/`connect()` — same as a first-dial network error.
67
+ - A pre-`welcome` token rejection -- an HTTP 401 on the upgrade, or a handshake-phase
68
+ `UNAUTHORIZED` (`4011`, with or without a preceding `error{}`) -- now gets the same one-time
69
+ refresh-retry treatment in *either* place, as ONE shared budget: when `token` is a provider
70
+ function, the SDK calls it again and retries the dial immediately (no backoff); a second
71
+ rejection, in either shape (`401` then `4011`, or `4011` then `401`), is fatal. A retry dial that
72
+ instead fails for an unrelated, non-auth reason (a network error, an HTTP 5xx, a transport death
73
+ before `welcome`) is *not* treated as a second rejection -- it takes the ordinary recoverable path
74
+ (non-fatal report, normal backoff) -- but the budget stays spent regardless, so a genuine
75
+ rejection on some later cycle, before the client ever goes stable, is still fatal with no further
76
+ refresh. Previously only the dial's own HTTP 401 got this retry -- a handshake-phase `4011` was
77
+ always immediately fatal, even with a token provider present. A **static string** `token` is
78
+ unaffected: still fatal on the very first rejection of any of the three forms, since there is
79
+ nothing to refresh (this was already true for HTTP 401 with a static token; it now also
80
+ explicitly applies to the two handshake-phase forms, which previously had no retry concept at all
81
+ to skip). Also fixed: this refresh-retry is now unconditional on `reconnect.maxAttempts`/
82
+ reconnect being disabled (it applies to the very first connect too -- one immediate redial that
83
+ completes the initial connection is not itself "a reconnect"), matching `ws-mixer-go`'s
84
+ `dialAndHandshake`; it likewise does not itself increment the attempt counter.
85
+ - The refresh-retry budget above is a client-level flag (not local to one `connectOnce()` call), and
86
+ re-arms only once the connection reaches stability (see `stableAfter` below), never merely on the
87
+ next redial -- otherwise a server that welcomes and then closes shortly after could make the
88
+ client hit the token endpoint on every single reconnect cycle forever.
89
+ - New `reconnect.stableAfter` option (default `10000`ms, next to `base`/`cap`/`connectTimeout`/
90
+ `maxAttempts`; must be a finite number `>= 0`, or the constructor throws a `RangeError`
91
+ synchronously, same as `close()`'s `code` validation -- `0` is legal and reproduces the pre-0.4
92
+ "reset on `welcome`" behaviour): how long a connection must stay up past `welcome` before it's
93
+ considered stable (WIRE.md/OVERVIEW.md section 2.9's `stable`). Fixed: the backoff attempt
94
+ counter, and every once-only retry budget re-armed alongside it (4013 KEEPALIVE_TIMEOUT's one-shot
95
+ immediate retry; the pre-`welcome` token refresh-retry above), now reset only once a connection
96
+ reaches stability -- **not** on `welcome` itself, as before. Fixed alongside it: 4013's one-shot
97
+ budget no longer un-spends itself on the very next 4013 (a leftover pre-`stableAfter` line reset
98
+ the flag back to "unused" immediately after falling back to normal backoff, so four 4013s in a row
99
+ with no intervening stable connection cycled immediate/backoff/immediate/backoff... forever instead
100
+ of only the first ever being immediate). A server that welcomes and then immediately closes no
101
+ longer resets backoff every cycle (which turned it into a redial-roughly-once-a-second loop
102
+ instead of actually backing off): `4009`/`4014`'s own "start backoff at the cap" treatment is
103
+ unaffected (still an explicit "refused on purpose, back off hard" rule, not a workaround for the
104
+ counter's reset timing). Stability is tracked per connection: a `drain` hand-over's retired
105
+ connection ending, however long after its replacement's own `welcome`, never resets or clears the
106
+ replacement's own stability timer. `reconnect.maxAttempts`'s doc is updated to match: it now
107
+ counts consecutive reconnect attempts *without* a stable connection in between.
108
+ - Fixed: `giveUp` (the `reconnect.maxAttempts`-exhaustion path) now mirrors `goFatal` -- it clears
109
+ the stability timer and detaches-then-fails every live/in-flight conn (`conn`, `dialingConn`,
110
+ `retiringConn`, which a drain hand-over can leave all pointing at the very same live connection)
111
+ before reporting. Previously, exhaustion during (for example) a drain hand-over's parallel dial
112
+ failing could leave the still-live predecessor connection running -- socket, ping/watchdog timers,
113
+ and the now-orphaned stability timer all still ticking 10s past a client that already reports
114
+ itself `closed`.
115
+ - Fixed: a transport failure between the 101 upgrade and `welcome` no longer fabricates a
116
+ semantically-meaningless ws-mixer close code (`wsCode: 4002`, "as if" this side had itself closed
117
+ with `INTERNAL_ERROR`) depending on whether `ws`'s `'error'` or `'close'` event happened to be
118
+ delivered first. `'close'` is now the sole reporter for this window (measured against real `ws`
119
+ 8.21: a TCP reset/half-close/peer close frame delivers a bare `'close'` with no `'error'` at all;
120
+ a protocol-level failure `ws` itself detects delivers `'error'` immediately followed by `'close'`
121
+ a macrotask or more later -- `'close'` never races `'error'`, it always follows it, if it comes at
122
+ all); `'error'` only records its message for `'close'`'s report to fall back on when the close
123
+ itself carries no reason. The report now always carries the close code `ws` actually delivers
124
+ (`1006` for a genuine abnormal closure, or whatever real close code it sent), never a fabricated
125
+ one. If `'error'` fires and `'close'` never follows at all, nothing here waits on it: the existing
126
+ hello/welcome timeout (`wsCode` `4001`) is unaffected and is what eventually reports it, as before
127
+ -- deliberately the only fallback for that case. The connected phase was checked for the same
128
+ class of bug and does not have it: an `'error'` there never produces a report by itself (see the
129
+ `message` bullet below for the one real behaviour change to the connected phase's report shape).
130
+ - Fixed: a second `drain` on the same connection (before its first parallel reconnect resolves) no
131
+ longer starts a second, redundant parallel dial -- mirrors `ws-mixer-go`, which already ignores a
132
+ repeat drain the same way.
133
+ - `conformance/adapter/adapter.mjs`: `disconnected.error_name` now prefers the SDK's own
134
+ `DisconnectReason.errorName` (which already derives the wire name from a bare 4xxx close code, not
135
+ only from an explicit `error{}`) over re-deriving it from `errorCode` through the adapter's own
136
+ smaller name table.
137
+ - A disconnect whose close frame carried no reason (from the peer, or a `ws`-level abnormal closure)
138
+ now reports `message: "socket closed with code N"` instead of an empty string, in both the
139
+ connected and handshake phases. Previously that fallback text only ever reached the internal
140
+ `WsMixerError` used to reject in-flight sends and fail the handshake promise; the *emitted*
141
+ `'close'` event (and so `DisconnectPayload.message`) carried the bare, possibly-empty close
142
+ reason directly. A non-empty `message` is strictly more useful (and matches `ws-mixer-go`, which
143
+ reports `"peer closed with code N"` for the same case) -- consumers that want "nothing, if the
144
+ peer sent nothing" specifically should keep preferring `closeReason` over `message`, as the
145
+ `closeReason` field's own doc already recommends.
146
+ - Confirmed (no behavior change): `MixerClient.close()`/`close({code,message})` already stop
147
+ reconnecting in every state, and clear the stability timer alongside the existing backoff timer.
148
+ Precisely what's reported: one non-fatal disconnect when a connection had actually been
149
+ established for this cycle; nothing at all when none had (dialing, mid-handshake, or backing off)
150
+ -- exactly like plain `close()` in those same windows, since there is no connection to send
151
+ `error{}`/a WS close over in the first place.
152
+ - **Behavior change** (D-2026-09-20-09, cross-SDK alignment with `ws-mixer-go`): `errorCode`/
153
+ `errorName` are no longer synthesised for anything that isn't a ws-mixer wire close code. An HTTP
154
+ `401`/`403` upgrade rejection previously carried `errorCode: UNAUTHORIZED` -- it no longer does;
155
+ `httpStatus` alone still identifies it (`404`/`429`/`5xx` were never affected, they never carried
156
+ one). Conversely, a **connected**-phase bare close (no preceding `error{}`) in ws-mixer's private-use
157
+ range `4001`-`4999` now derives both `errorCode`/`errorName` from the wire code the same way the
158
+ dial/handshake phases already did -- previously only `errorName` was derived there, `errorCode`
159
+ stayed `undefined`. The derivation itself is unchanged: `errorCode = wsCode - 4000`, `errorName`
160
+ from the WIRE.md §2.8 table (unknown -> `INTERNAL_ERROR`), restricted to `4001`-`4999` (not `4000`,
161
+ which is never legitimately on the wire -- `NO_ERROR` closes as `1000`). Also fixed as part of the
162
+ same alignment: the `'fatal'` event's `WsMixerError.code` now follows this same rule instead of its
163
+ own, separately-stale `info.errorCode` read -- for an HTTP `401`/`403` it is now `INTERNAL_ERROR`
164
+ (no ws-mixer code exists for an upgrade rejection) where it used to be `UNAUTHORIZED`, and for a
165
+ bare `4010`/`4011` close it is now the derived `UNSUPPORTED`/`UNAUTHORIZED` (previously
166
+ `INTERNAL_ERROR`, mismatching the disconnect reason's own `errorCode` for that same close).
167
+ Consumers that need to distinguish *why* a fatal happened should branch on the disconnect reason's
168
+ `httpStatus`/`wsCode`/`errorCode`, not on the fatal error's `code` alone.
169
+ - **Fixed (blocker):** a stream `OPEN`, `app`, or `drain` event already queued for delivery when the
170
+ connection ended could be silently dropped instead of delivered, whenever an async
171
+ `onStream`/`onApp`/`onDrain` handler was still in flight at that exact moment (`error{}` is always
172
+ the *last* message on the wire, so anything queued ahead of it genuinely arrived before the
173
+ connection ended and is owed delivery — CLIENT-SDK.md's "Handler delivery" row). The delivery loop
174
+ now finishes flushing whatever was already queued even after the connection has torn down, instead
175
+ of exiting as soon as `this.closed` flips true out from under an in-flight `await`. **Behaviour
176
+ change:** `onStream`/`onApp`/`onDrain` (and the `'stream'`/`'app'`/`'drain'` events) may now fire
177
+ shortly after `MixerClient.close()`/`MixerConn.close()`'s own promise has already resolved, for an
178
+ event that arrived before that close. A `drain` delivered this way never starts a reconnect for a
179
+ connection that is no longer the live one: on an already closing/closed client the existing
180
+ `!this.closing && this.state !== "closed"` guard already covered it, but a `drain` queued behind a
181
+ blocked handler on a conn that then dies for an *unrelated* reason (its own 'close' handler already
182
+ ran synchronously and replaced/cleared `this.conn`, and already scheduled the real reconnect) needed
183
+ a further `this.conn === conn` guard -- without it, the flushed `drain` would still see
184
+ `!this.closing && this.state !== "closed" && !this.drainReconnectScheduled` all true and spawn a
185
+ second, parallel reconnect against the already-dead conn, latching `drainReconnectScheduled` and
186
+ wrongly suppressing the next legitimate close-driven reconnect. Nothing can be newly enqueued once
187
+ the connection is closed (defensive backstop, in addition to the architectural invariant that the
188
+ read path always stops before teardown), so the flush is always bounded and terminates.
189
+ - **Fixed:** an open stream whose connection died abnormally (a `1006` tunnel death, or any
190
+ connection-level failure) with no `'error'` listener attached used to end *cleanly* — `'end'`
191
+ then `'close'`, with `stream.errored` never set — indistinguishable from the peer's own response
192
+ legitimately finishing (CLIENT-SDK.md's "Stream teardown on disconnect" row; WIRE.md section 2.9
193
+ requires the opposite: `io.ErrUnexpectedEOF`-equivalent, unless that stream's own `CLOSE` had
194
+ already arrived). **Behaviour change**, and it now depends on whether the peer's own `CLOSE` for
195
+ *that stream* had already arrived when the connection died, per WIRE.md's "`CLOSE` preserves
196
+ buffered data; `RESET` discards it" rule:
197
+ - `CLOSE` already arrived: the response DID complete, independent of the connection dying. Node's
198
+ `push(null)` doesn't discard what's still buffered — it marks EOF, and Node delivers the buffered
199
+ bytes to the consumer and only then emits `'end'`. `stream.errored` stays `null` and no `'error'`
200
+ event fires. Only the **write** side fails: a write already pending, or started afterward, fails
201
+ promptly (its callback receives the connection's error) instead of hanging or silently
202
+ succeeding.
203
+ - Otherwise (a peer `RESET`, or the connection ending any other way, with this stream's `CLOSE`
204
+ never having arrived): the stream always ends with an error, never a false clean end —
205
+ `stream.errored` carries it, `'close'` fires, and (for a consumer that *did* attach `'error'`)
206
+ that listener receives it, but `'end'` is never emitted for this case; a read or write already
207
+ pending, or started afterward, fails promptly. A pending write's callback now also receives the
208
+ real error instead of being reported as having succeeded, in both cases above. A `RESET` always
209
+ discards buffered data and errors, even if this stream's `CLOSE` had *also* already arrived
210
+ (`RESET` always wins). A stream already fully, cleanly closed before the connection died (both
211
+ directions' `CLOSE` already exchanged) is unaffected — it already ended cleanly and is no longer
212
+ tracked by the connection at all. `MixerClient`'s class doc comment and README already described
213
+ the intended behaviour; the code now actually matches it, with no remaining gap against
214
+ CLIENT-SDK.md's "data already buffered ... still delivered first" rule.
215
+ - **Fixed:** a stream whose peer `CLOSE` had already arrived when the connection died -- the
216
+ cleanly-ending case in the bullet above -- never actually emitted `'close'` (never got
217
+ destroyed): `push(null)` delivered the buffered data and `'end'` fired, but nothing then called
218
+ `destroy()`. It now does: buffered data delivered, `'end'`, then `destroy()`/`'close'`, same as
219
+ a stream that ends any other way.
220
+ - **Fixed:** in that same state, a write already pending (or started afterward) had its error
221
+ routed through Node's ordinary Writable error path, which also marks the readable side
222
+ `errored` and permanently blocks `'end'` -- a consumer draining `data`/`end` on the stream could
223
+ hang forever waiting behind a write it never awaited. Affected write callbacks are now held and
224
+ only settled with the terminal error once the read side has finished delivering buffered data
225
+ and emitted `'end'`, or the stream is destroyed some other way (app `destroy()`, `reset()`). An
226
+ app that awaits a write's callback without ever reading this stream will wait until it does one
227
+ or the other.
228
+ - **Fixed:** a fatal close (`UNSUPPORTED`/`UNAUTHORIZED`) on the retiring connection during a
229
+ `drain` hand-over's parallel dial didn't cancel that in-flight dial: the client emitted
230
+ `'fatal'` and then flipped back to `"connected"` once the dial's own `welcome` landed, and with
231
+ `UNAUTHORIZED` it could also redial again via the pre-`welcome` token-refresh-retry loop. The
232
+ dial is now cancelled (`NO_ERROR`/"client closing") as part of going fatal, and the client stays
233
+ closed.
234
+ - `conformance/adapter/adapter.mjs` now reports the SDK's real `SDK_VERSION` in `ready`/`hello`
235
+ instead of a stale hardcoded `"0.1.0"`.
236
+
237
+ ## 0.3.1 - 2026-09-20
238
+
239
+ - `DisconnectReason` gains `closeReason`: the reason field of the close frame *received from the
240
+ peer*, verbatim -- never this side's own outgoing reason. Absent when this side initiated the
241
+ close (a peer's echo carries no information), when no close frame was observed at all (an
242
+ abnormal closure), or when the SDK closed on a peer's `error{}` without reading whatever close
243
+ frame follows it. `message` is unchanged and still carries the human-readable text in every case.
244
+ - Fixed: a **handshake-phase** close now classifies (and, for `4010`/`4011`, goes fatal) from the
245
+ ws-mixer wire code the same way a connected-phase one already did, covering two previously-wrong
246
+ cases. (1) A bare close carrying a ws-mixer wire code before `welcome` previously reported
247
+ `errorName: "INTERNAL_ERROR"` and `fatal: false` -- silently retrying forever against a server
248
+ that will never accept the retry. (2) The normative pre-`welcome` auth-rejection shape
249
+ (`error{code:11 UNAUTHORIZED}` + close `4011`, before `welcome` -- OVERVIEW.md section 3.4's
250
+ Authenticate hook, `spec/fixtures/sequences/auth_failure.json`) was previously treated as a
251
+ *local* protocol violation: the client replied with its own `error{PROTOCOL_ERROR}` (forbidden by
252
+ WIRE.md section 2.7 -- a peer that receives `error` must never reply), closed with its own `4001`
253
+ instead of `4011`, discarded the server's real reason, and retried forever. A revoked token is now
254
+ what it should always have been: one fatal, non-retried disconnect. The client's own hello/welcome
255
+ timeout now also reports its locally-generated `wsCode: 4001` (previously omitted).
256
+ - New public API: `MixerClient.close({ code, message })` (CLIENT-SDK.md's "Application close" row)
257
+ performs `error{code, message}` + WS close `4000+code` (message truncated to 123 UTF-8 bytes on a
258
+ character boundary) + close the socket, instead of the default graceful drain -- e.g. to close
259
+ with the new `APPLICATION_CLOSE` code below. `code` must be an integer in `[0, 999]`, or `close()`
260
+ throws a `RangeError` synchronously. `close()` with no arguments is unchanged. Fixed: `code`/
261
+ `message` now also apply when `close()` is called while a `drain`-triggered replacement dial or an
262
+ in-flight (pre-`welcome`) handshake is still outstanding -- both previously always sent
263
+ `error{NO_ERROR, "client closing"}` regardless of what was passed.
264
+ - New named error code: `APPLICATION_CLOSE` (`0x0e`, WS close `4014`). Connection-level only, never
265
+ emitted by ws-mixer itself -- reserved for the application above to close a connection for its
266
+ own reason (see `close({ code, message })` above). A **connected-phase** `4014` gets `4009`'s own
267
+ "start at the cap" backoff treatment (WIRE.md section 2.9), not plain full-jitter backoff: it is
268
+ by nature sent *after* `welcome` already reset the attempt counter, so plain backoff would redial
269
+ roughly once a second forever instead of climbing. A handshake-phase `4014` is unaffected (its
270
+ attempt counter still climbs normally).
271
+ - Fixed: closing with an error code whose `4000 + code` isn't a legal WS close code (any
272
+ `code > 999`, reachable off the wire whenever a peer sends `error{code >= 0x1000_0000}`) now sends
273
+ WS close `4002` (`INTERNAL_ERROR`'s close code) on the wire instead of throwing inside `ws` and
274
+ falling back to `terminate()` -- a bare abnormal closure (`1006`) that told the peer nothing at
275
+ all. `errorCode` and the reported `wsCode` stay the real, semantic `4000+code` either way; only
276
+ the bytes actually put on the wire are clamped (spec `WIRE.md` section 2.8 / decision
277
+ `D-2026-09-20-03`).
278
+ - Fixed: `SDK_VERSION` -- sent on the wire in `hello.agent.sdk_version` and the dial's `User-Agent`
279
+ header -- was hard-coded to `"0.2.0"`, two releases stale. Now kept in sync with `package.json`'s
280
+ version by hand, enforced by `test/version.test.ts` so a future release bump that forgets it fails
281
+ CI instead of silently drifting again.
282
+ - `conformance/adapter/adapter.mjs`: `disconnected` now always includes `phase`, and includes
283
+ `close_reason` when the SDK reports one; `close` now forwards a non-zero numeric `code`/`message`
284
+ to the new application-close API (`code: 0`/absent stays the existing graceful drain-then-close,
285
+ so `graceful_close.json`'s WIRE.md section 2.10 step 14 drain sequence keeps being exercised); the
286
+ RESET code-name table gained `14: "APPLICATION_CLOSE"`; `connect`'s `reconnect.baseMs`/
287
+ `reconnect.capMs` (when positive) now override the SDK's default backoff `base`/`cap`, so a
288
+ scenario needing a fast connected-phase `4014` at-cap reconnect isn't stuck behind the SDK's 60s
289
+ default cap.
290
+
291
+ ## 0.3.0 - 2026-09-04
292
+
293
+ Repo split from the `mcpwarp/ws-mixer` monorepo into its own repo,
294
+ `mcpwarp/ws-mixer-js`, per MIGRATION.md section 4.1.
295
+
296
+ - Licensed Apache-2.0 (previously `UNLICENSED` in the monorepo).
297
+ - The package now publishes to the public npm registry (`registry.npmjs.org`) instead of a
298
+ private registry.
299
+ - Spec fixtures are no longer vendored from the monorepo's `spec/` directory;
300
+ they are now fetched from `ws-mixer-spec` at the tag pinned in `spec.pin`
301
+ (`npm run fetch-spec`, into the gitignored `.spec/`).
302
+
303
+ ## 0.2.0
304
+
305
+ Per OVERVIEW.md section 4.0 ("Client SDK requirements") and the decision log entry dated
306
+ 2026-08-27.
307
+
308
+ - `token` may now be a provider callback (`() => string | Promise<string>`), not just a static
309
+ string. It is called fresh on every dial -- initial connect and every reconnect -- never cached.
310
+ - HTTP `401` on the upgrade now gets exactly one immediate refresh-retry when `token` is a
311
+ provider: the SDK calls it again and redials right away, no backoff, though the retry still
312
+ counts toward `reconnect.maxAttempts`. A second `401` is fatal. A static string `token` is
313
+ unchanged: fatal on the first `401`.
314
+ - A token provider that throws or rejects is fatal; the error is surfaced verbatim via the new
315
+ `DisconnectReason.cause` field.
316
+ - The disconnect reason reported to `onDisconnect` and the `'close'` event is now a `DisconnectReason`:
317
+ adds `phase` (`"dial"` | `"handshake"` | `"connected"`), `errorName`, `httpStatus`, and `cause`
318
+ alongside the existing `wsCode`/`errorCode`/`fatal`/`message`. Every disconnect -- not just fatal
319
+ or maxAttempts-exhaustion ones -- is now reported, including a recoverable dial failure and a
320
+ non-fatal handshake failure, which previously went unreported.
321
+ - Fixed: a disconnect that also exhausted `reconnect.maxAttempts` (or hit the dial-side 401
322
+ refresh-retry ceiling) previously fired `onDisconnect` **twice** -- once for the failure, once
323
+ for giving up. It's now exactly one report per disconnect, `fatal: true` with the exhaustion
324
+ message, carrying the underlying failure's `wsCode`/`errorCode`/`httpStatus`/`cause`.
325
+ - Fixed: the dial's per-attempt failure context (phase, HTTP status, provider `cause`) is now
326
+ threaded through each dial attempt instead of stored on shared mutable client fields, so a
327
+ concurrent old-connection close can no longer clobber a new dial's own disconnect report.
328
+ - Fixed: an `unexpected-response` during the dial (HTTP 401/403/404/429) now drains and destroys
329
+ the response, destroys the request, and terminates the socket -- `ws` skips its own cleanup once
330
+ a listener is attached, and this SDK's listener wasn't doing that cleanup itself.
331
+ - A `drain` while `reconnect.maxAttempts` is `0` no longer starts a parallel reconnect the client
332
+ is configured never to complete, and no longer treats the `drain` itself as an immediate fatal
333
+ event either: per OVERVIEW.md section 2.9, the connection is left alone, in-flight streams finish
334
+ normally, and only once the server's own deadline closes the socket with `4012` is that reported
335
+ -- once -- as the terminal `fatal: true` disconnect (`message: "drained; reconnect disabled"`).
336
+ - Fixed: a superseded (drained) connection's own teardown, once its replacement's `welcome` lands,
337
+ no longer fires a spurious `onDisconnect`/`'close'` -- the client is still connected throughout,
338
+ via the replacement. Its in-flight streams now get a stream-scoped `StreamError(CANCEL,
339
+ "connection drained")` instead of the connection-level error, via `MixerConn.fail()`'s new
340
+ optional per-stream error override.
341
+ - `MixerStream.reset()` now actually sets `resetCode`, as its docs already promised. Added
342
+ internal `abort()`, used for SDK-detected stream violations (mirrors a peer-sent RESET): unlike
343
+ the app-facing `reset()`, `abort()` also emits `'reset'`, since in both cases the stream's owner
344
+ didn't choose the teardown.
345
+ - Per OVERVIEW.md section 2.9's Drain and the 2026-08-27 decision log: an `OPEN` received above a
346
+ drain's `last_stream_id` is now connection-fatal `PROTOCOL_ERROR` (WS close `4001`), not a
347
+ stream-scoped `REFUSED_STREAM` -- the server already promised not to send one, so this is it
348
+ breaking that promise, not a benign race. `stats().drainRefusedOpens` is renamed
349
+ `stats().drainViolations` to match.
350
+ - New public exports: `TokenProvider`, `DisconnectReason`, `DisconnectPhase`, `DisconnectPayload`.
351
+
352
+ ## 0.1.0
353
+
354
+ Initial release: `connect()`, `MixerClient`'s reconnect/backoff state machine (OVERVIEW.md section
355
+ 2.9 -- full-jitter backoff, `drain`/`4012` handling, `4013`/`4009` special-cased retries, fatal-close
356
+ handling), `MixerConn` (handshake, keepalive, flow control, the control-priority + round-robin
357
+ write scheduler), `MixerStream` (`stream.Duplex`), and the frame/control wire codecs.
package/LICENSE ADDED
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright 2026 Anatoly Tarnavsky
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.