@mcpwarp/ws-mixer 0.6.0 → 0.7.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 +63 -0
- package/README.md +29 -5
- package/dist/client.d.ts +4 -4
- package/dist/conn.d.ts +38 -6
- package/dist/errors.d.ts +11 -1
- package/dist/index.cjs +260 -84
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +260 -84
- package/dist/index.js.map +1 -1
- package/dist/stream.d.ts +59 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,69 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@mcpwarp/ws-mixer` (the JS/TypeScript client SDK) are documented here.
|
|
4
4
|
|
|
5
|
+
## 0.7.0 - 2026-09-26
|
|
6
|
+
|
|
7
|
+
- A write issued in the same tick as the app's own `destroy()`/`reset()` (or `closeWrite()`) no
|
|
8
|
+
longer puts its DATA on the wire after the stream's `RESET`/`CLOSE` and reports success: the chunk
|
|
9
|
+
is never sent, and the write callback gets the stream's error exactly once --
|
|
10
|
+
`StreamError(CANCEL)` for `destroy()`/`reset()`, `StreamError(STREAM_CLOSED)` after
|
|
11
|
+
`closeWrite()`. DATA still queued when a stream is retired is now rejected the same way instead of
|
|
12
|
+
being dropped (its write callback used to hang forever).
|
|
13
|
+
- A stream CLOSE'd in both directions (the peer's `CLOSE` plus the app's own `closeWrite()`/`end()`,
|
|
14
|
+
in either order) is now destroyed once its read side has emitted `'end'` (and, after `end()`, once
|
|
15
|
+
`'finish'` has fired), so `'close'` fires; previously it never did. `'end'` still precedes
|
|
16
|
+
`'close'` when data is buffered. `reset()` on such a stream, before it gets there, now destroys it
|
|
17
|
+
(discarding what is still buffered) instead of doing nothing.
|
|
18
|
+
- `closeWrite()` now fails a write that was waiting on send credit with `StreamError(STREAM_CLOSED)`
|
|
19
|
+
instead of leaving it waiting for a `WINDOW` -- forever, once the stream was CLOSE'd both ways, not
|
|
20
|
+
even `destroy()` settled it. The callback is called right away unless the peer's `CLOSE` has
|
|
21
|
+
arrived and its data is still unread; then it is held like the writes in the next item.
|
|
22
|
+
- A failed write on a stream whose peer `CLOSE` already arrived is now held whatever this side's own
|
|
23
|
+
write state: after `closeWrite()` it used to fail immediately, erroring the read side and losing
|
|
24
|
+
the peer's buffered data (and `'end'`). The same now applies to a write issued after
|
|
25
|
+
`closeWrite()`. A held write callback is settled once `'end'` fires, when the app calls
|
|
26
|
+
`destroy()`/`reset()`, or when the connection is torn down with nothing left unread on the stream;
|
|
27
|
+
while data is still unread it waits for `'end'` (or `destroy()`/`reset()`), even past the
|
|
28
|
+
connection's teardown.
|
|
29
|
+
- A stream CLOSE'd both ways but not yet destroyed (its reader hasn't reached `'end'`) is now still
|
|
30
|
+
reached by the connection's teardown: with nothing left unread it is destroyed there, so `'close'`
|
|
31
|
+
fires and a held write callback settles. It keeps its clean end: the teardown's error is not
|
|
32
|
+
applied to it (no `resetCode`, a held write keeps its own `STREAM_CLOSED` or socket error).
|
|
33
|
+
Previously such a stream was never reached, and a write held on it hung (0.6.0 failed it
|
|
34
|
+
immediately instead, losing the peer's buffered data and `'end'`). The connection tracks such
|
|
35
|
+
streams only weakly, except while one owes a write callback: that one is held until its callback
|
|
36
|
+
settles, even if the app dropped it. One the app dropped with no write pending (say, `end()`'d but
|
|
37
|
+
never read) can still be garbage-collected on a long-lived connection, in which case its `'close'`
|
|
38
|
+
listeners never run -- as in 0.6.0, where such streams were unreachable from the teardown
|
|
39
|
+
entirely.
|
|
40
|
+
- On a drain hand-over, a write whose DATA was still queued behind another stream's in-flight write,
|
|
41
|
+
or whose in-flight socket send failed after the hand-over, now fails with its stream's own
|
|
42
|
+
`StreamError(CANCEL, "connection drained")`, the same error as `stream.errored`. Previously it got
|
|
43
|
+
the superseded connection's `NO_ERROR`, or the raw socket error.
|
|
44
|
+
- Connection-death errors seen by a stream are now always a `ConnError` (`stream.errored`, its
|
|
45
|
+
`'error'`, write callbacks, and `sendApp()` rejections): the socket's own close (previously a plain
|
|
46
|
+
`WsMixerError`), a graceful `close()`'s `NO_ERROR` (likewise), and a raw `ws` send failure such as
|
|
47
|
+
`Error("WebSocket is not open")` (previously leaked as-is) are wrapped, keeping the original as
|
|
48
|
+
`cause` and copying `code`/`wsCode`/`closeReason`. `ConnError`/`WsMixerError` accept `cause`. A
|
|
49
|
+
drain hand-over still gives `StreamError(CANCEL, "connection drained")`; README's "Errors" section
|
|
50
|
+
now spells out which class means what.
|
|
51
|
+
- `conn.once(...)` listeners for `'stream'`/`'app'`/`'drain'` (and `events.once(conn, ...)`) were
|
|
52
|
+
never detached: the ordered delivery loop called the unwrapped listener, so it fired again on every
|
|
53
|
+
later event and leaked, pinning whatever it closed over (e.g. its stream) for the connection's lifetime.
|
|
54
|
+
Fixed; they now fire once and detach.
|
|
55
|
+
- An incoming `RESET` message longer than 256 UTF-8 bytes is now clamped to 256 bytes on a character
|
|
56
|
+
boundary (in `'reset'` and the stream's `StreamError`). WIRE.md section 2.3's limit is a
|
|
57
|
+
sender-side SHOULD, so it stays tolerated, not a protocol error.
|
|
58
|
+
- Tests: `test/stream-matrix.test.ts`, a generated 600-cell matrix over the stream teardown seam
|
|
59
|
+
(state x trigger x `'error'` listener x read buffer x pending write, including DATA queued behind
|
|
60
|
+
a second stream's in-flight write, plus a no-reader variant for peer-CLOSE'd streams) through a
|
|
61
|
+
real `MixerConn`, checking write callbacks settle exactly once and only report success for bytes
|
|
62
|
+
on the wire, no DATA after `CLOSE`/`RESET`, buffered data and `'end'` before `'close'`, `'close'`
|
|
63
|
+
exactly once and last, and the class and code of `stream.errored` and of any write-callback error
|
|
64
|
+
per trigger.
|
|
65
|
+
- ci: publish workflow now runs typecheck + tests (test job, on Node 20 and 24) before npm publish; a failing tag no longer ships.
|
|
66
|
+
- Code comments now point to ws-mixer-go (`ws-mixer-go/wsmixer/...`) and ws-mixer-spec (`ws-mixer-spec/docs/research/...`) instead of the old monorepo paths.
|
|
67
|
+
|
|
5
68
|
## 0.6.0 - 2026-09-26
|
|
6
69
|
|
|
7
70
|
- A stream write that fails outside stream termination now reports that error to its write callback,
|
package/README.md
CHANGED
|
@@ -254,11 +254,22 @@ Also true regardless of trigger:
|
|
|
254
254
|
preserves buffered data; `RESET` discards it" rule:
|
|
255
255
|
- The peer's `CLOSE` had already arrived (its read side had already legitimately finished on the
|
|
256
256
|
wire, independent of the connection dying): the response DID complete. Whatever was buffered is
|
|
257
|
-
still delivered, `'end'` still fires once it's drained, `
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
connection
|
|
261
|
-
though this stream's response was already
|
|
257
|
+
still delivered, `'end'` still fires once it's drained, and no `'error'` event fires -- exactly
|
|
258
|
+
as if the connection hadn't died. Only the **write** side fails: a write already pending, or
|
|
259
|
+
started afterward, fails (its callback receives the connection's `ConnError`, or
|
|
260
|
+
`StreamError(CANCEL, "connection drained")` on a drain hand-over) instead of hanging or silently
|
|
261
|
+
succeeding, since the connection is gone even though this stream's response was already
|
|
262
|
+
complete. `stream.errored` stays `null` unless such a write failed, in which case Node's own
|
|
263
|
+
Writable machinery sets it to that same error. If this side had also already closed its
|
|
264
|
+
write side (`closeWrite()`/`end()`), the stream's exchange was complete before the connection
|
|
265
|
+
died: a write fails with its own `StreamError(STREAM_CLOSED)` (or its own socket error) instead.
|
|
266
|
+
When the callback is called: with nothing left unread, as part of the teardown, and `'close'`
|
|
267
|
+
fires then too; with data still unread, only once the reader drains it and `'end'` fires (an
|
|
268
|
+
error any earlier would discard that data and suppress `'end'`), or when the app calls
|
|
269
|
+
`destroy()`/`reset()`. A stream with unread data that nobody ever reads keeps both pending until
|
|
270
|
+
the app does one or the other. The connection tracks a stream CLOSE'd both ways only weakly,
|
|
271
|
+
except while it owes a write callback: one the app dropped with no write pending may be
|
|
272
|
+
garbage-collected before the teardown, and then its `'close'` listeners never run.
|
|
262
273
|
- Otherwise (a peer `RESET`, or the connection ending abnormally -- `1006`, or any other way other
|
|
263
274
|
than that stream's own clean `CLOSE` -- with this stream's read side never having legitimately
|
|
264
275
|
finished): the stream always ends with an error, never a false clean end. `stream.errored`
|
|
@@ -316,6 +327,19 @@ only when this particular error was built from an actually-observed close frame
|
|
|
316
327
|
locally-raised protocol violation), and under the same "never this side's own outgoing reason" rule
|
|
317
328
|
as `closeReason`.
|
|
318
329
|
|
|
330
|
+
On a stream (`stream.errored`, its `'error'` event, a write callback) the subclass says what ended
|
|
331
|
+
it (a `sendApp()` rejection is always the `ConnError` case):
|
|
332
|
+
|
|
333
|
+
- `ConnError` -- the connection under it ended: an abnormal closure, a graceful `close()`, a
|
|
334
|
+
protocol failure, or a failed socket write. Whatever the connection's own terminal error was
|
|
335
|
+
(the socket's `WsMixerError`, a raw `ws` send error) is kept as `cause`; `code`, `wsCode` and
|
|
336
|
+
`closeReason` are copied from it.
|
|
337
|
+
- `StreamError` -- this stream alone was reset: a peer `RESET`, the app's own `reset()`/
|
|
338
|
+
`destroy()`/`close()` (`CANCEL`), a write after `closeWrite()` (`STREAM_CLOSED`), or a drain
|
|
339
|
+
hand-over (`CANCEL`, `"connection drained"`, see above).
|
|
340
|
+
- `null` (`stream.errored`) -- a clean end: the peer's `CLOSE` arrived and nothing pending failed,
|
|
341
|
+
or the app's own `destroy()` with no error and no write pending.
|
|
342
|
+
|
|
319
343
|
The `'fatal'` event's `WsMixerError.code` follows the same "present only when a ws-mixer error code
|
|
320
344
|
actually applies" rule as `DisconnectReason.errorCode` above (D-2026-09-20-09) -- it is `INTERNAL_ERROR`
|
|
321
345
|
whenever no ws-mixer code exists for the underlying failure (an HTTP `401`/`403` upgrade rejection,
|
package/dist/client.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Public client entry point: `connect(url, opts)`. Owns the WebSocket
|
|
3
3
|
* handshake (subprotocol, headers, size limits), and the reconnect/backoff
|
|
4
|
-
* policy from docs/research/2026-08-26-control-channel-and-connection-lifecycle.md
|
|
4
|
+
* policy from ws-mixer-spec/docs/research/2026-08-26-control-channel-and-connection-lifecycle.md
|
|
5
5
|
* and WIRE.md section 2.9's reconnect table. `MixerConn` (conn.ts) owns
|
|
6
6
|
* everything about one already-connected socket; this file owns the loop
|
|
7
7
|
* that replaces it.
|
|
@@ -19,7 +19,7 @@ import { WsMixerError } from "./errors.js";
|
|
|
19
19
|
import { MixerConn, type WSLike } from "./conn.js";
|
|
20
20
|
import type { MixerStream } from "./stream.js";
|
|
21
21
|
export declare const SUBPROTOCOL = "ws-mixer.v1";
|
|
22
|
-
export declare const SDK_VERSION = "0.
|
|
22
|
+
export declare const SDK_VERSION = "0.7.0";
|
|
23
23
|
export type ClientState = "idle" | "dialing" | "connected" | "backoff" | "closed";
|
|
24
24
|
export interface ReconnectOptions {
|
|
25
25
|
/** Base delay for exponential backoff, ms. Default 1000. */
|
|
@@ -111,7 +111,7 @@ export interface DisconnectReason {
|
|
|
111
111
|
* application-layer code that is legal for a stream RESET but never was
|
|
112
112
|
* for a connection close), that can differ from the *actual* bytes this
|
|
113
113
|
* side puts on the wire, which are clamped to `4000+INTERNAL_ERROR` (4002)
|
|
114
|
-
* instead (conn.ts's `wireCloseCode`, mirroring go/wsmixer's
|
|
114
|
+
* instead (conn.ts's `wireCloseCode`, mirroring ws-mixer-go/wsmixer's
|
|
115
115
|
* `wsCloseCode`) -- `errorCode` always keeps the real, unclamped code
|
|
116
116
|
* either way. When instead observed directly from a bare close frame
|
|
117
117
|
* (`onSocketClose`, no preceding `error{}`), `wsCode` is exactly what was
|
|
@@ -231,7 +231,7 @@ export declare interface MixerClient {
|
|
|
231
231
|
/**
|
|
232
232
|
* MixerClient owns the reconnect loop: it replaces `conn` with a fresh
|
|
233
233
|
* MixerConn on every disconnect, per the policy in
|
|
234
|
-
* docs/research/2026-08-26-control-channel-and-connection-lifecycle.md.
|
|
234
|
+
* ws-mixer-spec/docs/research/2026-08-26-control-channel-and-connection-lifecycle.md.
|
|
235
235
|
*
|
|
236
236
|
* In-flight streams are lost on reconnect -- there is no resumption
|
|
237
237
|
* (WIRE.md section 2.9). A handler must tell "the response ended" (EOF)
|
package/dist/conn.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* The JS SDK is always the answering peer (client): it never calls
|
|
6
6
|
* OpenStream, only receives OPEN from the server.
|
|
7
7
|
*
|
|
8
|
-
* Mirrors `go/wsmixer/conn.go`, `dispatch.go`, `sched.go`, `keepalive.go` and
|
|
8
|
+
* Mirrors `ws-mixer-go/wsmixer/conn.go`, `dispatch.go`, `sched.go`, `keepalive.go` and
|
|
9
9
|
* `drain.go`, adapted to Node's single-threaded event loop (no goroutines:
|
|
10
10
|
* one write scheduler driven by a wake/notify queue instead of channels).
|
|
11
11
|
*/
|
|
@@ -78,6 +78,23 @@ export declare class MixerConn extends EventEmitter {
|
|
|
78
78
|
/** The `last_stream_id` from the most recently received `drain`; set only once draining. */
|
|
79
79
|
private lastStreamId?;
|
|
80
80
|
private readonly streams;
|
|
81
|
+
/**
|
|
82
|
+
* Retired (out of `streams`: no frame or DATA is ever routed to them again)
|
|
83
|
+
* but not yet destroyed -- in practice a stream CLOSE'd both ways, waiting
|
|
84
|
+
* on its reader's 'end'. Tracked only so rejectOutstanding() still tears
|
|
85
|
+
* them down; each leaves on its own 'close', and teardown clears the rest.
|
|
86
|
+
* Weak by default: a stream never read never emits 'end', so never
|
|
87
|
+
* 'close', and a strong ref would pin every such stream the app abandoned
|
|
88
|
+
* for the connection's lifetime. Strong while the stream holds a write
|
|
89
|
+
* callback (retireStream(), or pinRetired() when one is held after
|
|
90
|
+
* retirement): the stream owns that callback, not the other way round, and
|
|
91
|
+
* the callback is what the app is waiting on -- collecting the stream
|
|
92
|
+
* would leave it unsettled forever. A retired stream's held callbacks are
|
|
93
|
+
* only ever flushed by destroy(), so a strong entry leaves on 'close' and
|
|
94
|
+
* is never unpinned.
|
|
95
|
+
* Dead weak refs are pruned whenever the map is touched.
|
|
96
|
+
*/
|
|
97
|
+
private readonly retired;
|
|
81
98
|
private highestOpened;
|
|
82
99
|
/**
|
|
83
100
|
* Minimal "ignore and count" counters (item 11 of the review: full
|
|
@@ -99,7 +116,7 @@ export declare class MixerConn extends EventEmitter {
|
|
|
99
116
|
private deliveryRunning;
|
|
100
117
|
private readonly stream0Bucket;
|
|
101
118
|
private nextPingId;
|
|
102
|
-
/** Watermark: every id below this has been acked (or pruned as stale) at least once. Mirrors go/wsmixer's Conn.lowestUnacked. */
|
|
119
|
+
/** Watermark: every id below this has been acked (or pruned as stale) at least once. Mirrors ws-mixer-go/wsmixer's Conn.lowestUnacked. */
|
|
103
120
|
private lowestUnacked;
|
|
104
121
|
private lastPongAt;
|
|
105
122
|
private readonly outstandingPings;
|
|
@@ -124,7 +141,7 @@ export declare class MixerConn extends EventEmitter {
|
|
|
124
141
|
private dispatchControl;
|
|
125
142
|
/**
|
|
126
143
|
* Queues one `'stream'`/`'app'`/`'drain'` event for in-order, async
|
|
127
|
-
* delivery, mirroring go/wsmixer's deliveryLoop: the read/dispatch path
|
|
144
|
+
* delivery, mirroring ws-mixer-go/wsmixer's deliveryLoop: the read/dispatch path
|
|
128
145
|
* above never blocks on application code, but all three still fire in
|
|
129
146
|
* wire order, off a single loop, one at a time. Handlers registered via
|
|
130
147
|
* `on('stream'|'app'|'drain', ...)` must not block for long -- a handler
|
|
@@ -168,6 +185,10 @@ export declare class MixerConn extends EventEmitter {
|
|
|
168
185
|
* caught here, counted (stats().handlerErrors) and surfaced via a guarded
|
|
169
186
|
* `'handlerError'` emit -- never an unhandled rejection, and never fatal
|
|
170
187
|
* to delivery: the next queued stream/app/drain event still runs.
|
|
188
|
+
*
|
|
189
|
+
* Iterates rawListeners(), not listeners(): for a once() registration that
|
|
190
|
+
* is the self-removing wrapper, so it detaches on first delivery (it
|
|
191
|
+
* returns and throws exactly what the wrapped listener does).
|
|
171
192
|
*/
|
|
172
193
|
private emitOrdered;
|
|
173
194
|
/**
|
|
@@ -193,8 +214,19 @@ export declare class MixerConn extends EventEmitter {
|
|
|
193
214
|
private sendControlFrame;
|
|
194
215
|
/** Backs `streamHost.sendData`: enqueue a DATA chunk for round-robin transmission. */
|
|
195
216
|
private sendData;
|
|
196
|
-
/**
|
|
217
|
+
/**
|
|
218
|
+
* Backs `streamHost.retireStream`: retire a fully-closed stream's id. DATA
|
|
219
|
+
* still queued for it is rejected, never dropped (a dropped item's write
|
|
220
|
+
* callback would hang forever) -- with the stream's own sendDone() error
|
|
221
|
+
* (its RESET's, or STREAM_CLOSED after its CLOSE). A stream not yet
|
|
222
|
+
* destroyed moves to `retired` until its 'close' -- strongly if it holds
|
|
223
|
+
* a write callback, else weakly (see `retired`).
|
|
224
|
+
*/
|
|
197
225
|
private retireStream;
|
|
226
|
+
/** Backs `streamHost.pinRetired`: a retired stream now holding a write callback is held strongly until its 'close'. */
|
|
227
|
+
private pinRetired;
|
|
228
|
+
/** Drops weak `retired` entries whose stream has been garbage-collected. */
|
|
229
|
+
private pruneRetired;
|
|
198
230
|
/** Resolves every pending close()'s wait for the stream table to empty, instead of polling. */
|
|
199
231
|
private notifyIdle;
|
|
200
232
|
private waitForEmpty;
|
|
@@ -219,7 +251,7 @@ export declare class MixerConn extends EventEmitter {
|
|
|
219
251
|
private startKeepalive;
|
|
220
252
|
private sendPing;
|
|
221
253
|
/**
|
|
222
|
-
* Pong watermark scheme, mirroring go/wsmixer/dispatch.go's handlePong:
|
|
254
|
+
* Pong watermark scheme, mirroring ws-mixer-go/wsmixer/dispatch.go's handlePong:
|
|
223
255
|
* every id below lowestUnacked has been acked (or pruned as stale) at
|
|
224
256
|
* least once, and no id >= nextPingId has ever been sent.
|
|
225
257
|
* - id >= nextPingId: never sent -> PROTOCOL_ERROR, connection-fatal.
|
|
@@ -266,7 +298,7 @@ export declare class MixerConn extends EventEmitter {
|
|
|
266
298
|
/**
|
|
267
299
|
* The peer sent `error{code,message}`: record it and close with
|
|
268
300
|
* `4000 + code` immediately, without waiting for the peer to do anything
|
|
269
|
-
* else -- mirrors go/wsmixer/dispatch.go's handlePeerError. `error` is
|
|
301
|
+
* else -- mirrors ws-mixer-go/wsmixer/dispatch.go's handlePeerError. `error` is
|
|
270
302
|
* always the last message on the wire (WIRE.md section 2.7), so
|
|
271
303
|
* there is nothing left to negotiate.
|
|
272
304
|
*/
|
package/dist/errors.d.ts
CHANGED
|
@@ -57,17 +57,27 @@ export declare class WsMixerError extends Error {
|
|
|
57
57
|
streamId?: number;
|
|
58
58
|
wsCode?: number;
|
|
59
59
|
closeReason?: string;
|
|
60
|
+
cause?: unknown;
|
|
60
61
|
});
|
|
61
62
|
get codeName(): string;
|
|
62
63
|
toString(): string;
|
|
63
64
|
}
|
|
64
|
-
/**
|
|
65
|
+
/**
|
|
66
|
+
* A connection-level error: a locally-detected violation that desynchronizes
|
|
67
|
+
* shared connection state (always error{code} + WS close), and also what every
|
|
68
|
+
* stream still live when the connection ends -- and every write/send pending
|
|
69
|
+
* on it -- is failed with, whatever ended it (MixerConn.rejectOutstanding
|
|
70
|
+
* wraps a non-ConnError teardown error, keeping the original as `cause`).
|
|
71
|
+
*/
|
|
65
72
|
export declare class ConnError extends WsMixerError {
|
|
66
73
|
readonly lastStreamId?: number;
|
|
67
74
|
constructor(code: number, message: string, opts?: {
|
|
68
75
|
streamId?: number;
|
|
69
76
|
lastStreamId?: number;
|
|
70
77
|
fatal?: boolean;
|
|
78
|
+
wsCode?: number;
|
|
79
|
+
closeReason?: string;
|
|
80
|
+
cause?: unknown;
|
|
71
81
|
});
|
|
72
82
|
}
|
|
73
83
|
/** A stream-scoped error: produces RESET(code, message) on one stream; the connection stays up. */
|