@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/LICENSE +22 -0
- package/package.json +29 -0
- package/src/index.test.ts +350 -0
- package/src/index.ts +505 -0
- package/src/node.test.ts +68 -0
- package/src/node.ts +96 -0
- package/src/realtime-socket.ts +60 -0
- package/src/replay.test.ts +99 -0
- package/src/web-socket.test.ts +132 -0
- package/src/web-socket.ts +80 -0
- package/src/workers.test.ts +84 -0
- package/src/workers.ts +147 -0
- package/tsconfig.json +21 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kuralle
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kuralle-syrinx/ws",
|
|
3
|
+
"version": "2.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "./src/index.ts",
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.ts",
|
|
11
|
+
"./node": "./src/node.ts",
|
|
12
|
+
"./realtime": "./src/realtime-socket.ts",
|
|
13
|
+
"./web": "./src/web-socket.ts",
|
|
14
|
+
"./workers": "./src/workers.ts"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"ws": "^8.18.0",
|
|
18
|
+
"@kuralle-syrinx/core": "2.1.0"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/ws": "^8.5.0",
|
|
22
|
+
"typescript": "^5.7.0",
|
|
23
|
+
"vitest": "^2.1.0"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"typecheck": "tsc --noEmit",
|
|
27
|
+
"test": "vitest run"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -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
|
+
});
|