@kuralle-syrinx/ws 2.1.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/src/index.ts ADDED
@@ -0,0 +1,505 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Shared persistent-WebSocket connection manager for provider plugins.
4
+ //
5
+ // One long-lived socket per session that auto-reconnects with exponential
6
+ // backoff, verifies the link before trusting a reconnect, guards against
7
+ // concurrent reconnects, gives up fast when the socket keeps dying immediately
8
+ // after connecting (bad key / policy rejection — backoff can't fix that), and
9
+ // holds the connection open through idle with a KeepAlive.
10
+ //
11
+ // Runtime-agnostic: it drives a ManagedSocket adapter, not a concrete library.
12
+ // Inject createNodeWsSocket (Node/Bun, via `ws`) or createWebSocketAdapter
13
+ // (Cloudflare Workers / browser, via the built-in WebSocket). The reconnection
14
+ // logic is identical everywhere; only the socket primitive differs.
15
+ //
16
+ // Reconnection model ported from Pipecat's WebsocketService
17
+ // (services/websocket_service.py): _try_reconnect / _verify_connection /
18
+ // quick-failure detection / disconnecting guard. Backoff reuses our equal-jitter
19
+ // waitForRetryDelay.
20
+
21
+ import { TimerScheduler, type RetryConfig, type Scheduler, waitForRetryDelay } from "@kuralle-syrinx/core";
22
+
23
+ /** A WebSocket text or binary frame, normalized across runtimes. */
24
+ export type SocketData = string | Uint8Array;
25
+
26
+ /**
27
+ * The minimal socket the connection manager drives. Implemented over Node `ws`
28
+ * (createNodeWsSocket) or the built-in WebSocket (createWebSocketAdapter). Keeping the
29
+ * manager behind this seam is what makes it portable to Cloudflare Workers,
30
+ * where `ws` does not run and there are no ping frames.
31
+ */
32
+ export interface ManagedSocket {
33
+ readonly isOpen: boolean;
34
+ send(data: SocketData): void;
35
+ /** Fire-and-forget liveness ping (Node WS ping frame). No-op where unsupported. */
36
+ keepAlivePing(): void;
37
+ /** When true, verify() sends a WS ping frame and awaits a pong (Node/Bun only). */
38
+ readonly supportsFramePing?: boolean;
39
+ /** Probe liveness: Node pings and awaits a pong; the built-in WebSocket just reports readyState. */
40
+ verify(timeoutMs: number): Promise<boolean>;
41
+ /** Remove listeners and close — used when replacing or tearing down. */
42
+ dispose(): void;
43
+ onOpen(handler: () => void): void;
44
+ onMessage(handler: (data: SocketData, isBinary: boolean) => void): void;
45
+ onClose(handler: (code: number, reason: string) => void): void;
46
+ onError(handler: (err: Error) => void): void;
47
+ }
48
+
49
+ // May be async: a Cloudflare Workers socket is opened via a `fetch` upgrade.
50
+ export type SocketFactory = (url: string, headers: Record<string, string>) => ManagedSocket | Promise<ManagedSocket>;
51
+
52
+ export interface WebSocketConnectionOptions {
53
+ /** Build the connection URL fresh on every (re)connect. */
54
+ readonly url: () => string;
55
+ readonly headers?: Record<string, string>;
56
+ /** Creates the underlying socket for the host runtime (Node, Bun, Workers, browser). */
57
+ readonly socketFactory: SocketFactory;
58
+ /** Backoff schedule for reconnect attempts (reused from the plugin's retry config). */
59
+ readonly retry: RetryConfig;
60
+ /** Max reconnect attempts per disconnect burst before giving up. Defaults to retry.maxAttempts. */
61
+ readonly maxReconnectAttempts?: number;
62
+ /** Periodic KeepAlive to stop idle providers from closing the socket. 0 disables. */
63
+ readonly keepAliveIntervalMs?: number;
64
+ /** App-level KeepAlive payload (e.g. Deepgram `{"type":"KeepAlive"}`). When omitted a WS ping is used
65
+ * (which is a no-op on built-in WebSockets — provide a message for KeepAlive on Workers/browser). */
66
+ readonly keepAliveMessage?: () => SocketData;
67
+ /** A reconnect that re-opens then dies within this window counts as a quick failure. */
68
+ readonly minStableMs?: number;
69
+ /** Consecutive quick failures before giving up (backoff can't fix an instantly-closing socket). */
70
+ readonly maxQuickFailures?: number;
71
+ readonly connectTimeoutMs?: number;
72
+ /** App-level round-trip liveness check used on runtimes without WS ping frames (web/workers). */
73
+ readonly livenessProbe?: (socket: ManagedSocket) => Promise<boolean>;
74
+ /** Max wall-clock time for a reconnect burst before giving up. */
75
+ readonly maxReconnectDurationMs?: number;
76
+ readonly scheduler?: Scheduler;
77
+ readonly onMessage: (data: SocketData, isBinary: boolean) => void;
78
+ /** Called once when a live connection drops unexpectedly, with the close cause — for
79
+ * failing in-flight work and dropping stale provider state before reconnecting. */
80
+ readonly onConnectionLost?: (err: Error) => void;
81
+ /** Called before each reconnect attempt so the consumer can drop stale provider state. */
82
+ readonly onReconnecting?: () => void;
83
+ /** Called after the socket is open/verified and before replay frames flush. */
84
+ readonly onReadyBeforeReplay?: () => void;
85
+ readonly onReconnected?: () => void;
86
+ /** Called when reconnection is abandoned (quick-failure loop or attempts exhausted). */
87
+ readonly onUnrecoverable?: (err: Error) => void;
88
+ /**
89
+ * Max frames to buffer for replay-on-reconnect. 0 (default) disables replay — `send()` to a
90
+ * closed socket throws as before. When > 0, a `send()` that fails because the socket is not open
91
+ * (so the frame PROVABLY never reached the wire) is buffered and re-sent in order on the next
92
+ * reconnect. Frames that were sent on an open socket are never buffered, so a frame the provider
93
+ * may already have received is never replayed — no duplicate speech.
94
+ */
95
+ readonly replayBufferSize?: number;
96
+ /** Observe replay activity: "deferred" (buffered), "replayed" (flushed on reconnect), "overflow" (dropped). */
97
+ readonly onReplay?: (event: "deferred" | "replayed" | "overflow", count: number) => void;
98
+ }
99
+
100
+ const DEFAULT_MIN_STABLE_MS = 5000;
101
+ const DEFAULT_MAX_QUICK_FAILURES = 3;
102
+ const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
103
+ const VERIFY_TIMEOUT_MS = 2000;
104
+ let connectionSequence = 0;
105
+
106
+ export class WebSocketConnection {
107
+ private socket: ManagedSocket | null = null;
108
+ private ready = false;
109
+ private closed = false;
110
+ private reconnecting = false;
111
+ private connResolver: (() => void) | null = null;
112
+ private connRejecter: ((err: Error) => void) | null = null;
113
+ private abortOpen: (() => void) | null = null;
114
+ private readonly scheduler: Scheduler;
115
+ private readonly keepAliveKey: string;
116
+ private keepAliveScheduled = false;
117
+ private lastConnectAtMs = 0;
118
+ private quickFailures = 0;
119
+ private reconnectBurstStartedAtMs: number | null = null;
120
+ private reconnectBurstResetKey: string;
121
+ private pendingReplay: SocketData[] = [];
122
+
123
+ constructor(private readonly opts: WebSocketConnectionOptions) {
124
+ this.scheduler = opts.scheduler ?? new TimerScheduler();
125
+ connectionSequence += 1;
126
+ this.keepAliveKey = `voice-ws.keepalive:${String(connectionSequence)}`;
127
+ this.reconnectBurstResetKey = `voice-ws.burst-reset:${String(connectionSequence)}`;
128
+ }
129
+
130
+ private get replayBufferSize(): number {
131
+ return Math.max(0, Math.floor(this.opts.replayBufferSize ?? 0));
132
+ }
133
+
134
+ /** Open the initial connection. Rejects if it cannot be established (so init fails loudly). */
135
+ async connect(): Promise<void> {
136
+ this.closed = false;
137
+ await this.openSocket();
138
+ this.opts.onReadyBeforeReplay?.();
139
+ this.flushReplay();
140
+ }
141
+
142
+ get isReady(): boolean {
143
+ return this.ready;
144
+ }
145
+
146
+ /**
147
+ * Send a frame. If the socket is not open: when replay is enabled (`replayBufferSize > 0`) the
148
+ * frame is buffered for replay on reconnect (it provably never reached the wire); otherwise this
149
+ * throws and the caller decides how to retry/report.
150
+ */
151
+ send(payload: SocketData): void {
152
+ if (!this.socket || !this.socket.isOpen) {
153
+ if (this.replayBufferSize > 0 && !this.closed) {
154
+ this.bufferForReplay(payload);
155
+ return;
156
+ }
157
+ throw new Error("WebSocket is not open");
158
+ }
159
+ this.socket.send(payload);
160
+ }
161
+
162
+ private bufferForReplay(payload: SocketData): void {
163
+ this.pendingReplay.push(payload);
164
+ this.opts.onReplay?.("deferred", 1);
165
+ while (this.pendingReplay.length > this.replayBufferSize) {
166
+ this.pendingReplay.shift();
167
+ this.opts.onReplay?.("overflow", 1);
168
+ }
169
+ }
170
+
171
+ /** Re-send frames buffered during a disconnect gap, in order, on the reconnected socket. */
172
+ private flushReplay(): void {
173
+ if (this.pendingReplay.length === 0) return;
174
+ const frames = this.pendingReplay;
175
+ this.pendingReplay = [];
176
+ let replayed = 0;
177
+ for (const frame of frames) {
178
+ if (this.socket?.isOpen) {
179
+ this.socket.send(frame);
180
+ replayed += 1;
181
+ }
182
+ }
183
+ if (replayed > 0) this.opts.onReplay?.("replayed", replayed);
184
+ }
185
+
186
+ async ensureReady(): Promise<void> {
187
+ if (this.ready) return;
188
+ await new Promise<void>((resolve, reject) => {
189
+ const timeout = setTimeout(() => reject(new Error("WebSocket connect timeout")), this.connectTimeoutMs);
190
+ this.connResolver = () => {
191
+ clearTimeout(timeout);
192
+ resolve();
193
+ };
194
+ this.connRejecter = (err) => {
195
+ clearTimeout(timeout);
196
+ reject(err);
197
+ };
198
+ });
199
+ }
200
+
201
+ async close(): Promise<void> {
202
+ this.closed = true;
203
+ this.pendingReplay = [];
204
+ this.cancelReconnectBurstReset();
205
+ this.reconnectBurstStartedAtMs = null;
206
+ this.stopKeepAlive();
207
+ this.abortPendingOpen(new Error("WebSocket connection closed"));
208
+ this.connResolver = null;
209
+ this.connRejecter = null;
210
+ this.ready = false;
211
+ this.socket?.dispose();
212
+ this.socket = null;
213
+ }
214
+
215
+ /**
216
+ * Force a reconnect now — for when the provider stream is wedged but the socket
217
+ * still looks open (e.g. an unanswered Finalize). Safe no-op if closed or a
218
+ * reconnect is already running.
219
+ */
220
+ reset(): void {
221
+ if (this.closed || this.reconnecting) return;
222
+ this.abortPendingOpen(new Error("WebSocket connection reset"));
223
+ this.socket?.dispose();
224
+ this.socket = null;
225
+ this.ready = false;
226
+ this.stopKeepAlive();
227
+ void this.tryReconnect();
228
+ }
229
+
230
+ private get connectTimeoutMs(): number {
231
+ return this.opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
232
+ }
233
+
234
+ private async openSocket(): Promise<void> {
235
+ this.abortPendingOpen(new Error("WebSocket connection replaced"));
236
+ this.socket?.dispose();
237
+ this.ready = false;
238
+
239
+ let socket: ManagedSocket | null = null;
240
+ let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
241
+ let settled = false;
242
+
243
+ const clearDeadline = (): void => {
244
+ if (deadlineTimer !== undefined) {
245
+ clearTimeout(deadlineTimer);
246
+ deadlineTimer = undefined;
247
+ }
248
+ };
249
+
250
+ const disposeAttempt = (): void => {
251
+ socket?.dispose();
252
+ if (socket === this.socket) this.socket = null;
253
+ };
254
+
255
+ try {
256
+ await new Promise<void>((resolve, reject) => {
257
+ deadlineTimer = setTimeout(() => {
258
+ if (settled) return;
259
+ settled = true;
260
+ clearDeadline();
261
+ this.abortOpen = null;
262
+ disposeAttempt();
263
+ reject(new Error("WebSocket connect timeout"));
264
+ }, this.connectTimeoutMs);
265
+
266
+ const settle = (fn: () => void) => {
267
+ if (settled) return;
268
+ settled = true;
269
+ clearDeadline();
270
+ this.abortOpen = null;
271
+ fn();
272
+ };
273
+
274
+ // settle() resolves THIS attempt's promise and is always safe to call (it
275
+ // is idempotent and owned by this socket). The shared connection state,
276
+ // however, must only be touched by the *current* socket: a socket replaced
277
+ // by a reconnect can emit a late close/message, and acting on it would
278
+ // clobber the healthy connection or trigger a spurious reconnect.
279
+ const bindSocket = (active: ManagedSocket): boolean => active === this.socket;
280
+
281
+ this.abortOpen = () => {
282
+ settle(() => reject(new Error("WebSocket connection disposed")));
283
+ };
284
+
285
+ void (async () => {
286
+ try {
287
+ const created = await this.opts.socketFactory(this.opts.url(), this.opts.headers ?? {});
288
+ if (settled) {
289
+ created.dispose();
290
+ return;
291
+ }
292
+ socket = created;
293
+ this.socket = created;
294
+
295
+ created.onOpen(() => {
296
+ settle(resolve);
297
+ if (!bindSocket(created)) return;
298
+ this.ready = true;
299
+ this.lastConnectAtMs = Date.now();
300
+ this.startKeepAlive();
301
+ this.connResolver?.();
302
+ this.connResolver = null;
303
+ this.connRejecter = null;
304
+ });
305
+
306
+ created.onMessage((data, isBinary) => {
307
+ if (!bindSocket(created)) return;
308
+ this.opts.onMessage(data, isBinary);
309
+ });
310
+
311
+ created.onError((err) => {
312
+ settle(() => reject(err));
313
+ if (!bindSocket(created)) return;
314
+ this.ready = false;
315
+ this.connRejecter?.(err);
316
+ this.connResolver = null;
317
+ this.connRejecter = null;
318
+ });
319
+
320
+ created.onClose((code, reason) => {
321
+ const closeErr = closeError(code, reason);
322
+ settle(() => reject(closeErr));
323
+ if (!bindSocket(created)) return;
324
+ this.ready = false;
325
+ this.stopKeepAlive();
326
+ this.connRejecter?.(closeErr);
327
+ this.connResolver = null;
328
+ this.connRejecter = null;
329
+ if (!this.closed && !this.reconnecting) {
330
+ this.opts.onConnectionLost?.(closeErr);
331
+ void this.tryReconnect();
332
+ }
333
+ });
334
+ } catch (err) {
335
+ settle(() => reject(err instanceof Error ? err : new Error(String(err))));
336
+ }
337
+ })();
338
+ });
339
+ } catch (err) {
340
+ if (!settled) {
341
+ settled = true;
342
+ clearDeadline();
343
+ this.abortOpen = null;
344
+ disposeAttempt();
345
+ }
346
+ throw err;
347
+ }
348
+ }
349
+
350
+ private async verifyConnection(timeoutMs: number): Promise<boolean> {
351
+ const socket = this.socket;
352
+ if (!socket) return false;
353
+ if (socket.supportsFramePing) return socket.verify(timeoutMs);
354
+ if (this.opts.livenessProbe) {
355
+ return await Promise.race([
356
+ this.opts.livenessProbe(socket),
357
+ new Promise<boolean>((resolve) => setTimeout(() => resolve(false), timeoutMs)),
358
+ ]);
359
+ }
360
+ return socket.verify(timeoutMs);
361
+ }
362
+
363
+ private abortPendingOpen(err: Error): void {
364
+ if (!this.abortOpen) return;
365
+ const abort = this.abortOpen;
366
+ this.abortOpen = null;
367
+ abort();
368
+ this.connRejecter?.(err);
369
+ this.connResolver = null;
370
+ this.connRejecter = null;
371
+ }
372
+
373
+ private async tryReconnect(): Promise<void> {
374
+ if (this.reconnecting || this.closed) return;
375
+ this.reconnecting = true;
376
+ try {
377
+ const maxDurationMs = this.opts.maxReconnectDurationMs;
378
+ if (maxDurationMs !== undefined) {
379
+ if (this.reconnectBurstStartedAtMs === null) {
380
+ this.reconnectBurstStartedAtMs = Date.now();
381
+ } else if (Date.now() - this.reconnectBurstStartedAtMs > maxDurationMs) {
382
+ this.giveUp(new Error(`WebSocket reconnect exceeded ${String(maxDurationMs)}ms`));
383
+ return;
384
+ }
385
+ }
386
+
387
+ // Quick-failure guard: a socket that re-opens then dies within minStableMs,
388
+ // repeatedly, will never be fixed by backoff (the handshake keeps
389
+ // succeeding — usually a bad key or policy rejection). Stop and surface it.
390
+ if (this.lastConnectAtMs > 0 && Date.now() - this.lastConnectAtMs < this.minStableMs) {
391
+ this.quickFailures += 1;
392
+ if (this.quickFailures >= this.maxQuickFailures) {
393
+ this.giveUp(
394
+ new Error(
395
+ `WebSocket closed within ${String(this.minStableMs)}ms of connecting ` +
396
+ `${String(this.quickFailures)} times — check credentials or provider policy`,
397
+ ),
398
+ );
399
+ return;
400
+ }
401
+ } else {
402
+ this.quickFailures = 0;
403
+ }
404
+
405
+ const maxAttempts = this.opts.maxReconnectAttempts ?? this.opts.retry.maxAttempts;
406
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
407
+ if (this.closed) return;
408
+ if (maxDurationMs !== undefined && this.reconnectBurstStartedAtMs !== null) {
409
+ if (Date.now() - this.reconnectBurstStartedAtMs > maxDurationMs) {
410
+ this.giveUp(new Error(`WebSocket reconnect exceeded ${String(maxDurationMs)}ms`));
411
+ return;
412
+ }
413
+ }
414
+ this.opts.onReconnecting?.();
415
+ try {
416
+ await this.openSocket();
417
+ if (this.socket && (await this.verifyConnection(VERIFY_TIMEOUT_MS))) {
418
+ this.scheduleReconnectBurstReset();
419
+ this.opts.onReadyBeforeReplay?.();
420
+ this.flushReplay();
421
+ this.opts.onReconnected?.();
422
+ return;
423
+ }
424
+ } catch {
425
+ // openSocket rejected — fall through to backoff and retry
426
+ }
427
+ if (this.closed) return;
428
+ await waitForRetryDelay(attempt, this.opts.retry);
429
+ }
430
+ this.giveUp(new Error(`failed to reconnect after ${String(maxAttempts)} attempts`));
431
+ } finally {
432
+ this.reconnecting = false;
433
+ }
434
+ }
435
+
436
+ private giveUp(err: Error): void {
437
+ this.closed = true;
438
+ this.cancelReconnectBurstReset();
439
+ this.stopKeepAlive();
440
+ this.socket?.dispose();
441
+ this.socket = null;
442
+ this.opts.onUnrecoverable?.(err);
443
+ }
444
+
445
+ private startKeepAlive(): void {
446
+ this.stopKeepAlive();
447
+ const intervalMs = this.opts.keepAliveIntervalMs ?? 0;
448
+ if (intervalMs <= 0) return;
449
+ const tick = (): void => {
450
+ this.keepAliveScheduled = false;
451
+ const socket = this.socket;
452
+ if (this.closed || !socket || !socket.isOpen) return;
453
+ if (this.opts.keepAliveMessage) {
454
+ socket.send(this.opts.keepAliveMessage());
455
+ } else {
456
+ socket.keepAlivePing();
457
+ }
458
+ if (!this.closed && socket.isOpen) {
459
+ this.keepAliveScheduled = true;
460
+ this.scheduler.schedule(this.keepAliveKey, intervalMs, tick);
461
+ }
462
+ };
463
+ this.keepAliveScheduled = true;
464
+ this.scheduler.schedule(this.keepAliveKey, intervalMs, tick);
465
+ }
466
+
467
+ private stopKeepAlive(): void {
468
+ if (!this.keepAliveScheduled) return;
469
+ this.scheduler.cancel(this.keepAliveKey);
470
+ this.keepAliveScheduled = false;
471
+ }
472
+
473
+ private get minStableMs(): number {
474
+ return this.opts.minStableMs ?? DEFAULT_MIN_STABLE_MS;
475
+ }
476
+
477
+ private get maxQuickFailures(): number {
478
+ return this.opts.maxQuickFailures ?? DEFAULT_MAX_QUICK_FAILURES;
479
+ }
480
+
481
+ private cancelReconnectBurstReset(): void {
482
+ this.scheduler.cancel(this.reconnectBurstResetKey);
483
+ }
484
+
485
+ private scheduleReconnectBurstReset(): void {
486
+ if (this.reconnectBurstStartedAtMs === null) return;
487
+ const burstStarted = this.reconnectBurstStartedAtMs;
488
+ const stableMs = this.minStableMs * 2;
489
+ this.cancelReconnectBurstReset();
490
+ this.scheduler.schedule(this.reconnectBurstResetKey, stableMs, () => {
491
+ if (this.reconnectBurstStartedAtMs === burstStarted && this.ready && this.socket?.isOpen) {
492
+ this.reconnectBurstStartedAtMs = null;
493
+ }
494
+ });
495
+ }
496
+ }
497
+
498
+ function closeError(code: number, reason: string): Error {
499
+ const reasonText = reason.trim();
500
+ return new Error(
501
+ reasonText
502
+ ? `WebSocket closed unexpectedly: code=${code} reason=${reasonText}`
503
+ : `WebSocket closed unexpectedly: code=${code}`,
504
+ );
505
+ }
@@ -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
+ });
package/src/node.ts ADDED
@@ -0,0 +1,96 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Node/Bun socket adapter for WebSocketConnection, backed by the `ws` library.
4
+ // Kept in its own entry point so a Cloudflare Workers build (which uses
5
+ // createWebSocketAdapter) never bundles `ws`.
6
+
7
+ import WebSocket, { type RawData } from "ws";
8
+ import type { ManagedSocket, SocketData, SocketFactory } from "./index.js";
9
+
10
+ export const createNodeWsSocket: SocketFactory = (url, headers): ManagedSocket => {
11
+ const ws = new WebSocket(url, Object.keys(headers).length > 0 ? { headers } : undefined);
12
+ let verifyTimer: ReturnType<typeof setTimeout> | null = null;
13
+ let verifyOnPong: (() => void) | null = null;
14
+ let verifyResolve: ((value: boolean) => void) | null = null;
15
+
16
+ const clearVerify = (result?: boolean): void => {
17
+ if (verifyTimer) clearTimeout(verifyTimer);
18
+ if (verifyOnPong) ws.off("pong", verifyOnPong);
19
+ verifyTimer = null;
20
+ verifyOnPong = null;
21
+ if (verifyResolve) {
22
+ verifyResolve(result ?? false);
23
+ verifyResolve = null;
24
+ }
25
+ };
26
+
27
+ return {
28
+ supportsFramePing: true,
29
+ get isOpen(): boolean {
30
+ return ws.readyState === ws.OPEN;
31
+ },
32
+ send: (data: SocketData) => ws.send(data),
33
+ keepAlivePing: () => {
34
+ try {
35
+ ws.ping();
36
+ } catch {
37
+ // socket not open — keepalive is best-effort
38
+ }
39
+ },
40
+ verify: (timeoutMs: number) =>
41
+ new Promise<boolean>((resolve) => {
42
+ clearVerify(false);
43
+ if (ws.readyState !== ws.OPEN) {
44
+ resolve(false);
45
+ return;
46
+ }
47
+ verifyResolve = resolve;
48
+ const onPong = (): void => {
49
+ clearVerify(true);
50
+ };
51
+ verifyOnPong = onPong;
52
+ verifyTimer = setTimeout(() => {
53
+ clearVerify(false);
54
+ }, timeoutMs);
55
+ ws.once("pong", onPong);
56
+ try {
57
+ ws.ping();
58
+ } catch {
59
+ clearVerify(false);
60
+ }
61
+ }),
62
+ dispose: () => {
63
+ clearVerify(false);
64
+ ws.removeAllListeners();
65
+ // Closing a socket that is still CONNECTING makes `ws` emit an asynchronous
66
+ // 'error' event ("WebSocket was closed before the connection was established").
67
+ // removeAllListeners() just stripped the error handler, so without re-attaching
68
+ // a sink that event becomes an uncaught exception and crashes the process — the
69
+ // exact thing that happens when a caller hangs up during a slow provider connect.
70
+ // Swallow stray close-time errors, and abort a pending handshake with terminate().
71
+ ws.on("error", () => {});
72
+ try {
73
+ if (ws.readyState === ws.CONNECTING) {
74
+ ws.terminate();
75
+ } else {
76
+ ws.close();
77
+ }
78
+ } catch {
79
+ // best effort
80
+ }
81
+ },
82
+ onOpen: (handler) => ws.on("open", handler),
83
+ onMessage: (handler) =>
84
+ ws.on("message", (data: RawData, isBinary: boolean) =>
85
+ handler(isBinary ? toUint8(data) : data.toString(), isBinary),
86
+ ),
87
+ onClose: (handler) => ws.on("close", (code: number, reason: Buffer) => handler(code, reason.toString("utf8"))),
88
+ onError: (handler) => ws.on("error", handler),
89
+ };
90
+ };
91
+
92
+ function toUint8(data: RawData): Uint8Array {
93
+ if (data instanceof ArrayBuffer) return new Uint8Array(data);
94
+ if (Array.isArray(data)) return new Uint8Array(Buffer.concat(data));
95
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
96
+ }