@kuralle-syrinx/ws 4.1.0 → 4.2.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.2.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": {
@@ -15,7 +33,7 @@
15
33
  },
16
34
  "dependencies": {
17
35
  "ws": "^8.18.0",
18
- "@kuralle-syrinx/core": "4.1.0"
36
+ "@kuralle-syrinx/core": "4.2.0"
19
37
  },
20
38
  "devDependencies": {
21
39
  "@types/ws": "^8.5.0",
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?.();
@@ -7,7 +7,7 @@
7
7
  import { describe, expect, it } from "vitest";
8
8
  import type { RetryConfig } from "@kuralle-syrinx/core";
9
9
 
10
- import { WebSocketConnection, type SocketData } from "./index.js";
10
+ import { WebSocketConnection, type ManagedSocket, type SocketData } from "./index.js";
11
11
  import { wrapWebSocket, type WebSocketEventLike, type WebSocketLike } from "./web-socket.js";
12
12
 
13
13
  const FAST_RETRY: RetryConfig = { maxAttempts: 5, baseDelayMs: 5, maxDelayMs: 20 };
@@ -83,6 +83,69 @@ describe("WebSocketConnection reconnect replay (VE-06.2)", () => {
83
83
  await conn.close();
84
84
  });
85
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
+
86
149
  it("bounds the replay buffer, dropping oldest with an overflow signal", async () => {
87
150
  const fake = new FakeWebSocket();
88
151
  const events: string[] = [];