@kuralle-syrinx/ws 4.0.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 +21 -3
- package/src/index.ts +12 -1
- package/src/replay.test.ts +64 -1
package/package.json
CHANGED
|
@@ -1,9 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kuralle-syrinx/ws",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.2.0",
|
|
4
4
|
"private": false,
|
|
5
|
-
"
|
|
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.
|
|
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?.();
|
package/src/replay.test.ts
CHANGED
|
@@ -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[] = [];
|