@kuralle-syrinx/ws 4.1.0 → 4.3.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/package.json CHANGED
@@ -1,9 +1,27 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/ws",
3
- "version": "4.1.0",
3
+ "version": "4.3.0",
4
4
  "private": false,
5
- "type": "module",
5
+ "description": "Reconnecting WebSocket connection manager for Syrinx provider sockets — verify-before-trust reconnects, ordered replay, keepalive; Node and Workers",
6
+ "keywords": [
7
+ "voice",
8
+ "voice-agent",
9
+ "speech",
10
+ "syrinx",
11
+ "websocket",
12
+ "reconnect"
13
+ ],
6
14
  "license": "MIT",
15
+ "homepage": "https://github.com/kuralle/syrinx#readme",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/kuralle/syrinx.git",
19
+ "directory": "packages/ws"
20
+ },
21
+ "bugs": {
22
+ "url": "https://github.com/kuralle/syrinx/issues"
23
+ },
24
+ "type": "module",
7
25
  "main": "./src/index.ts",
8
26
  "types": "./src/index.ts",
9
27
  "exports": {
@@ -14,13 +32,13 @@
14
32
  "./workers": "./src/workers.ts"
15
33
  },
16
34
  "dependencies": {
17
- "ws": "^8.18.0",
18
- "@kuralle-syrinx/core": "4.1.0"
35
+ "ws": "^8.21.0",
36
+ "@kuralle-syrinx/core": "4.3.0"
19
37
  },
20
38
  "devDependencies": {
21
39
  "@types/ws": "^8.5.0",
22
40
  "typescript": "^5.7.0",
23
- "vitest": "^2.1.0"
41
+ "vitest": "^3.2.6"
24
42
  },
