@kuralle-syrinx/ws 4.4.1 → 4.5.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/dist/index.d.ts +130 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +424 -0
- package/dist/index.js.map +1 -0
- package/dist/node.d.ts +3 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +95 -0
- package/dist/node.js.map +1 -0
- package/dist/realtime-socket.d.ts +24 -0
- package/dist/realtime-socket.d.ts.map +1 -0
- package/dist/realtime-socket.js +45 -0
- package/dist/realtime-socket.js.map +1 -0
- package/dist/web-socket.d.ts +20 -0
- package/dist/web-socket.d.ts.map +1 -0
- package/dist/web-socket.js +62 -0
- package/dist/web-socket.js.map +1 -0
- package/dist/workers.d.ts +26 -0
- package/dist/workers.d.ts.map +1 -0
- package/dist/workers.js +122 -0
- package/dist/workers.js.map +1 -0
- package/package.json +32 -10
- package/src/index.test.ts +350 -0
- package/src/node.test.ts +68 -0
- package/src/replay.test.ts +162 -0
- package/src/web-socket.test.ts +132 -0
- package/src/workers.test.ts +84 -0
|
@@ -0,0 +1,132 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
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
|
+
});
|