@kuralle-syrinx/ws 4.4.0 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,350 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+ import { WebSocketServer, type WebSocket } from "ws";
5
+ import type { RetryConfig } from "@kuralle-syrinx/core";
6
+
7
+ import { WebSocketConnection, type ManagedSocket, type SocketFactory } from "./index.js";
8
+ import { createNodeWsSocket } from "./node.js";
9
+
10
+ let servers: WebSocketServer[] = [];
11
+
12
+ afterEach(async () => {
13
+ await Promise.all(
14
+ servers.splice(0).map(
15
+ (server) =>
16
+ new Promise<void>((resolve) => {
17
+ for (const client of server.clients) client.terminate();
18
+ server.close(() => resolve());
19
+ }),
20
+ ),
21
+ );
22
+ });
23
+
24
+ async function createServer(onConnection: (socket: WebSocket) => void): Promise<{ url: string; port: number; server: WebSocketServer }> {
25
+ const server = await new Promise<WebSocketServer>((resolve) => {
26
+ let next: WebSocketServer;
27
+ next = new WebSocketServer({ port: 0 }, () => resolve(next));
28
+ });
29
+ servers.push(server);
30
+ server.on("connection", onConnection);
31
+ const address = server.address();
32
+ if (!address || typeof address === "string") throw new Error("Expected TCP server address");
33
+ return { url: `ws://127.0.0.1:${address.port}/`, port: address.port, server };
34
+ }
35
+
36
+ const FAST_RETRY: RetryConfig = { maxAttempts: 3, baseDelayMs: 10, maxDelayMs: 40 };
37
+
38
+ async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
39
+ const startedAt = Date.now();
40
+ while (Date.now() - startedAt < timeoutMs) {
41
+ if (predicate()) return;
42
+ await new Promise((resolve) => setTimeout(resolve, 5));
43
+ }
44
+ throw new Error("Timed out waiting for condition");
45
+ }
46
+
47
+ describe("WebSocketConnection", () => {
48
+ it("delivers messages and holds the socket open with a KeepAlive", async () => {
49
+ const serverReceived: string[] = [];
50
+ const { url } = await createServer((socket) => {
51
+ socket.on("message", (data, isBinary) => {
52
+ if (!isBinary) serverReceived.push(data.toString());
53
+ });
54
+ socket.send("hello-from-server");
55
+ });
56
+
57
+ const messages: string[] = [];
58
+ const conn = new WebSocketConnection({
59
+ url: () => url,
60
+ socketFactory: createNodeWsSocket,
61
+ retry: FAST_RETRY,
62
+ keepAliveIntervalMs: 30,
63
+ keepAliveMessage: () => JSON.stringify({ type: "KeepAlive" }),
64
+ onMessage: (data, isBinary) => {
65
+ if (!isBinary) messages.push(data.toString());
66
+ },
67
+ });
68
+ await conn.connect();
69
+ await waitFor(() => messages.includes("hello-from-server"));
70
+ // Several keepalive intervals should have elapsed.
71
+ await waitFor(() => serverReceived.filter((m) => m.includes("KeepAlive")).length >= 2);
72
+
73
+ expect(messages).toContain("hello-from-server");
74
+ expect(serverReceived.filter((m) => m.includes("KeepAlive")).length).toBeGreaterThanOrEqual(2);
75
+
76
+ await conn.close();
77
+ });
78
+
79
+ it("reconnects with verification after the server drops the socket", async () => {
80
+ let connections = 0;
81
+ const { url } = await createServer((socket) => {
82
+ connections += 1;
83
+ // ws auto-replies to ping with pong, so verify() passes.
84
+ if (connections === 1) {
85
+ // Drop the first connection shortly after it opens.
86
+ setTimeout(() => socket.close(1011, "transient"), 20);
87
+ }
88
+ });
89
+
90
+ let reconnecting = 0;
91
+ let reconnected = 0;
92
+ const conn = new WebSocketConnection({
93
+ url: () => url,
94
+ socketFactory: createNodeWsSocket,
95
+ retry: FAST_RETRY,
96
+ minStableMs: 0, // don't treat the deliberate drop as a quick failure
97
+ onMessage: () => undefined,
98
+ onReconnecting: () => {
99
+ reconnecting += 1;
100
+ },
101
+ onReconnected: () => {
102
+ reconnected += 1;
103
+ },
104
+ });
105
+ await conn.connect();
106
+ await waitFor(() => reconnected >= 1);
107
+
108
+ expect(reconnecting).toBeGreaterThanOrEqual(1);
109
+ expect(reconnected).toBe(1);
110
+ expect(connections).toBeGreaterThanOrEqual(2);
111
+ // The reconnected socket is usable.
112
+ expect(conn.isReady).toBe(true);
113
+ conn.send("after-reconnect");
114
+
115
+ await conn.close();
116
+ });
117
+
118
+ it("gives up and reports unrecoverable when the server stays down", async () => {
119
+ const { url, server } = await createServer(() => undefined);
120
+ let unrecoverable: Error | null = null;
121
+ const conn = new WebSocketConnection({
122
+ url: () => url,
123
+ socketFactory: createNodeWsSocket,
124
+ retry: FAST_RETRY,
125
+ maxReconnectAttempts: 2,
126
+ minStableMs: 0,
127
+ onMessage: () => undefined,
128
+ onUnrecoverable: (err) => {
129
+ unrecoverable = err;
130
+ },
131
+ });
132
+ await conn.connect();
133
+ expect(conn.isReady).toBe(true);
134
+
135
+ // Kill the server: the live socket drops and every reconnect attempt fails.
136
+ await new Promise<void>((resolve) => {
137
+ for (const client of server.clients) client.terminate();
138
+ server.close(() => resolve());
139
+ });
140
+
141
+ await waitFor(() => unrecoverable !== null, 3000);
142
+ expect(unrecoverable).toBeInstanceOf(Error);
143
+ expect((unrecoverable! as Error).message).toContain("failed to reconnect");
144
+
145
+ await conn.close();
146
+ });
147
+
148
+ it("rejects connect when disposed before the socket opens", async () => {
149
+ const hangingSocket: ManagedSocket = {
150
+ get isOpen() {
151
+ return false;
152
+ },
153
+ send: () => undefined,
154
+ keepAlivePing: () => undefined,
155
+ verify: async () => false,
156
+ dispose: () => undefined,
157
+ onOpen: () => undefined,
158
+ onMessage: () => undefined,
159
+ onClose: () => undefined,
160
+ onError: () => undefined,
161
+ };
162
+ const socketFactory: SocketFactory = () => hangingSocket;
163
+ const conn = new WebSocketConnection({
164
+ url: () => "ws://127.0.0.1:1/",
165
+ socketFactory,
166
+ retry: FAST_RETRY,
167
+ onMessage: () => undefined,
168
+ });
169
+
170
+ const connectPromise = conn.connect();
171
+ await new Promise((resolve) => setTimeout(resolve, 10));
172
+ await conn.close();
173
+
174
+ await expect(connectPromise).rejects.toThrow(/disposed/i);
175
+ });
176
+
177
+ it("stops reconnecting when the socket keeps dying right after connecting", async () => {
178
+ // Server accepts + answers ping (verify passes) but closes ~15ms after open,
179
+ // every time — backoff can't fix this, so the quick-failure guard must give up.
180
+ const { url } = await createServer((socket) => {
181
+ setTimeout(() => socket.close(1011, "flap"), 15);
182
+ });
183
+
184
+ let unrecoverable: Error | null = null;
185
+ let reconnects = 0;
186
+ const conn = new WebSocketConnection({
187
+ url: () => url,
188
+ socketFactory: createNodeWsSocket,
189
+ retry: FAST_RETRY,
190
+ minStableMs: 200,
191
+ maxQuickFailures: 2,
192
+ onMessage: () => undefined,
193
+ onReconnected: () => {
194
+ reconnects += 1;
195
+ },
196
+ onUnrecoverable: (err) => {
197
+ unrecoverable = err;
198
+ },
199
+ });
200
+ await conn.connect();
201
+ await waitFor(() => unrecoverable !== null, 3000);
202
+
203
+ expect((unrecoverable! as Error).message).toContain("check credentials or provider policy");
204
+ // It should bail out quickly, not loop forever.
205
+ expect(reconnects).toBeLessThanOrEqual(3);
206
+
207
+ await conn.close();
208
+ });
209
+
210
+ it("rejects openSocket when the socket factory never resolves", async () => {
211
+ const connectTimeoutMs = 50;
212
+ const hangingFactory: SocketFactory = () =>
213
+ new Promise<ManagedSocket>(() => {
214
+ /* never resolves */
215
+ });
216
+
217
+ const conn = new WebSocketConnection({
218
+ url: () => "ws://127.0.0.1:1/",
219
+ socketFactory: hangingFactory,
220
+ connectTimeoutMs,
221
+ retry: FAST_RETRY,
222
+ onMessage: () => undefined,
223
+ });
224
+
225
+ const startedAt = Date.now();
226
+ await expect(conn.connect()).rejects.toThrow(/connect timeout/i);
227
+ expect(Date.now() - startedAt).toBeLessThan(connectTimeoutMs + 100);
228
+ expect(Date.now() - startedAt).toBeGreaterThanOrEqual(connectTimeoutMs - 10);
229
+
230
+ await conn.close();
231
+ });
232
+
233
+ it("rejects openSocket when the socket fires no event after construction", async () => {
234
+ const connectTimeoutMs = 50;
235
+ const silentSocket: ManagedSocket = {
236
+ get isOpen() {
237
+ return false;
238
+ },
239
+ send: () => undefined,
240
+ keepAlivePing: () => undefined,
241
+ verify: async () => false,
242
+ dispose: () => undefined,
243
+ onOpen: () => undefined,
244
+ onMessage: () => undefined,
245
+ onClose: () => undefined,
246
+ onError: () => undefined,
247
+ };
248
+ const socketFactory: SocketFactory = () => silentSocket;
249
+
250
+ const conn = new WebSocketConnection({
251
+ url: () => "ws://127.0.0.1:1/",
252
+ socketFactory,
253
+ connectTimeoutMs,
254
+ retry: { maxAttempts: 2, baseDelayMs: 5, maxDelayMs: 10 },
255
+ maxReconnectAttempts: 1,
256
+ minStableMs: 0,
257
+ onMessage: () => undefined,
258
+ });
259
+
260
+ const startedAt = Date.now();
261
+ await expect(conn.connect()).rejects.toThrow(/connect timeout/i);
262
+ expect(Date.now() - startedAt).toBeLessThan(connectTimeoutMs + 100);
263
+
264
+ await conn.close();
265
+ });
266
+
267
+ it("reconnects after openSocket times out on a silent socket during tryReconnect", async () => {
268
+ const connectTimeoutMs = 40;
269
+ let factoryCalls = 0;
270
+ const { url, server } = await createServer((socket) => {
271
+ socket.on("message", () => undefined);
272
+ });
273
+
274
+ const silentSocket: ManagedSocket = {
275
+ get isOpen() {
276
+ return false;
277
+ },
278
+ send: () => undefined,
279
+ keepAlivePing: () => undefined,
280
+ verify: async () => false,
281
+ dispose: () => undefined,
282
+ onOpen: () => undefined,
283
+ onMessage: () => undefined,
284
+ onClose: () => undefined,
285
+ onError: () => undefined,
286
+ };
287
+
288
+ const socketFactory: SocketFactory = (targetUrl, headers) => {
289
+ factoryCalls += 1;
290
+ if (factoryCalls === 2) return silentSocket;
291
+ return createNodeWsSocket(targetUrl, headers);
292
+ };
293
+
294
+ let reconnected = 0;
295
+ const conn = new WebSocketConnection({
296
+ url: () => url,
297
+ socketFactory,
298
+ connectTimeoutMs,
299
+ retry: { maxAttempts: 3, baseDelayMs: 5, maxDelayMs: 10 },
300
+ minStableMs: 0,
301
+ onMessage: () => undefined,
302
+ onReconnected: () => {
303
+ reconnected += 1;
304
+ },
305
+ });
306
+
307
+ await conn.connect();
308
+ expect(factoryCalls).toBe(1);
309
+
310
+ for (const client of server.clients) client.terminate();
311
+ await waitFor(() => reconnected >= 1, 5000);
312
+ expect(factoryCalls).toBeGreaterThanOrEqual(3);
313
+
314
+ await conn.close();
315
+ });
316
+
317
+ it("gives up after maxReconnectDurationMs when the link keeps flapping", async () => {
318
+ const minStableMs = 30;
319
+ const maxReconnectDurationMs = 200;
320
+ const { url } = await createServer((socket) => {
321
+ setTimeout(() => socket.close(1011, "flap"), minStableMs + 1);
322
+ });
323
+
324
+ let unrecoverable: Error | null = null;
325
+ let reconnects = 0;
326
+ const conn = new WebSocketConnection({
327
+ url: () => url,
328
+ socketFactory: createNodeWsSocket,
329
+ retry: { maxAttempts: 50, baseDelayMs: 5, maxDelayMs: 10 },
330
+ minStableMs,
331
+ maxQuickFailures: 100,
332
+ maxReconnectDurationMs,
333
+ maxReconnectAttempts: 50,
334
+ onMessage: () => undefined,
335
+ onReconnected: () => {
336
+ reconnects += 1;
337
+ },
338
+ onUnrecoverable: (err) => {
339
+ unrecoverable = err;
340
+ },
341
+ });
342
+ await conn.connect();
343
+ await waitFor(() => unrecoverable !== null, 5000);
344
+
345
+ expect((unrecoverable! as Error).message).toContain(String(maxReconnectDurationMs));
346
+ expect(reconnects).toBeGreaterThanOrEqual(1);
347
+
348
+ await conn.close();
349
+ });
350
+ });
@@ -0,0 +1,68 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Regression: disposing a Node `ws` socket that is still CONNECTING must not crash
4
+ // the process. `ws.close()` on a connecting socket emits an asynchronous 'error'
5
+ // event ("WebSocket was closed before the connection was established"); dispose()
6
+ // removeAllListeners()'d the error sink, so without re-attaching one the event
7
+ // becomes an uncaught exception. This happens whenever a caller hangs up during a
8
+ // slow provider connect (surfaced live via the Cartesia TTS plugin teardown).
9
+
10
+ import { afterEach, describe, expect, it, vi } from "vitest";
11
+ import { WebSocketServer, type WebSocket } from "ws";
12
+ import { createNodeWsSocket } from "./node.js";
13
+
14
+ describe("createNodeWsSocket dispose while connecting", () => {
15
+ afterEach(() => {
16
+ vi.restoreAllMocks();
17
+ });
18
+
19
+ it("does not raise an unhandled 'error' when disposed mid-handshake", async () => {
20
+ // A socket to a non-routable address stays in CONNECTING. If dispose() leaked an
21
+ // unhandled 'error', the vitest process would record an uncaughtException and fail
22
+ // this file — reaching the assertion is the proof.
23
+ const uncaught: unknown[] = [];
24
+ const onUncaught = (err: unknown): void => {
25
+ uncaught.push(err);
26
+ };
27
+ process.on("uncaughtException", onUncaught);
28
+ try {
29
+ // createNodeWsSocket is synchronous; await narrows the SocketFactory union.
30
+ const socket = await createNodeWsSocket("ws://10.255.255.1:9999", {});
31
+ socket.dispose(); // synchronous, while readyState === CONNECTING
32
+ await new Promise((resolve) => setTimeout(resolve, 150));
33
+ } finally {
34
+ process.off("uncaughtException", onUncaught);
35
+ }
36
+ expect(uncaught).toEqual([]);
37
+ });
38
+
39
+ it("clears the verify timeout when disposed during verify", async () => {
40
+ vi.useFakeTimers();
41
+ const server = await new Promise<WebSocketServer>((resolve) => {
42
+ let next: WebSocketServer;
43
+ next = new WebSocketServer({ port: 0 }, () => resolve(next));
44
+ });
45
+ server.on("connection", (ws) => {
46
+ ws.on("ping", () => undefined);
47
+ });
48
+ const address = server.address();
49
+ if (!address || typeof address === "string") throw new Error("Expected TCP server address");
50
+
51
+ const socket = await createNodeWsSocket(`ws://127.0.0.1:${String(address.port)}/`, {});
52
+ await new Promise<void>((resolve, reject) => {
53
+ const timer = setTimeout(() => reject(new Error("Timed out waiting for websocket open")), 2000);
54
+ socket.onOpen(() => {
55
+ clearTimeout(timer);
56
+ resolve();
57
+ });
58
+ });
59
+
60
+ const verifyPromise = socket.verify(5000);
61
+ socket.dispose();
62
+ await vi.advanceTimersByTimeAsync(6000);
63
+ await expect(verifyPromise).resolves.toBe(false);
64
+
65
+ await new Promise<void>((resolve) => server.close(() => resolve()));
66
+ vi.useRealTimers();
67
+ });
68
+ });
@@ -0,0 +1,162 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // VE-06.2: reconnect replay. A frame whose send() fails because the socket is not open
4
+ // (provably never reached the wire) is buffered and re-sent in order on reconnect; frames
5
+ // sent on an open socket are never buffered, so received frames are never duplicated.
6
+
7
+ import { describe, expect, it } from "vitest";
8
+ import type { RetryConfig } from "@kuralle-syrinx/core";
9
+
10
+ import { WebSocketConnection, type ManagedSocket, type SocketData } from "./index.js";
11
+ import { wrapWebSocket, type WebSocketEventLike, type WebSocketLike } from "./web-socket.js";
12
+
13
+ const FAST_RETRY: RetryConfig = { maxAttempts: 5, baseDelayMs: 5, maxDelayMs: 20 };
14
+
15
+ class FakeWebSocket implements WebSocketLike {
16
+ readyState = 0;
17
+ binaryType = "blob";
18
+ readonly sent: SocketData[] = [];
19
+ private readonly listeners = new Map<string, Array<(event: WebSocketEventLike) => void>>();
20
+ send(data: string | ArrayBufferView | ArrayBuffer): void {
21
+ this.sent.push(typeof data === "string" ? data : new Uint8Array(data as ArrayBuffer));
22
+ }
23
+ close(): void { this.readyState = 3; this.emit("close", { code: 1006, reason: "drop" }); }
24
+ addEventListener(type: string, l: (e: WebSocketEventLike) => void): void {
25
+ const list = this.listeners.get(type) ?? []; list.push(l); this.listeners.set(type, list);
26
+ }
27
+ private emit(type: string, event: WebSocketEventLike): void { for (const l of this.listeners.get(type) ?? []) l(event); }
28
+ fireOpen(): void { this.readyState = 1; this.emit("open", {}); }
29
+ }
30
+
31
+ const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
32
+
33
+ describe("WebSocketConnection reconnect replay (VE-06.2)", () => {
34
+ it("send() to a closed socket throws when replay is disabled (default)", async () => {
35
+ const fake = new FakeWebSocket();
36
+ const conn = new WebSocketConnection({
37
+ url: () => "wss://x", socketFactory: () => wrapWebSocket(fake), retry: FAST_RETRY,
38
+ onMessage: () => undefined,
39
+ });
40
+ const connected = conn.connect(); fake.fireOpen(); await connected;
41
+ fake.readyState = 3; // simulate closed
42
+ expect(() => conn.send("frame")).toThrow(/not open/);
43
+ await conn.close();
44
+ });
45
+
46
+ it("buffers gap frames and replays them in order on reconnect; on-wire frames are not replayed", async () => {
47
+ const fakes: FakeWebSocket[] = [];
48
+ const replayEvents: Array<{ event: string; count: number }> = [];
49
+ const conn = new WebSocketConnection({
50
+ url: () => "wss://x",
51
+ socketFactory: () => { const f = new FakeWebSocket(); fakes.push(f); return wrapWebSocket(f); },
52
+ retry: FAST_RETRY,
53
+ replayBufferSize: 10,
54
+ onReplay: (event, count) => replayEvents.push({ event, count }),
55
+ onMessage: () => undefined,
56
+ });
57
+
58
+ const connected = conn.connect();
59
+ fakes[0]!.fireOpen();
60
+ await connected;
61
+
62
+ conn.send("on-wire-1"); // sent on the open socket — must NOT be replayed
63
+ expect(fakes[0]!.sent).toEqual(["on-wire-1"]);
64
+
65
+ // Connection drops → reconnect begins.
66
+ fakes[0]!.close();
67
+ // During the gap (socket not open) the caller keeps sending — these buffer for replay.
68
+ conn.send("gap-1");
69
+ conn.send("gap-2");
70
+
71
+ // Wait for the reconnect to create a new socket, then open it.
72
+ await wait(40);
73
+ expect(fakes.length).toBeGreaterThanOrEqual(2);
74
+ fakes[fakes.length - 1]!.fireOpen();
75
+ await wait(10);
76
+
77
+ const reconnected = fakes[fakes.length - 1]!;
78
+ // The two gap frames are replayed in order on the new socket; the on-wire frame is NOT.
79
+ expect(reconnected.sent).toEqual(["gap-1", "gap-2"]);
80
+ expect(replayEvents.filter((e) => e.event === "replayed")).toHaveLength(1);
81
+ expect(replayEvents.find((e) => e.event === "replayed")?.count).toBe(2);
82
+
83
+ await conn.close();
84
+ });
85
+
86
+ it("buffers frames sent during the reconnect verify window until after onReadyBeforeReplay", async () => {
87
+ // The provider-facing contract: on a fresh connection, the config frame
88
+ // (sent by onReadyBeforeReplay) reaches the wire before any caller frame.
89
+ // A send() landing between socket-open and verify completion must buffer,
90
+ // not pass through — otherwise config-first providers see audio first.
91
+ class HeldVerifySocket implements ManagedSocket {
92
+ isOpen = false;
93
+ readonly sent: SocketData[] = [];
94
+ readonly supportsFramePing = true;
95
+ resolveVerify: ((ok: boolean) => void) | null = null;
96
+ private openHandler: (() => void) | null = null;
97
+ private closeHandler: ((code: number, reason: string) => void) | null = null;
98
+ send(data: SocketData): void { this.sent.push(data); }
99
+ keepAlivePing(): void {}
100
+ verify(): Promise<boolean> {
101
+ return new Promise((resolve) => { this.resolveVerify = resolve; });
102
+ }
103
+ dispose(): void {}
104
+ onOpen(h: () => void): void { this.openHandler = h; }
105
+ onMessage(): void {}
106
+ onClose(h: (code: number, reason: string) => void): void { this.closeHandler = h; }
107
+ onError(): void {}
108
+ fireOpen(): void { this.isOpen = true; this.openHandler?.(); }
109
+ fireClose(): void { this.isOpen = false; this.closeHandler?.(1006, "drop"); }
110
+ }
111
+
112
+ const sockets: HeldVerifySocket[] = [];
113
+ const conn = new WebSocketConnection({
114
+ url: () => "wss://x",
115
+ socketFactory: () => {
116
+ const s = new HeldVerifySocket();
117
+ sockets.push(s);
118
+ setTimeout(() => s.fireOpen(), 0);
119
+ return s;
120
+ },
121
+ retry: FAST_RETRY,
122
+ replayBufferSize: 10,
123
+ onReadyBeforeReplay: () => conn.send("config"),
124
+ onMessage: () => undefined,
125
+ });
126
+
127
+ await conn.connect();
128
+ expect(sockets[0]!.sent).toEqual(["config"]);
129
+
130
+ // Drop the link → reconnect opens socket 2, then awaits verify (held open).
131
+ sockets[0]!.fireClose();
132
+ await wait(40);
133
+ expect(sockets.length).toBeGreaterThanOrEqual(2);
134
+ const second = sockets[sockets.length - 1]!;
135
+ expect(second.isOpen).toBe(true);
136
+ expect(second.resolveVerify).not.toBeNull();
137
+
138
+ // Caller sends while the link is open but NOT yet verified/configured.
139
+ conn.send("audio-during-verify");
140
+ expect(second.sent).toEqual([]); // must buffer, not hit the wire
141
+
142
+ second.resolveVerify!(true);
143
+ await wait(10);
144
+ expect(second.sent).toEqual(["config", "audio-during-verify"]);
145
+
146
+ await conn.close();
147
+ });
148
+
149
+ it("bounds the replay buffer, dropping oldest with an overflow signal", async () => {
150
+ const fake = new FakeWebSocket();
151
+ const events: string[] = [];
152
+ const conn = new WebSocketConnection({
153
+ url: () => "wss://x", socketFactory: () => wrapWebSocket(fake), retry: FAST_RETRY,
154
+ replayBufferSize: 2, onReplay: (e) => events.push(e), onMessage: () => undefined,
155
+ });
156
+ const connected = conn.connect(); fake.fireOpen(); await connected;
157
+ fake.readyState = 3; // closed
158
+ conn.send("a"); conn.send("b"); conn.send("c"); // buffer holds last 2 → "a" overflows
159
+ expect(events.filter((e) => e === "overflow")).toHaveLength(1);
160
+ await conn.close();
161
+ });
162
+ });