25
43
  "scripts": {
26
44
  "typecheck": "tsc --noEmit",
package/src/index.ts CHANGED
@@ -106,6 +106,10 @@ let connectionSequence = 0;
106
106
  export class WebSocketConnection {
107
107
  private socket: ManagedSocket | null = null;
108
108
  private ready = false;
109
+ /** True only once the connection is verified and onReadyBeforeReplay has been
110
+ * given the chance to run — send() must not reach an open-but-unconfigured
111
+ * socket, or caller frames beat the provider config frame on reconnect. */
112
+ private established = false;
109
113
  private closed = false;
110
114
  private reconnecting = false;
111
115
  private connResolver: (() => void) | null = null;
@@ -135,6 +139,7 @@ export class WebSocketConnection {
135
139
  async connect(): Promise<void> {
136
140
  this.closed = false;
137
141
  await this.openSocket();
142
+ this.established = true;
138
143
  this.opts.onReadyBeforeReplay?.();
139
144
  this.flushReplay();
140
145
  }
@@ -149,7 +154,7 @@ export class WebSocketConnection {
149
154
  * throws and the caller decides how to retry/report.
150
155
  */
151
156
  send(payload: SocketData): void {
152
- if (!this.socket || !this.socket.isOpen) {
157
+ if (!this.socket || !this.socket.isOpen || !this.established) {
153
158
  if (this.replayBufferSize > 0 && !this.closed) {
154
159
  this.bufferForReplay(payload);
155
160
  return;
@@ -208,6 +213,7 @@ export class WebSocketConnection {
208
213
  this.connResolver = null;
209
214
  this.connRejecter = null;
210
215
  this.ready = false;
216
+ this.established = false;
211
217
  this.socket?.dispose();
212
218
  this.socket = null;
213
219
  }
@@ -223,6 +229,7 @@ export class WebSocketConnection {
223
229
  this.socket?.dispose();
224
230
  this.socket = null;
225
231
  this.ready = false;
232
+ this.established = false;
226
233
  this.stopKeepAlive();
227
234
  void this.tryReconnect();
228
235
  }
@@ -235,6 +242,7 @@ export class WebSocketConnection {
235
242
  this.abortPendingOpen(new Error("WebSocket connection replaced"));
236
243
  this.socket?.dispose();
237
244
  this.ready = false;
245
+ this.established = false;
238
246
 
239
247
  let socket: ManagedSocket | null = null;
240
248
  let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
@@ -312,6 +320,7 @@ export class WebSocketConnection {
312
320
  settle(() => reject(err));
313
321
  if (!bindSocket(created)) return;
314
322
  this.ready = false;
323
+ this.established = false;
315
324
  this.connRejecter?.(err);
316
325
  this.connResolver = null;
317
326
  this.connRejecter = null;
@@ -322,6 +331,7 @@ export class WebSocketConnection {
322
331
  settle(() => reject(closeErr));
323
332
  if (!bindSocket(created)) return;
324
333
  this.ready = false;
334
+ this.established = false;
325
335
  this.stopKeepAlive();
326
336
  this.connRejecter?.(closeErr);
327
337
  this.connResolver = null;
@@ -416,6 +426,7 @@ export class WebSocketConnection {
416
426
  await this.openSocket();
417
427
  if (this.socket && (await this.verifyConnection(VERIFY_TIMEOUT_MS))) {
418
428
  this.scheduleReconnectBurstReset();
429
+ this.established = true;
419
430
  this.opts.onReadyBeforeReplay?.();
420
431
  this.flushReplay();
421
432
  this.opts.onReconnected?.();
package/src/index.test.ts DELETED
@@ -1,350 +0,0 @@
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
- });
package/src/node.test.ts DELETED
@@ -1,68 +0,0 @@
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
- });
@@ -1,99 +0,0 @@
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 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("bounds the replay buffer, dropping oldest with an overflow signal", async () => {
87
- const fake = new FakeWebSocket();
88
- const events: string[] = [];
89
- const conn = new WebSocketConnection({
90
- url: () => "wss://x", socketFactory: () => wrapWebSocket(fake), retry: FAST_RETRY,
91
- replayBufferSize: 2, onReplay: (e) => events.push(e), onMessage: () => undefined,
92
- });
93
- const connected = conn.connect(); fake.fireOpen(); await connected;
94
- fake.readyState = 3; // closed
95
- conn.send("a"); conn.send("b"); conn.send("c"); // buffer holds last 2 → "a" overflows
96
- expect(events.filter((e) => e === "overflow")).toHaveLength(1);
97
- await conn.close();
98
- });
99
- });
@@ -1,132 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
- //
3
- // Proves the connection manager runs over the standard built-in WebSocket
4
- // (Cloudflare Workers / browser / Bun) with no `ws` and no ping frames — keepalive uses an
5
- // app message and verify falls back to readyState.
6
-
7
- import { describe, expect, it } from "vitest";
8
- import type { RetryConfig } from "@kuralle-syrinx/core";
9
-
10
- import { WebSocketConnection, type SocketData } from "./index.js";
11
- import { wrapWebSocket, type WebSocketEventLike, type WebSocketLike } from "./web-socket.js";
12
-
13
- const FAST_RETRY: RetryConfig = { maxAttempts: 3, baseDelayMs: 10, maxDelayMs: 40 };
14
-
15
- /** A controllable standard-WebSocket-shaped socket — no ping, addEventListener-based. */
16
- class FakeWebSocket implements WebSocketLike {
17
- readyState = 0;
18
- binaryType = "blob";
19
- readonly sent: SocketData[] = [];
20
- private readonly listeners = new Map<string, Array<(event: WebSocketEventLike) => void>>();
21
-
22
- send(data: string | ArrayBufferView | ArrayBuffer): void {
23
- this.sent.push(typeof data === "string" ? data : new Uint8Array(data as ArrayBuffer));
24
- }
25
- close(): void {
26
- this.readyState = 3;
27
- this.emit("close", { code: 1000, reason: "" });
28
- }
29
- addEventListener(type: string, listener: (event: WebSocketEventLike) => void): void {
30
- const list = this.listeners.get(type) ?? [];
31
- list.push(listener);
32
- this.listeners.set(type, list);
33
- }
34
- private emit(type: string, event: WebSocketEventLike): void {
35
- for (const l of this.listeners.get(type) ?? []) l(event);
36
- }
37
- fireOpen(): void {
38
- this.readyState = 1;
39
- this.emit("open", {});
40
- }
41
- fireMessage(data: string | ArrayBuffer): void {
42
- this.emit("message", { data });
43
- }
44
- }
45
-
46
- describe("WebSocketConnection over the standard WebSocket (Workers/browser)", () => {
47
- it("connects, delivers text + binary, and keepalives with an app message (no ping)", async () => {
48
- const fake = new FakeWebSocket();
49
- const received: Array<{ data: SocketData; isBinary: boolean }> = [];
50
- const conn = new WebSocketConnection({
51
- url: () => "wss://example/v1",
52
- socketFactory: () => wrapWebSocket(fake),
53
- retry: FAST_RETRY,
54
- keepAliveIntervalMs: 20,
55
- keepAliveMessage: () => JSON.stringify({ type: "KeepAlive" }),
56
- onMessage: (data, isBinary) => received.push({ data, isBinary }),
57
- });
58
-
59
- const connected = conn.connect();
60
- fake.fireOpen();
61
- await connected;
62
- expect(conn.isReady).toBe(true);
63
-
64
- fake.fireMessage("hello-text");
65
- fake.fireMessage(new Uint8Array([1, 2, 3]).buffer);
66
-
67
- await new Promise((resolve) => setTimeout(resolve, 60));
68
-
69
- expect(received[0]).toEqual({ data: "hello-text", isBinary: false });
70
- const binary = received.find((r) => r.isBinary);
71
- expect(binary?.data).toEqual(new Uint8Array([1, 2, 3]));
72
- // KeepAlive went out as an app message, since the built-in WebSocket has no ping frame.
73
- expect(fake.sent.filter((m) => typeof m === "string" && m.includes("KeepAlive")).length).toBeGreaterThanOrEqual(1);
74
-
75
- await conn.close();
76
- expect(fake.readyState).toBe(3);
77
- });
78
-
79
- it("verify fails when livenessProbe gets no reply and reconnect continues", async () => {
80
- let activeFake: FakeWebSocket | null = null;
81
- let probeCalls = 0;
82
- let reconnecting = 0;
83
-
84
- const conn = new WebSocketConnection({
85
- url: () => "wss://example/v1",
86
- socketFactory: () => {
87
- activeFake = new FakeWebSocket();
88
- queueMicrotask(() => activeFake!.fireOpen());
89
- return wrapWebSocket(activeFake);
90
- },
91
- retry: FAST_RETRY,
92
- minStableMs: 0,
93
- connectTimeoutMs: 500,
94
- livenessProbe: async () => {
95
- probeCalls += 1;
96
- await new Promise((resolve) => setTimeout(resolve, 20));
97
- return false;
98
- },
99
- onMessage: () => undefined,
100
- onReconnecting: () => {
101
- reconnecting += 1;
102
- },
103
- });
104
-
105
- await conn.connect();
106
- expect(conn.isReady).toBe(true);
107
-
108
- activeFake!.close();
109
- await new Promise((resolve) => setTimeout(resolve, 500));
110
-
111
- expect(probeCalls).toBeGreaterThanOrEqual(1);
112
- expect(reconnecting).toBeGreaterThanOrEqual(1);
113
-
114
- await conn.close();
115
- });
116
- });
117
-
118
- describe("wrapWebSocket skip-open (workerd fetch-upgrade)", () => {
119
- it("invokes onOpen via microtask when the socket is already open", async () => {
120
- const fake = new FakeWebSocket();
121
- fake.readyState = 1;
122
-
123
- let opened = false;
124
- wrapWebSocket(fake).onOpen(() => {
125
- opened = true;
126
- });
127
-
128
- expect(opened).toBe(false);
129
- await new Promise<void>((resolve) => queueMicrotask(resolve));
130
- expect(opened).toBe(true);
131
- });
132
- });
@@ -1,84 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
- //
3
- // Proves the Workers fetch-upgrade adapter drives the full manager: the async
4
- // socket factory is awaited, the auth headers + Upgrade go out on the fetch, and
5
- // the accepted (already-open) socket connects without an "open" event.
6
-
7
- import { afterEach, describe, expect, it, vi } from "vitest";
8
- import type { RetryConfig } from "@kuralle-syrinx/core";
9
-
10
- import { WebSocketConnection } from "./index.js";
11
- import { createWorkersSocket } from "./workers.js";
12
- import type { WebSocketEventLike } from "./web-socket.js";
13
-
14
- const FAST_RETRY: RetryConfig = { maxAttempts: 3, baseDelayMs: 10, maxDelayMs: 40 };
15
-
16
- const realFetch = globalThis.fetch;
17
- afterEach(() => {
18
- globalThis.fetch = realFetch;
19
- });
20
-
21
- /** A Workers-style already-open socket returned by a fetch upgrade. */
22
- class FakeWorkersSocket {
23
- readyState = 1; // already OPEN, as after a fetch upgrade
24
- binaryType = "blob";
25
- accepted = false;
26
- readonly sent: Array<string | ArrayBufferView | ArrayBuffer> = [];
27
- private readonly listeners = new Map<string, Array<(event: WebSocketEventLike) => void>>();
28
- accept(): void {
29
- this.accepted = true;
30
- }
31
- send(data: string | ArrayBufferView | ArrayBuffer): void {
32
- this.sent.push(data);
33
- }
34
- close(): void {
35
- this.readyState = 3;
36
- }
37
- addEventListener(type: string, listener: (event: WebSocketEventLike) => void): void {
38
- const list = this.listeners.get(type) ?? [];
39
- list.push(listener);
40
- this.listeners.set(type, list);
41
- }
42
- fire(type: string, event: WebSocketEventLike): void {
43
- for (const l of this.listeners.get(type) ?? []) l(event);
44
- }
45
- }
46
-
47
- describe("createWorkersSocket (Cloudflare fetch-upgrade)", () => {
48
- it("upgrades with auth headers and connects an already-open socket", async () => {
49
- const fake = new FakeWorkersSocket();
50
- const fetchMock = vi.fn(async (_url: string, _init: { headers: Record<string, string> }) => ({
51
- status: 101,
52
- webSocket: fake,
53
- }));
54
- globalThis.fetch = fetchMock as unknown as typeof fetch;
55
-
56
- const received: string[] = [];
57
- const conn = new WebSocketConnection({
58
- url: () => "wss://api.deepgram.com/v1/listen",
59
- headers: { Authorization: "Token secret-key" },
60
- socketFactory: createWorkersSocket,
61
- retry: FAST_RETRY,
62
- onMessage: (data) => {
63
- if (typeof data === "string") received.push(data);
64
- },
65
- });
66
-
67
- await conn.connect();
68
- expect(conn.isReady).toBe(true);
69
- expect(fake.accepted).toBe(true);
70
-
71
- // The fetch carried the Upgrade + the provider auth header.
72
- const init = fetchMock.mock.calls[0]![1];
73
- expect(init.headers.Upgrade).toBe("websocket");
74
- expect(init.headers.Authorization).toBe("Token secret-key");
75
-
76
- fake.fire("message", { data: "{\"type\":\"Results\"}" });
77
- expect(received).toEqual(["{\"type\":\"Results\"}"]);
78
-
79
- conn.send("audio-frame");
80
- expect(fake.sent).toContain("audio-frame");
81
-
82
- await conn.close();
83
- });
84
- });
package/tsconfig.json DELETED
@@ -1,21 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2022",
4
- "module": "NodeNext",
5
- "moduleResolution": "NodeNext",
6
- "lib": ["ES2022"],
7
- "strict": true,
8
- "noUncheckedIndexedAccess": true,
9
- "noImplicitReturns": true,
10
- "declaration": true,
11
- "declarationMap": true,
12
- "sourceMap": true,
13
- "outDir": "./dist",
14
- "rootDir": "./src",
15
- "esModuleInterop": true,
16
- "skipLibCheck": true,
17
- "forceConsistentCasingInFileNames": true
18
- },
19
- "include": ["src/**/*.ts"],
20
- "exclude": ["node_modules", "dist"]
21
- }