@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
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// Full-duplex provider socket for OpenAI Realtime (and siblings). Wraps
|
|
4
|
+
// WebSocketConnection with JSON send/receive — the cascade STT/TTS sockets
|
|
5
|
+
// stay separate; this is the bi-model live loop transport.
|
|
6
|
+
|
|
7
|
+
import { VOICE_PROVIDER_RETRY_CONFIG, type RetryConfig } from "@kuralle-syrinx/core";
|
|
8
|
+
|
|
9
|
+
import { WebSocketConnection, type SocketFactory } from "./index.js";
|
|
10
|
+
|
|
11
|
+
export interface RealtimeSocketOptions {
|
|
12
|
+
readonly url: () => string;
|
|
13
|
+
readonly headers?: Record<string, string>;
|
|
14
|
+
readonly socketFactory: SocketFactory;
|
|
15
|
+
readonly retry?: RetryConfig;
|
|
16
|
+
readonly keepAliveIntervalMs?: number;
|
|
17
|
+
readonly replayBufferSize?: number;
|
|
18
|
+
readonly onMessage: (json: string) => void;
|
|
19
|
+
readonly onReady?: () => void;
|
|
20
|
+
readonly onConnectionLost?: (err: Error) => void;
|
|
21
|
+
readonly onUnrecoverable?: (err: Error) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class RealtimeSocket {
|
|
25
|
+
private conn: WebSocketConnection | null = null;
|
|
26
|
+
|
|
27
|
+
constructor(private readonly opts: RealtimeSocketOptions) {}
|
|
28
|
+
|
|
29
|
+
async connect(): Promise<void> {
|
|
30
|
+
this.conn = new WebSocketConnection({
|
|
31
|
+
url: this.opts.url,
|
|
32
|
+
headers: this.opts.headers,
|
|
33
|
+
socketFactory: this.opts.socketFactory,
|
|
34
|
+
retry: this.opts.retry ?? VOICE_PROVIDER_RETRY_CONFIG,
|
|
35
|
+
keepAliveIntervalMs: this.opts.keepAliveIntervalMs ?? 15_000,
|
|
36
|
+
replayBufferSize: this.opts.replayBufferSize ?? 0,
|
|
37
|
+
onMessage: (data) => {
|
|
38
|
+
if (typeof data === "string") this.opts.onMessage(data);
|
|
39
|
+
},
|
|
40
|
+
onConnectionLost: this.opts.onConnectionLost,
|
|
41
|
+
onUnrecoverable: this.opts.onUnrecoverable,
|
|
42
|
+
onReadyBeforeReplay: this.opts.onReady,
|
|
43
|
+
});
|
|
44
|
+
await this.conn.connect();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
send(event: Record<string, unknown>): void {
|
|
48
|
+
if (!this.conn) throw new Error("Realtime socket is not connected");
|
|
49
|
+
this.conn.send(JSON.stringify(event));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
get isReady(): boolean {
|
|
53
|
+
return this.conn?.isReady ?? false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async close(): Promise<void> {
|
|
57
|
+
await this.conn?.close();
|
|
58
|
+
this.conn = null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
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 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("bounds the replay buffer, dropping oldest with an overflow signal", async () => {
|
|
87
|
+
const fake = new FakeWebSocket();
|
|
88
|
+
const events: string[] = [];
|
|
89
|
+
const conn = new WebSocketConnection({
|
|
90
|
+
url: () => "wss://x", socketFactory: () => wrapWebSocket(fake), retry: FAST_RETRY,
|
|
91
|
+
replayBufferSize: 2, onReplay: (e) => events.push(e), onMessage: () => undefined,
|
|
92
|
+
});
|
|
93
|
+
const connected = conn.connect(); fake.fireOpen(); await connected;
|
|
94
|
+
fake.readyState = 3; // closed
|
|
95
|
+
conn.send("a"); conn.send("b"); conn.send("c"); // buffer holds last 2 → "a" overflows
|
|
96
|
+
expect(events.filter((e) => e === "overflow")).toHaveLength(1);
|
|
97
|
+
await conn.close();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -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,80 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// Adapter for WebSocketConnection backed by the standard built-in WebSocket —
|
|
4
|
+
// the one available in the browser, Cloudflare Workers, and Bun (and Node 21+).
|
|
5
|
+
// It has no ping frame, so verify() falls back to readyState and keepalive must
|
|
6
|
+
// use an app-level message (keepAliveMessage). Headers cannot be set on the
|
|
7
|
+
// WebSocket constructor in the browser; on Workers, auth-header connections use
|
|
8
|
+
// the fetch-upgrade route — construct the socket yourself and pass it to
|
|
9
|
+
// wrapWebSocket.
|
|
10
|
+
|
|
11
|
+
import type { ManagedSocket, SocketData, SocketFactory } from "./index.js";
|
|
12
|
+
|
|
13
|
+
/** The subset of the standard WebSocket this adapter relies on (no DOM lib needed). */
|
|
14
|
+
export interface WebSocketLike {
|
|
15
|
+
readyState: number;
|
|
16
|
+
binaryType: string;
|
|
17
|
+
send(data: string | ArrayBufferView | ArrayBuffer): void;
|
|
18
|
+
close(code?: number, reason?: string): void;
|
|
19
|
+
addEventListener(type: string, listener: (event: WebSocketEventLike) => void): void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The event object a standard WebSocket hands its listeners (message: data; close: code/reason). */
|
|
23
|
+
export interface WebSocketEventLike {
|
|
24
|
+
readonly data?: string | ArrayBuffer;
|
|
25
|
+
readonly code?: number;
|
|
26
|
+
readonly reason?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const WEBSOCKET_OPEN = 1;
|
|
30
|
+
|
|
31
|
+
/** Factory using the runtime's global WebSocket (no auth headers — same-origin or token-in-URL). */
|
|
32
|
+
export const createWebSocketAdapter: SocketFactory = (url): ManagedSocket => {
|
|
33
|
+
const ctor = (globalThis as { WebSocket?: new (url: string) => WebSocketLike }).WebSocket;
|
|
34
|
+
if (!ctor) throw new Error("global WebSocket is not available in this runtime");
|
|
35
|
+
return wrapWebSocket(new ctor(url));
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** Wrap an already-constructed WebSocket (e.g. a Workers fetch-upgrade `response.webSocket`). */
|
|
39
|
+
export function wrapWebSocket(ws: WebSocketLike): ManagedSocket {
|
|
40
|
+
ws.binaryType = "arraybuffer";
|
|
41
|
+
return {
|
|
42
|
+
get isOpen(): boolean {
|
|
43
|
+
return ws.readyState === WEBSOCKET_OPEN;
|
|
44
|
+
},
|
|
45
|
+
send: (data: SocketData) => ws.send(data),
|
|
46
|
+
keepAlivePing: () => {
|
|
47
|
+
// The standard WebSocket has no ping frame — rely on keepAliveMessage.
|
|
48
|
+
},
|
|
49
|
+
verify: async (_timeoutMs: number): Promise<boolean> => ws.readyState === WEBSOCKET_OPEN,
|
|
50
|
+
dispose: () => {
|
|
51
|
+
try {
|
|
52
|
+
ws.close();
|
|
53
|
+
} catch {
|
|
54
|
+
// best effort
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
onOpen: (handler) => {
|
|
58
|
+
// workerd: after fetch-upgrade + WebSocket.accept(), readyState is already OPEN
|
|
59
|
+
// and the runtime never emits an "open" event — only sockets that transition
|
|
60
|
+
// from CONNECTING fire it. queueMicrotask preserves the async onOpen contract
|
|
61
|
+
// WebSocketConnection expects without waiting for an event that won't come.
|
|
62
|
+
if (ws.readyState === WEBSOCKET_OPEN) {
|
|
63
|
+
queueMicrotask(handler);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
ws.addEventListener("open", () => handler());
|
|
67
|
+
},
|
|
68
|
+
onMessage: (handler) =>
|
|
69
|
+
ws.addEventListener("message", (event) => {
|
|
70
|
+
const payload = event.data;
|
|
71
|
+
if (typeof payload === "string") {
|
|
72
|
+
handler(payload, false);
|
|
73
|
+
} else if (payload) {
|
|
74
|
+
handler(new Uint8Array(payload), true);
|
|
75
|
+
}
|
|
76
|
+
}),
|
|
77
|
+
onClose: (handler) => ws.addEventListener("close", (event) => handler(event.code ?? 1006, event.reason ?? "")),
|
|
78
|
+
onError: (handler) => ws.addEventListener("error", () => handler(new Error("WebSocket error"))),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -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
|
+
});
|
package/src/workers.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// Cloudflare Workers socket adapter. The built-in WebSocket constructor can't
|
|
4
|
+
// set request headers, so auth-header provider connections (Deepgram's
|
|
5
|
+
// `Authorization: Token`, Cartesia's `X-API-Key`) are opened with a `fetch`
|
|
6
|
+
// upgrade and the returned `response.webSocket`, then `accept()`-ed before use.
|
|
7
|
+
//
|
|
8
|
+
// Pattern from Cloudflare's own agents/voice package (workers-ai-providers.ts):
|
|
9
|
+
// const resp = await fetch(url, { headers: { Upgrade: "websocket", ...auth } });
|
|
10
|
+
// const ws = resp.webSocket; ws.accept();
|
|
11
|
+
|
|
12
|
+
import type { ManagedSocket } from "./index.js";
|
|
13
|
+
import { wrapWebSocket, type WebSocketLike } from "./web-socket.js";
|
|
14
|
+
|
|
15
|
+
/** The Workers WebSocket adds accept(); the upgrade response carries it. */
|
|
16
|
+
type WorkersWebSocket = WebSocketLike & { accept(): void };
|
|
17
|
+
interface UpgradeResponse {
|
|
18
|
+
readonly status?: number;
|
|
19
|
+
readonly webSocket?: WorkersWebSocket | null;
|
|
20
|
+
}
|
|
21
|
+
type WorkersFetch = (url: string, init: { headers: Record<string, string> }) => Promise<UpgradeResponse>;
|
|
22
|
+
|
|
23
|
+
export interface WorkersDurableObjectWebSocketContext {
|
|
24
|
+
acceptWebSocket(socket: WorkersWebSocket): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface WorkersInboundSocket {
|
|
28
|
+
readonly socket: ManagedSocket;
|
|
29
|
+
readonly response: Response;
|
|
30
|
+
readonly clientWebSocket: WorkersWebSocket;
|
|
31
|
+
readonly serverWebSocket: WorkersWebSocket;
|
|
32
|
+
readonly controller?: WorkersInboundSocketController;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface WorkersInboundSocketController {
|
|
36
|
+
message(data: string | ArrayBuffer): void;
|
|
37
|
+
close(code: number, reason: string): void;
|
|
38
|
+
error(err?: Error): void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type WebSocketPairValue = { 0: WorkersWebSocket; 1: WorkersWebSocket };
|
|
42
|
+
type WebSocketPairConstructor = new () => WebSocketPairValue;
|
|
43
|
+
|
|
44
|
+
/** Open an auth-header WebSocket from a Worker via fetch upgrade. Async — await before use. */
|
|
45
|
+
export async function createWorkersSocket(url: string, headers: Record<string, string>): Promise<ManagedSocket> {
|
|
46
|
+
const doFetch = (globalThis as { fetch?: WorkersFetch }).fetch;
|
|
47
|
+
if (!doFetch) throw new Error("fetch is not available in this runtime");
|
|
48
|
+
// workerd's fetch() only accepts http(s) schemes; the upgrade is requested on
|
|
49
|
+
// the http(s) URL with `Upgrade: websocket`, not on a ws(s):// URL.
|
|
50
|
+
const resp = await doFetch(toHttpUrl(url), { headers: { ...headers, Upgrade: "websocket" } });
|
|
51
|
+
const ws = resp.webSocket;
|
|
52
|
+
if (!ws) throw new Error(`WebSocket upgrade failed (status ${String(resp.status ?? "unknown")})`);
|
|
53
|
+
ws.accept();
|
|
54
|
+
return wrapWebSocket(ws);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Normalize a ws(s):// provider URL to the http(s):// scheme workerd's fetch requires. */
|
|
58
|
+
function toHttpUrl(url: string): string {
|
|
59
|
+
if (url.startsWith("wss://")) return `https://${url.slice("wss://".length)}`;
|
|
60
|
+
if (url.startsWith("ws://")) return `http://${url.slice("ws://".length)}`;
|
|
61
|
+
return url;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function createWorkersInboundSocket(ctx?: WorkersDurableObjectWebSocketContext): WorkersInboundSocket {
|
|
65
|
+
const Pair = (globalThis as { WebSocketPair?: WebSocketPairConstructor }).WebSocketPair;
|
|
66
|
+
if (!Pair) throw new Error("WebSocketPair is not available in this runtime");
|
|
67
|
+
const pair = new Pair();
|
|
68
|
+
const client = pair[0];
|
|
69
|
+
const server = pair[1];
|
|
70
|
+
let controller: WorkersInboundSocketController | undefined;
|
|
71
|
+
let socket: ManagedSocket;
|
|
72
|
+
if (ctx) {
|
|
73
|
+
ctx.acceptWebSocket(server);
|
|
74
|
+
const controlled = createControlledSocket(server);
|
|
75
|
+
socket = controlled.socket;
|
|
76
|
+
controller = controlled.controller;
|
|
77
|
+
} else {
|
|
78
|
+
server.accept();
|
|
79
|
+
socket = wrapWebSocket(server);
|
|
80
|
+
}
|
|
81
|
+
const response = new Response(null, {
|
|
82
|
+
status: 101,
|
|
83
|
+
webSocket: client,
|
|
84
|
+
} as ResponseInit & { webSocket: WorkersWebSocket });
|
|
85
|
+
return {
|
|
86
|
+
socket,
|
|
87
|
+
response,
|
|
88
|
+
clientWebSocket: client,
|
|
89
|
+
serverWebSocket: server,
|
|
90
|
+
controller,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function createControlledSocket(ws: WorkersWebSocket): {
|
|
95
|
+
readonly socket: ManagedSocket;
|
|
96
|
+
readonly controller: WorkersInboundSocketController;
|
|
97
|
+
} {
|
|
98
|
+
const messageHandlers = new Set<(data: string | Uint8Array, isBinary: boolean) => void>();
|
|
99
|
+
const closeHandlers = new Set<(code: number, reason: string) => void>();
|
|
100
|
+
const errorHandlers = new Set<(err: Error) => void>();
|
|
101
|
+
return {
|
|
102
|
+
socket: {
|
|
103
|
+
get isOpen(): boolean {
|
|
104
|
+
return ws.readyState === 1;
|
|
105
|
+
},
|
|
106
|
+
send: (data) => ws.send(data),
|
|
107
|
+
keepAlivePing: () => undefined,
|
|
108
|
+
verify: async () => ws.readyState === 1,
|
|
109
|
+
dispose: () => {
|
|
110
|
+
try {
|
|
111
|
+
ws.close();
|
|
112
|
+
} catch {
|
|
113
|
+
// best effort
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
onOpen: (handler) => {
|
|
117
|
+
if (ws.readyState === 1) queueMicrotask(handler);
|
|
118
|
+
},
|
|
119
|
+
onMessage: (handler) => {
|
|
120
|
+
messageHandlers.add(handler);
|
|
121
|
+
},
|
|
122
|
+
onClose: (handler) => {
|
|
123
|
+
closeHandlers.add(handler);
|
|
124
|
+
},
|
|
125
|
+
onError: (handler) => {
|
|
126
|
+
errorHandlers.add(handler);
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
controller: {
|
|
130
|
+
message(data) {
|
|
131
|
+
if (typeof data === "string") {
|
|
132
|
+
for (const handler of messageHandlers) handler(data, false);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const bytes = new Uint8Array(data);
|
|
136
|
+
for (const handler of messageHandlers) handler(bytes, true);
|
|
137
|
+
},
|
|
138
|
+
close(code, reason) {
|
|
139
|
+
for (const handler of closeHandlers) handler(code, reason);
|
|
140
|
+
},
|
|
141
|
+
error(err) {
|
|
142
|
+
const error = err ?? new Error("WebSocket error");
|
|
143
|
+
for (const handler of errorHandlers) handler(error);
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
}
|