@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 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, `stream.errored` stays `null`, and no
258
- `'error'` event fires -- exactly as if the connection hadn't died. Only the **write** side fails:
259
- a write already pending, or started afterward, fails promptly (its callback receives the
260
- connection's error) instead of hanging or silently succeeding, since the connection is gone even
261
- though this stream's response was already complete.
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.6.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
- /** Backs `streamHost.retireStream`: retire a fully-closed stream's id. */
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
- /** A connection-fatal error: desynchronizes shared connection state. Always error{code} + WS close. */
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. */