@kuralle-syrinx/ws 4.2.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 +4 -4
- package/src/index.test.ts +0 -350
- package/src/node.test.ts +0 -68
- package/src/replay.test.ts +0 -162
- package/src/web-socket.test.ts +0 -132
- package/src/workers.test.ts +0 -84
- package/tsconfig.json +0 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kuralle-syrinx/ws",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.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
6
|
"keywords": [
|
|
@@ -32,13 +32,13 @@
|
|
|
32
32
|
"./workers": "./src/workers.ts"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"ws": "^8.
|
|
36
|
-
"@kuralle-syrinx/core": "4.
|
|
35
|
+
"ws": "^8.21.0",
|
|
36
|
+
"@kuralle-syrinx/core": "4.3.0"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@types/ws": "^8.5.0",
|
|
40
40
|
"typescript": "^5.7.0",
|
|
41
|
-
"vitest": "^2.
|
|
41
|
+
"vitest": "^3.2.6"
|
|
42
42
|
},
|
|
43
43
|
"scripts": {
|
|
44
44
|
"typecheck": "tsc --noEmit",
|
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
|
-
});
|
package/src/replay.test.ts
DELETED
|
@@ -1,162 +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 ManagedSocket, 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("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
|
-
|
|
149
|
-
it("bounds the replay buffer, dropping oldest with an overflow signal", async () => {
|
|
150
|
-
const fake = new FakeWebSocket();
|
|
151
|
-
const events: string[] = [];
|
|
152
|
-
const conn = new WebSocketConnection({
|
|
153
|
-
url: () => "wss://x", socketFactory: () => wrapWebSocket(fake), retry: FAST_RETRY,
|
|
154
|
-
replayBufferSize: 2, onReplay: (e) => events.push(e), onMessage: () => undefined,
|
|
155
|
-
});
|
|
156
|
-
const connected = conn.connect(); fake.fireOpen(); await connected;
|
|
157
|
-
fake.readyState = 3; // closed
|
|
158
|
-
conn.send("a"); conn.send("b"); conn.send("c"); // buffer holds last 2 → "a" overflows
|
|
159
|
-
expect(events.filter((e) => e === "overflow")).toHaveLength(1);
|
|
160
|
-
await conn.close();
|
|
161
|
-
});
|
|
162
|
-
});
|
package/src/web-socket.test.ts
DELETED
|
@@ -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
|
-
});
|
package/src/workers.test.ts
DELETED
|
@@ -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
|
-
}
|