@kuralle-syrinx/server-websocket 3.0.0 → 4.0.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 +3 -3
- package/src/admission-control.test.ts +23 -0
- package/src/browser-pacing.test.ts +1 -1
- package/src/edge-twilio.test.ts +26 -0
- package/src/edge-twilio.ts +42 -20
- package/src/edge.test.ts +79 -0
- package/src/edge.ts +13 -0
- package/src/inbound-audio.test.ts +58 -0
- package/src/inbound-audio.ts +9 -2
- package/src/index.test.ts +6 -3
- package/src/index.ts +68 -8
- package/src/outbound-playout-pipeline.test.ts +58 -1
- package/src/outbound-playout-pipeline.ts +39 -0
- package/src/smartpbx.ts +7 -1
- package/src/telnyx.ts +7 -1
- package/src/transport-host.ts +12 -1
- package/src/twilio-auth.test.ts +36 -0
- package/src/twilio-auth.ts +55 -0
- package/src/twilio.ts +7 -1
- package/src/websocket-upgrade.ts +20 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kuralle-syrinx/server-websocket",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
"dependencies": {
|
|
16
16
|
"@evan/opus": "1.0.3",
|
|
17
17
|
"ws": "^8.18.0",
|
|
18
|
-
"@kuralle-syrinx/core": "
|
|
19
|
-
"@kuralle-syrinx/ws": "
|
|
18
|
+
"@kuralle-syrinx/core": "4.0.0",
|
|
19
|
+
"@kuralle-syrinx/ws": "4.0.0"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@types/node": "^22.0.0",
|
|
@@ -79,6 +79,29 @@ describe("WT-08 admission control and upgrade routing", () => {
|
|
|
79
79
|
await server.close();
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
+
it("rejects an unauthorized upgrade with 4401 and admits an authorized one", async () => {
|
|
83
|
+
const server = registerServer(await createVoiceWebSocketServer({
|
|
84
|
+
port: 0,
|
|
85
|
+
authorize: (request) => new URL(request.url ?? "/", "http://x").searchParams.get("token") === "secret",
|
|
86
|
+
createSession: () => new VoiceAgentSession({ plugins: {} }),
|
|
87
|
+
}));
|
|
88
|
+
const address = server.address();
|
|
89
|
+
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
90
|
+
|
|
91
|
+
const rejected = registerSocket(new WebSocket(`${websocketUrl(address.port)}?token=wrong`));
|
|
92
|
+
await new Promise<void>((resolve, reject) => {
|
|
93
|
+
rejected.once("open", resolve);
|
|
94
|
+
rejected.once("error", reject);
|
|
95
|
+
});
|
|
96
|
+
expect(await waitForClose(rejected)).toBe(4401);
|
|
97
|
+
|
|
98
|
+
const authorized = await openBrowserSocketReady(`${websocketUrl(address.port)}?token=secret`);
|
|
99
|
+
expect(authorized.readyState).toBe(WebSocket.OPEN);
|
|
100
|
+
|
|
101
|
+
authorized.close();
|
|
102
|
+
await server.close();
|
|
103
|
+
});
|
|
104
|
+
|
|
82
105
|
it("destroys sockets on unmatched upgrade paths when this router is the sole upgrade handler", async () => {
|
|
83
106
|
const httpServer = registerHttpServer(createServer());
|
|
84
107
|
const server = registerServer(await createVoiceWebSocketServer({
|
|
@@ -168,7 +168,7 @@ describe("WT-03 Browser outbound pacing", () => {
|
|
|
168
168
|
expect(c.type).toBe("tts_chunk");
|
|
169
169
|
expect(c.turnId).toBe("test-turn");
|
|
170
170
|
expect(c.sequence).toBeGreaterThan(0);
|
|
171
|
-
expect(c.sampleRateHz).toBe(
|
|
171
|
+
expect(c.sampleRateHz).toBe(48000); // opus frames carry the 48 kHz codec rate
|
|
172
172
|
expect(c.encoding).toBe("opus");
|
|
173
173
|
expect(c.channels).toBe(1);
|
|
174
174
|
expect(c.byteLength).toBeGreaterThan(0);
|
package/src/edge-twilio.test.ts
CHANGED
|
@@ -159,6 +159,32 @@ describe("Twilio edge ingress", () => {
|
|
|
159
159
|
expect(socket.disposed).toBe(true);
|
|
160
160
|
});
|
|
161
161
|
|
|
162
|
+
it("actually closes the session on hangup (no provider-socket leak)", async () => {
|
|
163
|
+
// Regression for the R1 leak: cleanup released without decrementing
|
|
164
|
+
// connectionCount, so release early-returned and session.close() never ran —
|
|
165
|
+
// Deepgram/TTS sockets leaked until DO eviction on every phone call.
|
|
166
|
+
const socket = new FakeSocket();
|
|
167
|
+
let closed = false;
|
|
168
|
+
const bus = new PipelineBusImpl();
|
|
169
|
+
const session = {
|
|
170
|
+
bus,
|
|
171
|
+
async start() { void bus.start(); },
|
|
172
|
+
async close() { closed = true; bus.stop(); },
|
|
173
|
+
on() {}, off() {}, requestClientInterrupt() {},
|
|
174
|
+
} as unknown as VoiceAgentSession;
|
|
175
|
+
|
|
176
|
+
await runTwilioEdgeWebSocketConnection(
|
|
177
|
+
socket,
|
|
178
|
+
new Request("https://edge.test/twilio?sessionId=tw-close"),
|
|
179
|
+
{ sessionStore: new InMemorySessionStore(), createSession: () => session, keepAliveIntervalMs: 0 },
|
|
180
|
+
);
|
|
181
|
+
socket.emit(JSON.stringify({ event: "start", streamSid: "MZ9", start: { streamSid: "MZ9", callSid: "CA9" } }));
|
|
182
|
+
socket.emit(JSON.stringify({ event: "stop", streamSid: "MZ9" }));
|
|
183
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
184
|
+
|
|
185
|
+
expect(closed).toBe(true);
|
|
186
|
+
});
|
|
187
|
+
|
|
162
188
|
it("rotates the uplink contextId after each completed turn", async () => {
|
|
163
189
|
const received: UserAudioReceivedPacket[] = [];
|
|
164
190
|
const { socket, session } = await startConnection(received);
|
package/src/edge-twilio.ts
CHANGED
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
createWorkersInboundSocket,
|
|
31
31
|
type WorkersDurableObjectWebSocketContext,
|
|
32
32
|
} from "@kuralle-syrinx/ws/workers";
|
|
33
|
-
import type { SessionStore } from "./session-store.js";
|
|
33
|
+
import type { SessionStore, ManagedSession } from "./session-store.js";
|
|
34
34
|
|
|
35
35
|
const TWILIO_SAMPLE_RATE_HZ = 8_000;
|
|
36
36
|
const DEFAULT_RESUME_WINDOW_MS = 15_000;
|
|
@@ -82,6 +82,8 @@ export async function runTwilioEdgeWebSocketConnection(
|
|
|
82
82
|
const downlinkResamplers = new Map<string, StreamingPcm16Resampler>();
|
|
83
83
|
const disposers: Array<() => void> = [];
|
|
84
84
|
let session: VoiceAgentSession | null = null;
|
|
85
|
+
let managed: ManagedSession | null = null;
|
|
86
|
+
let pendingLease: Promise<{ managed: ManagedSession }> | null = null;
|
|
85
87
|
let sessionId = "";
|
|
86
88
|
let streamSid = "";
|
|
87
89
|
let contextId = "";
|
|
@@ -100,9 +102,26 @@ export async function runTwilioEdgeWebSocketConnection(
|
|
|
100
102
|
if (closed) return;
|
|
101
103
|
closed = true;
|
|
102
104
|
scheduler.cancel(KEEP_ALIVE_KEY);
|
|
105
|
+
scheduler.cancel("voice.edge.twilio.startup");
|
|
103
106
|
for (const dispose of disposers.splice(0)) dispose();
|
|
104
|
-
if (
|
|
107
|
+
if (managed && sessionId) {
|
|
108
|
+
// Decrement the connection count BEFORE releasing (R1) — otherwise release
|
|
109
|
+
// early-returns while connectionCount > 0 and session.close() never runs, so
|
|
110
|
+
// Deepgram/TTS provider sockets + the reasoner leak until DO eviction. A
|
|
111
|
+
// caller-hangup (`stopped`) releases immediately (retain 0); a transient drop
|
|
112
|
+
// keeps the session warm for the resume window.
|
|
113
|
+
managed.connectionCount = Math.max(0, managed.connectionCount - 1);
|
|
105
114
|
void options.sessionStore.release(sessionId, stopped ? 0 : resumeWindowMs);
|
|
115
|
+
} else if (pendingLease && sessionId) {
|
|
116
|
+
// Closed before the lease was adopted (e.g. startup-timeout race): tear the
|
|
117
|
+
// in-flight session down when it resolves so it is never orphaned in the store.
|
|
118
|
+
const id = sessionId;
|
|
119
|
+
void pendingLease
|
|
120
|
+
.then((leased) => {
|
|
121
|
+
leased.managed.connectionCount = Math.max(0, leased.managed.connectionCount - 1);
|
|
122
|
+
return options.sessionStore.release(id, 0);
|
|
123
|
+
})
|
|
124
|
+
.catch(() => undefined);
|
|
106
125
|
}
|
|
107
126
|
};
|
|
108
127
|
|
|
@@ -141,26 +160,29 @@ export async function runTwilioEdgeWebSocketConnection(
|
|
|
141
160
|
reject(new Error("twilio session startup timeout"));
|
|
142
161
|
});
|
|
143
162
|
});
|
|
144
|
-
|
|
145
|
-
options.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
]);
|
|
163
|
+
pendingLease = options.sessionStore.lease(sessionId, async () => {
|
|
164
|
+
const sess = await options.createSession(request);
|
|
165
|
+
await sess.start();
|
|
166
|
+
return {
|
|
167
|
+
id: sessionId,
|
|
168
|
+
session: sess,
|
|
169
|
+
currentContextId: "",
|
|
170
|
+
contextSampleRates: new Map(),
|
|
171
|
+
inputSequence: { lastSequence: null },
|
|
172
|
+
turnMetricsTurns: new Map(),
|
|
173
|
+
closeTimer: null,
|
|
174
|
+
connectionCount: 1,
|
|
175
|
+
};
|
|
176
|
+
});
|
|
177
|
+
const leased = await Promise.race([pendingLease, startupTimer]);
|
|
178
|
+
pendingLease = null;
|
|
161
179
|
scheduler.cancel("voice.edge.twilio.startup");
|
|
162
|
-
|
|
180
|
+
managed = leased.managed;
|
|
181
|
+
session = managed.session;
|
|
163
182
|
if (closed) {
|
|
183
|
+
// The socket closed while the session was starting up — tear the just-started
|
|
184
|
+
// session down instead of orphaning it (decrement so release actually closes it).
|
|
185
|
+
managed.connectionCount = Math.max(0, managed.connectionCount - 1);
|
|
164
186
|
await options.sessionStore.release(sessionId, 0);
|
|
165
187
|
return;
|
|
166
188
|
}
|
package/src/edge.test.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest";
|
|
|
4
4
|
import {
|
|
5
5
|
PipelineBusImpl,
|
|
6
6
|
Route,
|
|
7
|
+
VoiceAgentSession as VoiceAgentSessionImpl,
|
|
7
8
|
encodeSyrinxAudioEnvelope,
|
|
8
9
|
type Scheduler,
|
|
9
10
|
type ScheduledCallback,
|
|
@@ -381,6 +382,84 @@ describe("edge barge-in downlink", () => {
|
|
|
381
382
|
});
|
|
382
383
|
});
|
|
383
384
|
|
|
385
|
+
describe("edge tool-call cues (G3/WBS-3)", () => {
|
|
386
|
+
it("surfaces the typed lifecycle as tool_call_* wire messages: started → delayed → complete", async () => {
|
|
387
|
+
const socket = new FakeSocket();
|
|
388
|
+
const scheduler = new ManualScheduler();
|
|
389
|
+
const session = new VoiceAgentSessionImpl({ plugins: {}, delayCueAfterMs: 20 });
|
|
390
|
+
await runVoiceEdgeWebSocketConnection(socket, new Request("https://edge.test/ws?sessionId=s1"), {
|
|
391
|
+
sessionStore: new InMemorySessionStore(),
|
|
392
|
+
scheduler,
|
|
393
|
+
createSession: () => session,
|
|
394
|
+
});
|
|
395
|
+
waitForReady(socket);
|
|
396
|
+
|
|
397
|
+
session.bus.push(Route.Main, {
|
|
398
|
+
kind: "llm.tool_call",
|
|
399
|
+
contextId: "turn-1",
|
|
400
|
+
timestampMs: Date.now(),
|
|
401
|
+
toolId: "t1",
|
|
402
|
+
toolName: "consult_knowledge",
|
|
403
|
+
toolArgs: { query: "fees" },
|
|
404
|
+
});
|
|
405
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
406
|
+
session.bus.push(Route.Main, {
|
|
407
|
+
kind: "llm.tool_result",
|
|
408
|
+
contextId: "turn-1",
|
|
409
|
+
timestampMs: Date.now(),
|
|
410
|
+
toolId: "t1",
|
|
411
|
+
toolName: "consult_knowledge",
|
|
412
|
+
result: "answer",
|
|
413
|
+
});
|
|
414
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
415
|
+
|
|
416
|
+
expect(socket.json()).toContainEqual({
|
|
417
|
+
type: "tool_call_started", turnId: "turn-1", toolId: "t1", toolName: "consult_knowledge",
|
|
418
|
+
});
|
|
419
|
+
expect(socket.json()).toContainEqual({
|
|
420
|
+
type: "tool_call_delayed", turnId: "turn-1", toolId: "t1", toolName: "consult_knowledge", afterMs: 20,
|
|
421
|
+
});
|
|
422
|
+
expect(socket.json()).toContainEqual({
|
|
423
|
+
type: "tool_call_complete", turnId: "turn-1", toolId: "t1", toolName: "consult_knowledge",
|
|
424
|
+
});
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it("surfaces tool_call_failed when a barge-in aborts the pending delegate (R5)", async () => {
|
|
428
|
+
const socket = new FakeSocket();
|
|
429
|
+
const scheduler = new ManualScheduler();
|
|
430
|
+
const session = new VoiceAgentSessionImpl({ plugins: {}, delayCueAfterMs: 0 });
|
|
431
|
+
await runVoiceEdgeWebSocketConnection(socket, new Request("https://edge.test/ws?sessionId=s1"), {
|
|
432
|
+
sessionStore: new InMemorySessionStore(),
|
|
433
|
+
scheduler,
|
|
434
|
+
createSession: () => session,
|
|
435
|
+
});
|
|
436
|
+
waitForReady(socket);
|
|
437
|
+
|
|
438
|
+
session.bus.push(Route.Main, {
|
|
439
|
+
kind: "llm.tool_call",
|
|
440
|
+
contextId: "turn-1",
|
|
441
|
+
timestampMs: Date.now(),
|
|
442
|
+
toolId: "t1",
|
|
443
|
+
toolName: "consult_knowledge",
|
|
444
|
+
toolArgs: {},
|
|
445
|
+
});
|
|
446
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
447
|
+
session.bus.push(Route.Critical, {
|
|
448
|
+
kind: "interrupt.detected",
|
|
449
|
+
contextId: "turn-1",
|
|
450
|
+
timestampMs: Date.now(),
|
|
451
|
+
source: "vad",
|
|
452
|
+
});
|
|
453
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
454
|
+
|
|
455
|
+
expect(socket.json()).toContainEqual({
|
|
456
|
+
type: "tool_call_failed", turnId: "turn-1", toolId: "t1", toolName: "consult_knowledge",
|
|
457
|
+
});
|
|
458
|
+
// Barge-in downlink unaffected — the flush messages still went out.
|
|
459
|
+
expect(socket.json().some((m) => m.type === "audio_clear")).toBe(true);
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
384
463
|
describe("edge client playout progress", () => {
|
|
385
464
|
interface ProgressPacket {
|
|
386
465
|
contextId: string;
|
package/src/edge.ts
CHANGED
|
@@ -367,6 +367,19 @@ function wireEdgeSessionEvents(
|
|
|
367
367
|
onSession("agent_text_delta", (event) => {
|
|
368
368
|
sendJson(socket, { type: "agent_chunk", turnId: event.turnId, text: event.delta });
|
|
369
369
|
});
|
|
370
|
+
// G3 (RFC bimodel-delegate-seam): typed preamble/filler lifecycle. The standard
|
|
371
|
+
// "thinking" wire cue — clients key earcons/indicators on these instead of an
|
|
372
|
+
// app-invented message. started fires before the reasoner runs; delayed is the
|
|
373
|
+
// time-triggered "still working"; complete/failed end the wait (incl. barge-in).
|
|
374
|
+
onSession("tool_call_cue", (event) => {
|
|
375
|
+
sendJson(socket, {
|
|
376
|
+
type: `tool_call_${event.phase}`,
|
|
377
|
+
turnId: event.turnId,
|
|
378
|
+
toolId: event.toolId,
|
|
379
|
+
toolName: event.toolName,
|
|
380
|
+
...(event.afterMs !== undefined ? { afterMs: event.afterMs } : {}),
|
|
381
|
+
});
|
|
382
|
+
});
|
|
370
383
|
onSession("agent_finished", (event) => {
|
|
371
384
|
sendJson(socket, { type: "agent_end", turnId: event.turnId });
|
|
372
385
|
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { encodeSyrinxAudioEnvelope } from "@kuralle-syrinx/core";
|
|
5
|
+
import { decodeInboundBinaryAudio, resampleAudioBytes, type OpusIngressDecoder } from "./inbound-audio.js";
|
|
6
|
+
|
|
7
|
+
// Regression for the opus mic-uplink double-resample P0: opus ingress is decoded
|
|
8
|
+
// AND resampled to the engine rate inside decodeInboundBinaryAudio, so it must
|
|
9
|
+
// report the ENGINE rate — not the 48 kHz header rate — or the caller resamples
|
|
10
|
+
// the already-16 kHz audio a second time and delivers ~1/3 the samples (3× fast).
|
|
11
|
+
describe("decodeInboundBinaryAudio opus rate", () => {
|
|
12
|
+
const ENGINE_RATE = 16000;
|
|
13
|
+
const WIRE_RATE = 48000;
|
|
14
|
+
|
|
15
|
+
// A fake opus decoder returning PCM at the opus native rate (48 kHz), as the real
|
|
16
|
+
// decoder does. decodeInboundBinaryAudio then resamples it ONCE to the engine rate.
|
|
17
|
+
// 20 ms at 48 kHz mono PCM16 = 960 samples = 1920 bytes → 320 samples = 640 bytes @ 16 kHz.
|
|
18
|
+
const opusNativePcm = new Uint8Array(1920);
|
|
19
|
+
const fakeOpusDecoder: OpusIngressDecoder = () => opusNativePcm;
|
|
20
|
+
|
|
21
|
+
function opusEnvelope(): Uint8Array {
|
|
22
|
+
// The opus payload bytes are opaque to the decoder mock.
|
|
23
|
+
const opusPayload = new Uint8Array([1, 2, 3, 4]);
|
|
24
|
+
return encodeSyrinxAudioEnvelope(
|
|
25
|
+
{
|
|
26
|
+
type: "audio",
|
|
27
|
+
contextId: "turn-1",
|
|
28
|
+
sampleRateHz: WIRE_RATE,
|
|
29
|
+
sequence: 1,
|
|
30
|
+
encoding: "opus",
|
|
31
|
+
channels: 1,
|
|
32
|
+
byteLength: opusPayload.byteLength,
|
|
33
|
+
durationMs: 20,
|
|
34
|
+
},
|
|
35
|
+
opusPayload,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
it("reports the engine rate, not the header rate, so the caller does not resample again", () => {
|
|
40
|
+
const decoded = decodeInboundBinaryAudio(
|
|
41
|
+
opusEnvelope(),
|
|
42
|
+
ENGINE_RATE,
|
|
43
|
+
false,
|
|
44
|
+
ENGINE_RATE,
|
|
45
|
+
new Map(),
|
|
46
|
+
fakeOpusDecoder,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// Decoded once from 48 kHz → 16 kHz inside decodeInboundBinaryAudio: 640 bytes.
|
|
50
|
+
expect(decoded.sampleRateHz).toBe(ENGINE_RATE);
|
|
51
|
+
expect(decoded.audio.byteLength).toBe(640);
|
|
52
|
+
|
|
53
|
+
// The caller's final resample (reported rate → engine rate) is now an identity:
|
|
54
|
+
// 320 samples in, 320 samples out (not ~107 as under the old 48 kHz label → 3× fast).
|
|
55
|
+
const final = resampleAudioBytes(decoded.audio, decoded.sampleRateHz, ENGINE_RATE, new Map());
|
|
56
|
+
expect(final.byteLength).toBe(640);
|
|
57
|
+
});
|
|
58
|
+
});
|
package/src/inbound-audio.ts
CHANGED
|
@@ -42,12 +42,19 @@ export function decodeInboundBinaryAudio(
|
|
|
42
42
|
}
|
|
43
43
|
const { header, audio } = decodeSyrinxAudioEnvelope(data);
|
|
44
44
|
const sampleRateHz = requirePositiveIntegerFromHeader(header.sampleRateHz) ?? defaultSampleRateHz;
|
|
45
|
-
const
|
|
45
|
+
const isOpus = header.encoding === "opus";
|
|
46
|
+
const wireAudio = isOpus
|
|
46
47
|
? decodeOpusIngressMessage(audio, sampleRateHz, decodeOpusIngress, engineInputSampleRateHz, streamingResamplers)
|
|
47
48
|
: audio;
|
|
49
|
+
// Opus ingress is decoded AND resampled to the engine rate inside
|
|
50
|
+
// decodeOpusIngressMessage, so the returned PCM is already at engineInputSampleRateHz.
|
|
51
|
+
// Report that as its rate — reporting the 48 kHz *header* rate made the caller
|
|
52
|
+
// resample the already-16 kHz audio a second time, delivering ~1/3 the samples
|
|
53
|
+
// (3× sped-up audio → STT garbage). PCM carries its true header source rate.
|
|
54
|
+
const effectiveSampleRateHz = isOpus ? engineInputSampleRateHz : sampleRateHz;
|
|
48
55
|
return {
|
|
49
56
|
contextId: typeof header.contextId === "string" && header.contextId.length > 0 ? header.contextId : undefined,
|
|
50
|
-
sampleRateHz,
|
|
57
|
+
sampleRateHz: effectiveSampleRateHz,
|
|
51
58
|
sequence: header.sequence,
|
|
52
59
|
audio: wireAudio,
|
|
53
60
|
};
|
package/src/index.test.ts
CHANGED
|
@@ -601,7 +601,7 @@ describe("createVoiceWebSocketServer", () => {
|
|
|
601
601
|
type: "tts_chunk",
|
|
602
602
|
turnId: "turn-2",
|
|
603
603
|
sequence: 1,
|
|
604
|
-
sampleRateHz:
|
|
604
|
+
sampleRateHz: 48000, // opus frames are labeled at the 48 kHz codec rate
|
|
605
605
|
encoding: "opus",
|
|
606
606
|
channels: 1,
|
|
607
607
|
});
|
|
@@ -610,7 +610,7 @@ describe("createVoiceWebSocketServer", () => {
|
|
|
610
610
|
type: "audio",
|
|
611
611
|
contextId: "turn-2",
|
|
612
612
|
sequence: 1,
|
|
613
|
-
sampleRateHz:
|
|
613
|
+
sampleRateHz: 48000,
|
|
614
614
|
encoding: "opus",
|
|
615
615
|
});
|
|
616
616
|
expect(envelope.audio.byteLength).toBeGreaterThan(0);
|
|
@@ -1697,11 +1697,14 @@ describe("createVoiceWebSocketServer", () => {
|
|
|
1697
1697
|
});
|
|
1698
1698
|
|
|
1699
1699
|
const envelope = decodeTestBinaryAudioEnvelope(await binaryMessage);
|
|
1700
|
+
// Opus frames are encoded at the codec rate (48 kHz) and MUST be labeled as such,
|
|
1701
|
+
// so the client decodes then downsamples 48→16 kHz. Labeling them 16 kHz made the
|
|
1702
|
+
// client skip the downsample and play assistant audio ~3× slow.
|
|
1700
1703
|
expect(envelope.header).toMatchObject({
|
|
1701
1704
|
type: "audio",
|
|
1702
1705
|
contextId: "turn-tts",
|
|
1703
1706
|
sequence: 1,
|
|
1704
|
-
sampleRateHz:
|
|
1707
|
+
sampleRateHz: 48000,
|
|
1705
1708
|
encoding: "opus",
|
|
1706
1709
|
channels: 1,
|
|
1707
1710
|
});
|
package/src/index.ts
CHANGED
|
@@ -58,6 +58,7 @@ export * from "./twilio.js";
|
|
|
58
58
|
export * from "./telnyx.js";
|
|
59
59
|
export * from "./smartpbx.js";
|
|
60
60
|
export * from "./session-store.js";
|
|
61
|
+
export { validateTwilioSignature } from "./twilio-auth.js";
|
|
61
62
|
export { installGracefulShutdown, type GracefulClosable } from "./websocket-lifecycle.js";
|
|
62
63
|
|
|
63
64
|
export interface VoiceWebSocketServerOptions {
|
|
@@ -100,6 +101,13 @@ export interface VoiceWebSocketServerOptions {
|
|
|
100
101
|
readonly maxConcurrentSessions?: number;
|
|
101
102
|
readonly maxConcurrentSessionsScope?: "path" | "server";
|
|
102
103
|
readonly onTransportMetric?: (name: string) => void;
|
|
104
|
+
/**
|
|
105
|
+
* Authorize each inbound `/ws` upgrade (shared secret / bearer / signature).
|
|
106
|
+
* Return false or throw to reject with 4401. Unset = OPEN — voice endpoints are
|
|
107
|
+
* unauthenticated by default and each connection incurs provider cost and can
|
|
108
|
+
* attach to a live session; set this before exposing the endpoint publicly.
|
|
109
|
+
*/
|
|
110
|
+
readonly authorize?: (request: import("node:http").IncomingMessage) => boolean | Promise<boolean>;
|
|
103
111
|
}
|
|
104
112
|
|
|
105
113
|
export type { GracefulCloseOptions } from "./transport-host.js";
|
|
@@ -127,6 +135,12 @@ type ClientMessage =
|
|
|
127
135
|
readonly sequence?: number;
|
|
128
136
|
}
|
|
129
137
|
| { readonly type: "codec_capability"; readonly downlinkEncoding: "pcm_s16le" | "opus" }
|
|
138
|
+
| {
|
|
139
|
+
readonly type: "playout_progress";
|
|
140
|
+
readonly contextId?: string;
|
|
141
|
+
readonly playedOutMs: number;
|
|
142
|
+
readonly complete?: boolean;
|
|
143
|
+
}
|
|
130
144
|
| { readonly type: "ping" };
|
|
131
145
|
|
|
132
146
|
interface BrowserConnectionState {
|
|
@@ -159,6 +173,7 @@ export async function createVoiceWebSocketServer(
|
|
|
159
173
|
maxConcurrentSessions: positiveInteger(options.maxConcurrentSessions) ?? undefined,
|
|
160
174
|
maxConcurrentSessionsScope: options.maxConcurrentSessionsScope,
|
|
161
175
|
onAdmissionRejected: () => options.onTransportMetric?.(TRANSPORT_ADMISSION_REJECTED_METRIC),
|
|
176
|
+
authorize: options.authorize,
|
|
162
177
|
});
|
|
163
178
|
const wsServer = routedWebSocket.wsServer;
|
|
164
179
|
const sessionStore = options.sessionStore ?? new InMemorySessionStore();
|
|
@@ -462,6 +477,16 @@ function wireBrowserSessionEvents(
|
|
|
462
477
|
onSession("agent_tool_result", (event) => {
|
|
463
478
|
sendJson(socket, { type: "agent_tool_result", turnId: event.turnId, id: event.id, result: event.result }, maxBufferedAmountBytes);
|
|
464
479
|
});
|
|
480
|
+
// G3: typed preamble/filler lifecycle — same wire shape as the edge transport.
|
|
481
|
+
onSession("tool_call_cue", (event) => {
|
|
482
|
+
sendJson(socket, {
|
|
483
|
+
type: `tool_call_${event.phase}`,
|
|
484
|
+
turnId: event.turnId,
|
|
485
|
+
toolId: event.toolId,
|
|
486
|
+
toolName: event.toolName,
|
|
487
|
+
...(event.afterMs !== undefined ? { afterMs: event.afterMs } : {}),
|
|
488
|
+
}, maxBufferedAmountBytes);
|
|
489
|
+
});
|
|
465
490
|
onSession("agent_finished", (event) => {
|
|
466
491
|
sendJson(socket, { type: "agent_end", turnId: event.turnId }, maxBufferedAmountBytes);
|
|
467
492
|
});
|
|
@@ -489,6 +514,10 @@ function wireBrowserSessionEvents(
|
|
|
489
514
|
wireAudio: Uint8Array,
|
|
490
515
|
wireEncoding: "pcm_s16le" | "opus",
|
|
491
516
|
durationMs: number,
|
|
517
|
+
// The sample rate of THIS frame's payload. Opus frames are encoded at the
|
|
518
|
+
// codec rate (48 kHz), not outputSampleRateHz — mislabeling them 16 kHz made
|
|
519
|
+
// the client skip its 48→16 kHz downsample and play assistant audio ~3× slow.
|
|
520
|
+
wireSampleRateHz: number,
|
|
492
521
|
): void => {
|
|
493
522
|
const sequence = (ttsSequences.get(contextId) ?? 0) + 1;
|
|
494
523
|
ttsSequences.set(contextId, sequence);
|
|
@@ -500,7 +529,7 @@ function wireBrowserSessionEvents(
|
|
|
500
529
|
type: "tts_chunk",
|
|
501
530
|
turnId: contextId,
|
|
502
531
|
sequence,
|
|
503
|
-
sampleRateHz:
|
|
532
|
+
sampleRateHz: wireSampleRateHz,
|
|
504
533
|
encoding: wireEncoding,
|
|
505
534
|
channels: 1,
|
|
506
535
|
byteLength: wireAudio.byteLength,
|
|
@@ -512,7 +541,7 @@ function wireBrowserSessionEvents(
|
|
|
512
541
|
type: "audio",
|
|
513
542
|
contextId,
|
|
514
543
|
sequence,
|
|
515
|
-
sampleRateHz:
|
|
544
|
+
sampleRateHz: wireSampleRateHz,
|
|
516
545
|
encoding: wireEncoding,
|
|
517
546
|
channels: 1,
|
|
518
547
|
byteLength: wireAudio.byteLength,
|
|
@@ -532,7 +561,7 @@ function wireBrowserSessionEvents(
|
|
|
532
561
|
samples = resamplePcm16Streaming(streamingResamplers, samples, outputSampleRateHz, opusCodec.sampleRateHz);
|
|
533
562
|
}
|
|
534
563
|
for (const opus of opusCodec.encodePcm16Frame(samples, false)) {
|
|
535
|
-
pushWireFrame(opus, "opus", BROWSER_OPUS_FRAME_DURATION_MS);
|
|
564
|
+
pushWireFrame(opus, "opus", BROWSER_OPUS_FRAME_DURATION_MS, opusCodec.sampleRateHz);
|
|
536
565
|
}
|
|
537
566
|
}
|
|
538
567
|
return frames;
|
|
@@ -540,7 +569,7 @@ function wireBrowserSessionEvents(
|
|
|
540
569
|
|
|
541
570
|
for (let offset = 0; offset < resampled.byteLength; offset += frameBytesCount) {
|
|
542
571
|
const frameAudio = resampled.subarray(offset, Math.min(resampled.byteLength, offset + frameBytesCount));
|
|
543
|
-
pushWireFrame(frameAudio, "pcm_s16le", pcm16DurationMs(frameAudio, outputSampleRateHz));
|
|
572
|
+
pushWireFrame(frameAudio, "pcm_s16le", pcm16DurationMs(frameAudio, outputSampleRateHz), outputSampleRateHz);
|
|
544
573
|
}
|
|
545
574
|
return frames;
|
|
546
575
|
},
|
|
@@ -564,7 +593,7 @@ function wireBrowserSessionEvents(
|
|
|
564
593
|
type: "tts_chunk",
|
|
565
594
|
turnId: contextId,
|
|
566
595
|
sequence,
|
|
567
|
-
sampleRateHz:
|
|
596
|
+
sampleRateHz: opusCodec.sampleRateHz,
|
|
568
597
|
encoding: "opus",
|
|
569
598
|
channels: 1,
|
|
570
599
|
byteLength: opus.byteLength,
|
|
@@ -575,7 +604,7 @@ function wireBrowserSessionEvents(
|
|
|
575
604
|
type: "audio",
|
|
576
605
|
contextId,
|
|
577
606
|
sequence,
|
|
578
|
-
sampleRateHz:
|
|
607
|
+
sampleRateHz: opusCodec.sampleRateHz,
|
|
579
608
|
encoding: "opus",
|
|
580
609
|
channels: 1,
|
|
581
610
|
byteLength: opus.byteLength,
|
|
@@ -683,6 +712,22 @@ function handleClientMessage(
|
|
|
683
712
|
}
|
|
684
713
|
return currentContextId;
|
|
685
714
|
}
|
|
715
|
+
if (message.type === "playout_progress") {
|
|
716
|
+
// The browser reports how much assistant audio it has actually played out. This
|
|
717
|
+
// is a more accurate "heard" clock than the server pacer (which leads the ear by
|
|
718
|
+
// the client jitter buffer), so barge-in truncates history to what was heard (B2).
|
|
719
|
+
const progressContextId = message.contextId ?? currentContextId;
|
|
720
|
+
if (progressContextId) {
|
|
721
|
+
session.bus.push(Route.Main, {
|
|
722
|
+
kind: "tts.playout_progress",
|
|
723
|
+
contextId: progressContextId,
|
|
724
|
+
timestampMs: Date.now(),
|
|
725
|
+
playedOutMs: message.playedOutMs,
|
|
726
|
+
complete: message.complete ?? false,
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
return currentContextId;
|
|
730
|
+
}
|
|
686
731
|
if (message.type === "text") {
|
|
687
732
|
const nextContextId = message.contextId ?? contextId();
|
|
688
733
|
if (nextContextId !== currentContextId) {
|
|
@@ -750,6 +795,18 @@ function parseClientMessage(value: unknown): ClientMessage {
|
|
|
750
795
|
}
|
|
751
796
|
return { type, downlinkEncoding };
|
|
752
797
|
}
|
|
798
|
+
if (type === "playout_progress") {
|
|
799
|
+
const playedOutMs = value.playedOutMs;
|
|
800
|
+
if (typeof playedOutMs !== "number" || !Number.isFinite(playedOutMs) || playedOutMs < 0) {
|
|
801
|
+
throw new Error("playout_progress.playedOutMs must be a non-negative number");
|
|
802
|
+
}
|
|
803
|
+
return {
|
|
804
|
+
type,
|
|
805
|
+
contextId: optionalContextId(value.contextId),
|
|
806
|
+
playedOutMs,
|
|
807
|
+
complete: value.complete === true ? true : undefined,
|
|
808
|
+
};
|
|
809
|
+
}
|
|
753
810
|
throw new Error("Unsupported client message type");
|
|
754
811
|
}
|
|
755
812
|
|
|
@@ -883,9 +940,12 @@ function outboundMessageByteLength(data: string | Buffer): number {
|
|
|
883
940
|
}
|
|
884
941
|
|
|
885
942
|
function defaultContextId(): string {
|
|
886
|
-
|
|
943
|
+
// crypto-random, not Math.random: ids are guessable resource handles. V8's PRNG
|
|
944
|
+
// is state-recoverable from a few observed outputs, which would let a client
|
|
945
|
+
// predict other sessions' turn/session ids and attach to a live conversation.
|
|
946
|
+
return `turn-${globalThis.crypto.randomUUID()}`;
|
|
887
947
|
}
|
|
888
948
|
|
|
889
949
|
function defaultSessionId(): string {
|
|
890
|
-
return `session-${
|
|
950
|
+
return `session-${globalThis.crypto.randomUUID()}`;
|
|
891
951
|
}
|
|
@@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest";
|
|
|
5
5
|
import WebSocket from "ws";
|
|
6
6
|
import { Route, VoiceAgentSession } from "@kuralle-syrinx/core";
|
|
7
7
|
import { pcm16SamplesToBytes } from "@kuralle-syrinx/core/audio";
|
|
8
|
-
import { wireTelephonyOutboundPipeline } from "./outbound-playout-pipeline.js";
|
|
8
|
+
import { wireTelephonyOutboundPipeline, installTelephonyTurnRotation, type RotatableTurnContext } from "./outbound-playout-pipeline.js";
|
|
9
9
|
|
|
10
10
|
function createMockSocket(): WebSocket {
|
|
11
11
|
const emitter = new EventEmitter();
|
|
@@ -206,3 +206,60 @@ describe("wireTelephonyOutboundPipeline.drainAndClose", () => {
|
|
|
206
206
|
for (const dispose of disposers) dispose();
|
|
207
207
|
});
|
|
208
208
|
});
|
|
209
|
+
|
|
210
|
+
describe("installTelephonyTurnRotation", () => {
|
|
211
|
+
// Regression for the telephony "deaf after turn 1" P0: a carrier gives one
|
|
212
|
+
// stream per call, but STT/TTS retire a contextId once its turn completes, so
|
|
213
|
+
// reusing a single callSid id muted the agent after turn 1. Every turn must get
|
|
214
|
+
// a fresh per-turn id with a turn.change boundary.
|
|
215
|
+
it("rotates a per-turn contextId and emits turn.change on each completed turn", async () => {
|
|
216
|
+
const session = new VoiceAgentSession({ plugins: {} });
|
|
217
|
+
void session.start();
|
|
218
|
+
const disposers: Array<() => void> = [];
|
|
219
|
+
const state: RotatableTurnContext = {
|
|
220
|
+
contextId: "twilio-CA123",
|
|
221
|
+
contextBase: "twilio-CA123",
|
|
222
|
+
turnCounter: 0,
|
|
223
|
+
};
|
|
224
|
+
const turnChanges: Array<{ contextId: string; previousContextId: string; reason: string }> = [];
|
|
225
|
+
session.bus.on("turn.change", (pkt) => {
|
|
226
|
+
const change = pkt as unknown as { contextId: string; previousContextId: string; reason: string };
|
|
227
|
+
turnChanges.push({ contextId: change.contextId, previousContextId: change.previousContextId, reason: change.reason });
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
installTelephonyTurnRotation(session, disposers, state);
|
|
231
|
+
|
|
232
|
+
// Turn 1 runs on the base id.
|
|
233
|
+
expect(state.contextId).toBe("twilio-CA123");
|
|
234
|
+
|
|
235
|
+
session.bus.push(Route.Main, { kind: "eos.turn_complete", contextId: "twilio-CA123", timestampMs: Date.now(), text: "one", transcripts: [] });
|
|
236
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
237
|
+
expect(state.contextId).toBe("twilio-CA123-t1");
|
|
238
|
+
|
|
239
|
+
session.bus.push(Route.Main, { kind: "eos.turn_complete", contextId: "twilio-CA123-t1", timestampMs: Date.now(), text: "two", transcripts: [] });
|
|
240
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
241
|
+
expect(state.contextId).toBe("twilio-CA123-t2");
|
|
242
|
+
|
|
243
|
+
expect(turnChanges).toEqual([
|
|
244
|
+
{ contextId: "twilio-CA123-t1", previousContextId: "twilio-CA123", reason: "telephony_turn_complete" },
|
|
245
|
+
{ contextId: "twilio-CA123-t2", previousContextId: "twilio-CA123-t1", reason: "telephony_turn_complete" },
|
|
246
|
+
]);
|
|
247
|
+
|
|
248
|
+
for (const dispose of disposers) dispose();
|
|
249
|
+
await session.close();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("no-ops until a base is set", async () => {
|
|
253
|
+
const session = new VoiceAgentSession({ plugins: {} });
|
|
254
|
+
void session.start();
|
|
255
|
+
const disposers: Array<() => void> = [];
|
|
256
|
+
const state: RotatableTurnContext = { contextId: "", contextBase: "", turnCounter: 0 };
|
|
257
|
+
installTelephonyTurnRotation(session, disposers, state);
|
|
258
|
+
session.bus.push(Route.Main, { kind: "eos.turn_complete", contextId: "", timestampMs: Date.now(), text: "", transcripts: [] });
|
|
259
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
260
|
+
expect(state.contextId).toBe("");
|
|
261
|
+
expect(state.turnCounter).toBe(0);
|
|
262
|
+
for (const dispose of disposers) dispose();
|
|
263
|
+
await session.close();
|
|
264
|
+
});
|
|
265
|
+
});
|
|
@@ -24,6 +24,45 @@ export interface TelephonyOutboundHandle {
|
|
|
24
24
|
drainAndClose(socket: WebSocket, deadlineMs: number): Promise<void>;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Per-turn contextId identity for carrier transports.
|
|
29
|
+
*
|
|
30
|
+
* A phone carrier gives one continuous stream per call, but the engine finishes a
|
|
31
|
+
* turn per contextId and its STT/TTS plugins permanently retire a contextId once it
|
|
32
|
+
* completes (finalized/cancelled sets). Reusing one id for the whole call therefore
|
|
33
|
+
* makes the agent go deaf/mute after turn 1. Every carrier adapter mints a fresh
|
|
34
|
+
* per-turn id (`<base>-t<n>`) and rotates it at each turn boundary, emitting a
|
|
35
|
+
* `turn.change` so STT/metrics/dedup see a consistent boundary — matching the browser
|
|
36
|
+
* transport. Turn 1 uses `contextBase`; rotation begins at `-t1`.
|
|
37
|
+
*/
|
|
38
|
+
export interface RotatableTurnContext {
|
|
39
|
+
contextId: string;
|
|
40
|
+
contextBase: string;
|
|
41
|
+
turnCounter: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function installTelephonyTurnRotation(
|
|
45
|
+
session: VoiceAgentSession,
|
|
46
|
+
disposers: Array<() => void>,
|
|
47
|
+
state: RotatableTurnContext,
|
|
48
|
+
): void {
|
|
49
|
+
disposers.push(
|
|
50
|
+
session.bus.on("eos.turn_complete", () => {
|
|
51
|
+
if (!state.contextBase) return;
|
|
52
|
+
state.turnCounter += 1;
|
|
53
|
+
const previousContextId = state.contextId;
|
|
54
|
+
state.contextId = `${state.contextBase}-t${String(state.turnCounter)}`;
|
|
55
|
+
session.bus.push(Route.Main, {
|
|
56
|
+
kind: "turn.change",
|
|
57
|
+
contextId: state.contextId,
|
|
58
|
+
previousContextId,
|
|
59
|
+
timestampMs: Date.now(),
|
|
60
|
+
reason: "telephony_turn_complete",
|
|
61
|
+
});
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
27
66
|
export function wireTelephonyOutboundPipeline(args: {
|
|
28
67
|
readonly session: VoiceAgentSession;
|
|
29
68
|
readonly socket: WebSocket;
|
package/src/smartpbx.ts
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
} from "./json-message.js";
|
|
24
24
|
import { createRoutedWebSocketServer } from "./websocket-upgrade.js";
|
|
25
25
|
import { runWebSocketConnection, type GracefulCloseOptions, type TransportAdapter, type TransportHostConfig, TRANSPORT_ADMISSION_REJECTED_METRIC } from "./transport-host.js";
|
|
26
|
-
import { wireTelephonyOutboundPipeline } from "./outbound-playout-pipeline.js";
|
|
26
|
+
import { wireTelephonyOutboundPipeline, installTelephonyTurnRotation } from "./outbound-playout-pipeline.js";
|
|
27
27
|
import {
|
|
28
28
|
decodeStrictBase64,
|
|
29
29
|
nonNegativeInteger,
|
|
@@ -86,6 +86,8 @@ interface SmartPbxConnectionState {
|
|
|
86
86
|
callId: string;
|
|
87
87
|
accountId: string;
|
|
88
88
|
contextId: string;
|
|
89
|
+
contextBase: string;
|
|
90
|
+
turnCounter: number;
|
|
89
91
|
codec: SmartPbxCodec;
|
|
90
92
|
wireSampleRateHz: number;
|
|
91
93
|
opusDecoder: OpusDecoder | null;
|
|
@@ -140,6 +142,8 @@ export async function createSmartPbxMediaStreamServer(
|
|
|
140
142
|
callId: "",
|
|
141
143
|
accountId: "",
|
|
142
144
|
contextId: "",
|
|
145
|
+
contextBase: "",
|
|
146
|
+
turnCounter: 0,
|
|
143
147
|
codec: "g711_ulaw",
|
|
144
148
|
wireSampleRateHz: 8000,
|
|
145
149
|
opusDecoder: null,
|
|
@@ -239,6 +243,7 @@ export async function createSmartPbxMediaStreamServer(
|
|
|
239
243
|
},
|
|
240
244
|
});
|
|
241
245
|
state.clearPlayout = outbound.clearPlayout;
|
|
246
|
+
installTelephonyTurnRotation(session, disposers, state);
|
|
242
247
|
gracefulCloseRegistry.set(socket, (deadlineMs) => outbound.drainAndClose(socket, deadlineMs));
|
|
243
248
|
disposers.push(() => gracefulCloseRegistry.delete(socket));
|
|
244
249
|
return (reason) => {
|
|
@@ -261,6 +266,7 @@ export async function createSmartPbxMediaStreamServer(
|
|
|
261
266
|
if (!state.callId) throw new Error("SmartPBX start event is missing callId");
|
|
262
267
|
if (!state.accountId) throw new Error("SmartPBX start event is missing accountId");
|
|
263
268
|
state.contextId = contextIdFn(start);
|
|
269
|
+
state.contextBase = state.contextId;
|
|
264
270
|
state.codec = format.codec;
|
|
265
271
|
state.wireSampleRateHz = format.sampleRateHz;
|
|
266
272
|
state.opusDecoder = format.codec === "opus" ? new OpusDecoder({ channels: 1, sample_rate: 48000 }) : null;
|
package/src/telnyx.ts
CHANGED
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
} from "./json-message.js";
|
|
26
26
|
import { createRoutedWebSocketServer } from "./websocket-upgrade.js";
|
|
27
27
|
import { runWebSocketConnection, type GracefulCloseOptions, type TransportAdapter, type TransportHostConfig, TRANSPORT_ADMISSION_REJECTED_METRIC } from "./transport-host.js";
|
|
28
|
-
import { wireTelephonyOutboundPipeline } from "./outbound-playout-pipeline.js";
|
|
28
|
+
import { wireTelephonyOutboundPipeline, installTelephonyTurnRotation } from "./outbound-playout-pipeline.js";
|
|
29
29
|
import {
|
|
30
30
|
decodeStrictBase64,
|
|
31
31
|
nonNegativeInteger,
|
|
@@ -104,6 +104,8 @@ type TelnyxCodec = "PCMU" | "L16";
|
|
|
104
104
|
interface TelnyxConnectionState {
|
|
105
105
|
streamId: string;
|
|
106
106
|
contextId: string;
|
|
107
|
+
contextBase: string;
|
|
108
|
+
turnCounter: number;
|
|
107
109
|
inboundCodec: TelnyxCodec;
|
|
108
110
|
inboundSampleRateHz: number;
|
|
109
111
|
readonly outboundCodec: TelnyxCodec;
|
|
@@ -168,6 +170,8 @@ export async function createTelnyxMediaStreamServer(
|
|
|
168
170
|
createState: () => ({
|
|
169
171
|
streamId: "",
|
|
170
172
|
contextId: "",
|
|
173
|
+
contextBase: "",
|
|
174
|
+
turnCounter: 0,
|
|
171
175
|
inboundCodec: "PCMU",
|
|
172
176
|
inboundSampleRateHz: 8000,
|
|
173
177
|
outboundCodec: bidirectionalCodec,
|
|
@@ -290,6 +294,7 @@ export async function createTelnyxMediaStreamServer(
|
|
|
290
294
|
},
|
|
291
295
|
});
|
|
292
296
|
state.clearPlayout = outbound.clearPlayout;
|
|
297
|
+
installTelephonyTurnRotation(session, disposers, state);
|
|
293
298
|
gracefulCloseRegistry.set(socket, (deadlineMs) => outbound.drainAndClose(socket, deadlineMs));
|
|
294
299
|
disposers.push(() => gracefulCloseRegistry.delete(socket));
|
|
295
300
|
return (reason) => state.clearPlayout(reason);
|
|
@@ -315,6 +320,7 @@ export async function createTelnyxMediaStreamServer(
|
|
|
315
320
|
state.streamId = message.stream_id ?? start.stream_id ?? "";
|
|
316
321
|
if (!state.streamId) throw new Error("Telnyx start event is missing stream_id");
|
|
317
322
|
state.contextId = contextIdFn(start);
|
|
323
|
+
state.contextBase = state.contextId;
|
|
318
324
|
state.inboundCodec = format.codec;
|
|
319
325
|
state.inboundSampleRateHz = format.sampleRateHz;
|
|
320
326
|
state.started = true;
|
package/src/transport-host.ts
CHANGED
|
@@ -30,6 +30,15 @@ export interface TransportAdmissionOptions {
|
|
|
30
30
|
readonly maxConcurrentSessions?: number;
|
|
31
31
|
readonly maxConcurrentSessionsScope?: "path" | "server";
|
|
32
32
|
readonly onAdmissionRejected?: () => void;
|
|
33
|
+
/**
|
|
34
|
+
* Authorize an inbound connection before the WebSocket upgrade completes. Return
|
|
35
|
+
* false (or throw) to reject with 4401. Voice endpoints are unauthenticated by
|
|
36
|
+
* default and each connection incurs provider cost and can attach to a live
|
|
37
|
+
* session — set this (shared secret / bearer / Twilio signature) before exposing
|
|
38
|
+
* the endpoint. Receives the raw upgrade request (headers + URL) so it can read
|
|
39
|
+
* an `Authorization` header or a `?token=` query param.
|
|
40
|
+
*/
|
|
41
|
+
readonly authorize?: (request: IncomingMessage) => boolean | Promise<boolean>;
|
|
33
42
|
}
|
|
34
43
|
|
|
35
44
|
export const TRANSPORT_ADMISSION_REJECTED_METRIC = "transport.admission_rejected";
|
|
@@ -39,9 +48,11 @@ export function rejectWebSocketAdmission(
|
|
|
39
48
|
request: IncomingMessage,
|
|
40
49
|
socket: Socket,
|
|
41
50
|
head: Buffer,
|
|
51
|
+
code = 1013,
|
|
52
|
+
reason = "try again later",
|
|
42
53
|
): void {
|
|
43
54
|
wsServer.handleUpgrade(request, socket, head, (websocket) => {
|
|
44
|
-
websocket.close(
|
|
55
|
+
websocket.close(code, reason);
|
|
45
56
|
});
|
|
46
57
|
}
|
|
47
58
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { validateTwilioSignature } from "./twilio-auth.js";
|
|
5
|
+
|
|
6
|
+
// Vector computed with the canonical HMAC-SHA1 algorithm (verified against
|
|
7
|
+
// node:crypto createHmac("sha1")) for these exact inputs — this pins that our
|
|
8
|
+
// Web Crypto implementation matches Twilio's documented signing scheme.
|
|
9
|
+
describe("validateTwilioSignature", () => {
|
|
10
|
+
const authToken = "12345";
|
|
11
|
+
const url = "https://mycompany.com/myapp.php?foo=1&bar=2";
|
|
12
|
+
const params = {
|
|
13
|
+
CallSid: "CA1234567890ABCDE",
|
|
14
|
+
Caller: "+14158675310",
|
|
15
|
+
Digits: "1234",
|
|
16
|
+
From: "+14158675310",
|
|
17
|
+
To: "+18005551212",
|
|
18
|
+
};
|
|
19
|
+
const validSignature = "GvWf1cFY/Q7PnoempGyD5oXAezc=";
|
|
20
|
+
|
|
21
|
+
it("accepts a correct signature", async () => {
|
|
22
|
+
expect(await validateTwilioSignature({ authToken, signature: validSignature, url, params })).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("rejects a tampered signature", async () => {
|
|
26
|
+
expect(await validateTwilioSignature({ authToken, signature: "AAAA" + validSignature.slice(4), url, params })).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("rejects a wrong auth token", async () => {
|
|
30
|
+
expect(await validateTwilioSignature({ authToken: "wrong", signature: validSignature, url, params })).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("rejects a missing signature", async () => {
|
|
34
|
+
expect(await validateTwilioSignature({ authToken, signature: null, url, params })).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// Twilio request-signature validation (X-Twilio-Signature). Runtime-neutral —
|
|
4
|
+
// uses Web Crypto (HMAC-SHA1), so it works on Node and Cloudflare Workers. Twilio
|
|
5
|
+
// signs each webhook: signature = base64(HMAC-SHA1(authToken, fullUrl + concat of
|
|
6
|
+
// POST params sorted by key, each as key+value)). Reference:
|
|
7
|
+
// https://www.twilio.com/docs/usage/security#validating-requests
|
|
8
|
+
//
|
|
9
|
+
// Plug into the transport `authorize` hook, or call directly in a webhook handler.
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Validate a Twilio request signature. `params` are the POST form fields (empty
|
|
13
|
+
* for a bare GET / WS upgrade). Constant-time compares the computed signature
|
|
14
|
+
* against the provided one. Returns false on any mismatch or malformed input.
|
|
15
|
+
*/
|
|
16
|
+
export async function validateTwilioSignature(args: {
|
|
17
|
+
readonly authToken: string;
|
|
18
|
+
readonly signature: string | null | undefined;
|
|
19
|
+
readonly url: string;
|
|
20
|
+
readonly params?: Record<string, string>;
|
|
21
|
+
}): Promise<boolean> {
|
|
22
|
+
const { authToken, signature, url } = args;
|
|
23
|
+
if (!authToken || !signature) return false;
|
|
24
|
+
|
|
25
|
+
const params = args.params ?? {};
|
|
26
|
+
let data = url;
|
|
27
|
+
for (const key of Object.keys(params).sort()) {
|
|
28
|
+
data += key + params[key];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const key = await crypto.subtle.importKey(
|
|
32
|
+
"raw",
|
|
33
|
+
new TextEncoder().encode(authToken),
|
|
34
|
+
{ name: "HMAC", hash: "SHA-1" },
|
|
35
|
+
false,
|
|
36
|
+
["sign"],
|
|
37
|
+
);
|
|
38
|
+
const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(data));
|
|
39
|
+
const expected = base64FromBytes(new Uint8Array(mac));
|
|
40
|
+
return timingSafeEqual(expected, signature);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function base64FromBytes(bytes: Uint8Array): string {
|
|
44
|
+
let binary = "";
|
|
45
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
46
|
+
// btoa exists on Workers and modern Node globals.
|
|
47
|
+
return btoa(binary);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function timingSafeEqual(a: string, b: string): boolean {
|
|
51
|
+
if (a.length !== b.length) return false;
|
|
52
|
+
let diff = 0;
|
|
53
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
54
|
+
return diff === 0;
|
|
55
|
+
}
|
package/src/twilio.ts
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
} from "./json-message.js";
|
|
23
23
|
import { createRoutedWebSocketServer } from "./websocket-upgrade.js";
|
|
24
24
|
import { runWebSocketConnection, type GracefulCloseOptions, type TransportAdapter, type TransportHostConfig, TRANSPORT_ADMISSION_REJECTED_METRIC } from "./transport-host.js";
|
|
25
|
-
import { wireTelephonyOutboundPipeline } from "./outbound-playout-pipeline.js";
|
|
25
|
+
import { wireTelephonyOutboundPipeline, installTelephonyTurnRotation } from "./outbound-playout-pipeline.js";
|
|
26
26
|
import {
|
|
27
27
|
decodeStrictBase64,
|
|
28
28
|
nonNegativeInteger,
|
|
@@ -90,6 +90,8 @@ interface TwilioMediaMessage {
|
|
|
90
90
|
interface TwilioConnectionState {
|
|
91
91
|
streamSid: string;
|
|
92
92
|
contextId: string;
|
|
93
|
+
contextBase: string;
|
|
94
|
+
turnCounter: number;
|
|
93
95
|
started: boolean;
|
|
94
96
|
stopped: boolean;
|
|
95
97
|
lastInboundSequenceNumber: number | null;
|
|
@@ -148,6 +150,8 @@ export async function createTwilioMediaStreamServer(
|
|
|
148
150
|
createState: () => ({
|
|
149
151
|
streamSid: "",
|
|
150
152
|
contextId: "",
|
|
153
|
+
contextBase: "",
|
|
154
|
+
turnCounter: 0,
|
|
151
155
|
started: false,
|
|
152
156
|
stopped: false,
|
|
153
157
|
lastInboundSequenceNumber: null,
|
|
@@ -277,6 +281,7 @@ export async function createTwilioMediaStreamServer(
|
|
|
277
281
|
},
|
|
278
282
|
});
|
|
279
283
|
state.clearPlayout = outbound.clearPlayout;
|
|
284
|
+
installTelephonyTurnRotation(session, disposers, state);
|
|
280
285
|
gracefulCloseRegistry.set(socket, (deadlineMs) => outbound.drainAndClose(socket, deadlineMs));
|
|
281
286
|
disposers.push(() => gracefulCloseRegistry.delete(socket));
|
|
282
287
|
return (reason) => state.clearPlayout(reason);
|
|
@@ -296,6 +301,7 @@ export async function createTwilioMediaStreamServer(
|
|
|
296
301
|
state.streamSid = start.streamSid ?? message.streamSid ?? "";
|
|
297
302
|
if (!state.streamSid) throw new Error("Twilio start event is missing streamSid");
|
|
298
303
|
state.contextId = contextIdFn(start);
|
|
304
|
+
state.contextBase = state.contextId;
|
|
299
305
|
state.started = true;
|
|
300
306
|
return;
|
|
301
307
|
}
|
package/src/websocket-upgrade.ts
CHANGED
|
@@ -68,7 +68,7 @@ export function createRoutedWebSocketServer(
|
|
|
68
68
|
perMessageDeflate: false,
|
|
69
69
|
});
|
|
70
70
|
const router = getOrCreateRouter(httpServer);
|
|
71
|
-
const
|
|
71
|
+
const accept = (request: IncomingMessage, socket: Socket, head: Buffer): void => {
|
|
72
72
|
const maxConcurrentSessions = admission?.maxConcurrentSessions;
|
|
73
73
|
const scope = admission?.maxConcurrentSessionsScope ?? "path";
|
|
74
74
|
const activeSessions = scope === "server"
|
|
@@ -86,6 +86,25 @@ export function createRoutedWebSocketServer(
|
|
|
86
86
|
wsServer.emit("connection", websocket, request);
|
|
87
87
|
});
|
|
88
88
|
};
|
|
89
|
+
const handler: UpgradeHandler = (request, socket, head) => {
|
|
90
|
+
if (!admission?.authorize) {
|
|
91
|
+
accept(request, socket, head);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
// Authorize before completing the upgrade; reject with 4401 on failure/throw.
|
|
95
|
+
void Promise.resolve()
|
|
96
|
+
.then(() => admission.authorize!(request))
|
|
97
|
+
.then((ok) => {
|
|
98
|
+
if (ok) {
|
|
99
|
+
accept(request, socket, head);
|
|
100
|
+
} else {
|
|
101
|
+
rejectWebSocketAdmission(wsServer, request, socket, head, 4401, "unauthorized");
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
.catch(() => {
|
|
105
|
+
rejectWebSocketAdmission(wsServer, request, socket, head, 4401, "unauthorized");
|
|
106
|
+
});
|
|
107
|
+
};
|
|
89
108
|
router.handlers.set(path, handler);
|
|
90
109
|
router.servers.add(wsServer);
|
|
91
110
|
return {
|