@kuralle-syrinx/server-websocket 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 +31 -0
- package/src/admission-control.test.ts +247 -0
- package/src/browser-opus.test.ts +41 -0
- package/src/browser-opus.ts +59 -0
- package/src/browser-pacing.test.ts +440 -0
- package/src/edge-twilio.test.ts +215 -0
- package/src/edge-twilio.ts +285 -0
- package/src/edge.test.ts +272 -0
- package/src/edge.ts +652 -0
- package/src/graceful-drain.test.ts +306 -0
- package/src/inbound-audio.ts +102 -0
- package/src/index.test.ts +2071 -0
- package/src/index.ts +891 -0
- package/src/json-message.ts +39 -0
- package/src/outbound-playout-pipeline.test.ts +208 -0
- package/src/outbound-playout-pipeline.ts +167 -0
- package/src/paced-playout.test.ts +247 -0
- package/src/paced-playout.ts +161 -0
- package/src/playout-progress.test.ts +56 -0
- package/src/playout-progress.ts +67 -0
- package/src/session-store.test.ts +274 -0
- package/src/session-store.ts +123 -0
- package/src/smartpbx.test.ts +967 -0
- package/src/smartpbx.ts +531 -0
- package/src/telnyx.test.ts +1413 -0
- package/src/telnyx.ts +708 -0
- package/src/test-helpers.ts +264 -0
- package/src/transport-helpers.ts +78 -0
- package/src/transport-host.test.ts +51 -0
- package/src/transport-host.ts +213 -0
- package/src/turn-metrics.test.ts +327 -0
- package/src/turn-metrics.ts +193 -0
- package/src/twilio.test.ts +1275 -0
- package/src/twilio.ts +599 -0
- package/src/websocket-close.test.ts +63 -0
- package/src/websocket-close.ts +68 -0
- package/src/websocket-lifecycle.test.ts +49 -0
- package/src/websocket-lifecycle.ts +105 -0
- package/src/websocket-upgrade.ts +102 -0
- package/tsconfig.json +22 -0
- package/vitest.config.ts +7 -0
package/src/twilio.ts
ADDED
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import type { IncomingMessage } from "node:http";
|
|
4
|
+
import { createServer, type Server as HttpServer } from "node:http";
|
|
5
|
+
import { WebSocket, WebSocketServer, type RawData } from "ws";
|
|
6
|
+
import { Route, type VoiceAgentSession } from "@kuralle-syrinx/core";
|
|
7
|
+
import {
|
|
8
|
+
decodeMuLawToPcm16,
|
|
9
|
+
encodePcm16ToMuLaw,
|
|
10
|
+
pcm16BytesToSamples,
|
|
11
|
+
pcm16SamplesToBytes,
|
|
12
|
+
resamplePcm16Streaming,
|
|
13
|
+
type StreamingPcm16Resampler,
|
|
14
|
+
} from "@kuralle-syrinx/core/audio";
|
|
15
|
+
import { sendJsonCapped } from "./websocket-close.js";
|
|
16
|
+
import {
|
|
17
|
+
optionalRecord,
|
|
18
|
+
optionalString,
|
|
19
|
+
optionalStringOrNumber,
|
|
20
|
+
parseJsonRecord,
|
|
21
|
+
requiredString,
|
|
22
|
+
} from "./json-message.js";
|
|
23
|
+
import { createRoutedWebSocketServer } from "./websocket-upgrade.js";
|
|
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";
|
|
26
|
+
import {
|
|
27
|
+
decodeStrictBase64,
|
|
28
|
+
nonNegativeInteger,
|
|
29
|
+
numberFromString,
|
|
30
|
+
optionalNonNegativeIntegerString,
|
|
31
|
+
optionalPositiveIntegerString,
|
|
32
|
+
positiveInteger,
|
|
33
|
+
rawDataToText,
|
|
34
|
+
} from "./transport-helpers.js";
|
|
35
|
+
|
|
36
|
+
export interface TwilioMediaStreamServerOptions {
|
|
37
|
+
readonly server?: HttpServer;
|
|
38
|
+
readonly port?: number;
|
|
39
|
+
readonly host?: string;
|
|
40
|
+
readonly path?: string;
|
|
41
|
+
readonly createSession: (request: IncomingMessage) => VoiceAgentSession | Promise<VoiceAgentSession>;
|
|
42
|
+
readonly contextId?: (start: TwilioStartPayload) => string;
|
|
43
|
+
readonly inputSampleRateHz?: number;
|
|
44
|
+
readonly outputSampleRateHz?: number;
|
|
45
|
+
readonly twilioSampleRateHz?: number;
|
|
46
|
+
readonly outboundFrameDurationMs?: number;
|
|
47
|
+
readonly maxQueuedOutputAudioMs?: number;
|
|
48
|
+
readonly heartbeatIntervalMs?: number;
|
|
49
|
+
readonly startupTimeoutMs?: number;
|
|
50
|
+
readonly maxSessionDurationMs?: number;
|
|
51
|
+
readonly maxBufferedAmountBytes?: number;
|
|
52
|
+
readonly maxInboundMessageBytes?: number;
|
|
53
|
+
readonly maxConcurrentSessions?: number;
|
|
54
|
+
readonly maxConcurrentSessionsScope?: "path" | "server";
|
|
55
|
+
readonly onTransportMetric?: (name: string) => void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface TwilioMediaStreamServer {
|
|
59
|
+
readonly httpServer: HttpServer;
|
|
60
|
+
readonly wsServer: WebSocketServer;
|
|
61
|
+
address(): ReturnType<HttpServer["address"]>;
|
|
62
|
+
close(opts?: GracefulCloseOptions): Promise<void>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface TwilioStartPayload {
|
|
66
|
+
readonly streamSid?: string;
|
|
67
|
+
readonly callSid?: string;
|
|
68
|
+
readonly mediaFormat?: {
|
|
69
|
+
readonly encoding?: string;
|
|
70
|
+
readonly sampleRate?: number | string;
|
|
71
|
+
readonly channels?: number | string;
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface TwilioMediaMessage {
|
|
76
|
+
readonly event?: string;
|
|
77
|
+
readonly streamSid?: string;
|
|
78
|
+
readonly sequenceNumber?: string;
|
|
79
|
+
readonly start?: TwilioStartPayload;
|
|
80
|
+
readonly media?: {
|
|
81
|
+
readonly payload?: string;
|
|
82
|
+
readonly track?: string;
|
|
83
|
+
readonly chunk?: string;
|
|
84
|
+
readonly timestamp?: string;
|
|
85
|
+
};
|
|
86
|
+
readonly mark?: { readonly name?: string };
|
|
87
|
+
readonly dtmf?: { readonly digit?: string };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface TwilioConnectionState {
|
|
91
|
+
streamSid: string;
|
|
92
|
+
contextId: string;
|
|
93
|
+
started: boolean;
|
|
94
|
+
stopped: boolean;
|
|
95
|
+
lastInboundSequenceNumber: number | null;
|
|
96
|
+
lastInboundMediaChunk: number | null;
|
|
97
|
+
lastInboundMediaTimestampMs: number | null;
|
|
98
|
+
outboundSequence: number;
|
|
99
|
+
pendingMarks: Set<string>;
|
|
100
|
+
pendingEndMarkName: string;
|
|
101
|
+
onPlaybackMarkReceived?: () => void;
|
|
102
|
+
clearPlayout: (reason: string) => void;
|
|
103
|
+
readonly streamingResamplers: Map<string, StreamingPcm16Resampler>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const DEFAULT_ENGINE_SAMPLE_RATE_HZ = 16000;
|
|
107
|
+
const DEFAULT_TWILIO_SAMPLE_RATE_HZ = 8000;
|
|
108
|
+
const DEFAULT_OUTBOUND_FRAME_DURATION_MS = 20;
|
|
109
|
+
// Bounds queue memory + sanity only. TTS providers legitimately burst entire
|
|
110
|
+
// replies ahead of realtime (Deepgram); the pacer drains at wire rate and
|
|
111
|
+
// barge-in clear is O(1), so a generous cap is safe. A tiny cap turns every
|
|
112
|
+
// long reply into dropped audio.
|
|
113
|
+
const DEFAULT_MAX_QUEUED_OUTPUT_AUDIO_MS = 60_000;
|
|
114
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000;
|
|
115
|
+
const DEFAULT_STARTUP_TIMEOUT_MS = 15_000;
|
|
116
|
+
const DEFAULT_MAX_SESSION_DURATION_MS = 30 * 60_000;
|
|
117
|
+
const DEFAULT_MAX_BUFFERED_AMOUNT_BYTES = 8 * 1024 * 1024;
|
|
118
|
+
const DEFAULT_MAX_INBOUND_MESSAGE_BYTES = 256 * 1024;
|
|
119
|
+
|
|
120
|
+
export async function createTwilioMediaStreamServer(
|
|
121
|
+
options: TwilioMediaStreamServerOptions,
|
|
122
|
+
): Promise<TwilioMediaStreamServer> {
|
|
123
|
+
const ownsHttpServer = !options.server;
|
|
124
|
+
const httpServer = options.server ?? createServer();
|
|
125
|
+
const routedWebSocket = createRoutedWebSocketServer(httpServer, options.path ?? "/twilio", {
|
|
126
|
+
maxConcurrentSessions: positiveInteger(options.maxConcurrentSessions) ?? undefined,
|
|
127
|
+
maxConcurrentSessionsScope: options.maxConcurrentSessionsScope,
|
|
128
|
+
onAdmissionRejected: () => options.onTransportMetric?.(TRANSPORT_ADMISSION_REJECTED_METRIC),
|
|
129
|
+
});
|
|
130
|
+
const wsServer = routedWebSocket.wsServer;
|
|
131
|
+
const sessions = new Set<VoiceAgentSession>();
|
|
132
|
+
const inputSampleRateHz = positiveInteger(options.inputSampleRateHz) ?? DEFAULT_ENGINE_SAMPLE_RATE_HZ;
|
|
133
|
+
const twilioSampleRateHz = positiveInteger(options.twilioSampleRateHz) ?? DEFAULT_TWILIO_SAMPLE_RATE_HZ;
|
|
134
|
+
const outboundFrameDurationMs = positiveInteger(options.outboundFrameDurationMs) ?? DEFAULT_OUTBOUND_FRAME_DURATION_MS;
|
|
135
|
+
const maxQueuedOutputAudioMs = positiveInteger(options.maxQueuedOutputAudioMs) ?? DEFAULT_MAX_QUEUED_OUTPUT_AUDIO_MS;
|
|
136
|
+
const frameBytes = Math.max(1, Math.round((twilioSampleRateHz * outboundFrameDurationMs) / 1000));
|
|
137
|
+
const hostConfig: TransportHostConfig = {
|
|
138
|
+
heartbeatIntervalMs: nonNegativeInteger(options.heartbeatIntervalMs) ?? DEFAULT_HEARTBEAT_INTERVAL_MS,
|
|
139
|
+
startupTimeoutMs: nonNegativeInteger(options.startupTimeoutMs) ?? DEFAULT_STARTUP_TIMEOUT_MS,
|
|
140
|
+
maxSessionDurationMs: nonNegativeInteger(options.maxSessionDurationMs) ?? DEFAULT_MAX_SESSION_DURATION_MS,
|
|
141
|
+
maxBufferedAmountBytes: positiveInteger(options.maxBufferedAmountBytes) ?? DEFAULT_MAX_BUFFERED_AMOUNT_BYTES,
|
|
142
|
+
maxInboundMessageBytes: positiveInteger(options.maxInboundMessageBytes) ?? DEFAULT_MAX_INBOUND_MESSAGE_BYTES,
|
|
143
|
+
};
|
|
144
|
+
const contextIdFn = options.contextId ?? defaultTwilioContextId;
|
|
145
|
+
const gracefulCloseRegistry = new Map<WebSocket, (deadlineMs: number) => Promise<void>>();
|
|
146
|
+
|
|
147
|
+
const adapter: TransportAdapter<TwilioConnectionState> = {
|
|
148
|
+
createState: () => ({
|
|
149
|
+
streamSid: "",
|
|
150
|
+
contextId: "",
|
|
151
|
+
started: false,
|
|
152
|
+
stopped: false,
|
|
153
|
+
lastInboundSequenceNumber: null,
|
|
154
|
+
lastInboundMediaChunk: null,
|
|
155
|
+
lastInboundMediaTimestampMs: null,
|
|
156
|
+
outboundSequence: 0,
|
|
157
|
+
pendingMarks: new Set(),
|
|
158
|
+
pendingEndMarkName: "",
|
|
159
|
+
clearPlayout: () => undefined,
|
|
160
|
+
streamingResamplers: new Map(),
|
|
161
|
+
}),
|
|
162
|
+
|
|
163
|
+
async acquireSession({ request, shouldAbort, onSessionCreated }) {
|
|
164
|
+
const sess = await options.createSession(request);
|
|
165
|
+
onSessionCreated(sess);
|
|
166
|
+
if (shouldAbort()) {
|
|
167
|
+
await sess.close().catch(() => undefined);
|
|
168
|
+
throw new Error("Twilio websocket session startup aborted");
|
|
169
|
+
}
|
|
170
|
+
sessions.add(sess);
|
|
171
|
+
await sess.start();
|
|
172
|
+
if (shouldAbort()) {
|
|
173
|
+
sessions.delete(sess);
|
|
174
|
+
await sess.close().catch(() => undefined);
|
|
175
|
+
throw new Error("Twilio websocket session startup aborted");
|
|
176
|
+
}
|
|
177
|
+
return { session: sess, resumed: false };
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
wireSession(session, socket, state, disposers) {
|
|
181
|
+
const sendPendingEndMark = (): void => {
|
|
182
|
+
if (state.stopped || !state.streamSid || !state.pendingEndMarkName || state.pendingMarks.size > 0) return;
|
|
183
|
+
const markName = state.pendingEndMarkName;
|
|
184
|
+
const sent = sendTwilioJson(socket, {
|
|
185
|
+
event: "mark",
|
|
186
|
+
streamSid: state.streamSid,
|
|
187
|
+
mark: { name: markName },
|
|
188
|
+
}, hostConfig.maxBufferedAmountBytes);
|
|
189
|
+
if (sent) state.pendingEndMarkName = "";
|
|
190
|
+
};
|
|
191
|
+
state.onPlaybackMarkReceived = sendPendingEndMark;
|
|
192
|
+
|
|
193
|
+
const outbound = wireTelephonyOutboundPipeline({
|
|
194
|
+
session,
|
|
195
|
+
socket,
|
|
196
|
+
disposers,
|
|
197
|
+
outboundFrameDurationMs,
|
|
198
|
+
maxQueuedOutputAudioMs,
|
|
199
|
+
callbacks: {
|
|
200
|
+
carrierLabel: "twilio",
|
|
201
|
+
getContextId: () => state.contextId,
|
|
202
|
+
isActive: () => !state.stopped && !!state.streamSid,
|
|
203
|
+
encodeFrames: (audio, sourceSampleRateHz, contextId) => {
|
|
204
|
+
const samples = pcm16BytesToSamples(audio);
|
|
205
|
+
const resampled = resamplePcm16Streaming(state.streamingResamplers, samples, sourceSampleRateHz, twilioSampleRateHz);
|
|
206
|
+
const encoded = encodePcm16ToMuLaw(resampled);
|
|
207
|
+
const frames = [];
|
|
208
|
+
for (let offset = 0; offset < encoded.byteLength; offset += frameBytes) {
|
|
209
|
+
const frame = encoded.subarray(offset, Math.min(encoded.byteLength, offset + frameBytes));
|
|
210
|
+
frames.push({
|
|
211
|
+
contextId,
|
|
212
|
+
send: () => {
|
|
213
|
+
if (state.stopped) return false;
|
|
214
|
+
return sendTwilioJson(socket, {
|
|
215
|
+
event: "media",
|
|
216
|
+
streamSid: state.streamSid,
|
|
217
|
+
media: { payload: Buffer.from(frame).toString("base64") },
|
|
218
|
+
}, hostConfig.maxBufferedAmountBytes);
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
state.outboundSequence += 1;
|
|
223
|
+
const markName = `${contextId}:${String(state.outboundSequence)}`;
|
|
224
|
+
const finalFrame = frames.at(-1);
|
|
225
|
+
if (finalFrame) {
|
|
226
|
+
frames[frames.length - 1] = {
|
|
227
|
+
contextId,
|
|
228
|
+
send: finalFrame.send,
|
|
229
|
+
afterSend: () => {
|
|
230
|
+
if (state.stopped) return;
|
|
231
|
+
const sent = sendTwilioJson(socket, {
|
|
232
|
+
event: "mark",
|
|
233
|
+
streamSid: state.streamSid,
|
|
234
|
+
mark: { name: markName },
|
|
235
|
+
}, hostConfig.maxBufferedAmountBytes);
|
|
236
|
+
if (sent) {
|
|
237
|
+
state.pendingMarks.add(markName);
|
|
238
|
+
session.bus.push(Route.Background, {
|
|
239
|
+
kind: "metric.conversation",
|
|
240
|
+
contextId,
|
|
241
|
+
timestampMs: Date.now(),
|
|
242
|
+
name: "twilio.mark_sent",
|
|
243
|
+
value: markName,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
return frames;
|
|
250
|
+
},
|
|
251
|
+
onInterrupt: (contextId) => {
|
|
252
|
+
state.pendingMarks.clear();
|
|
253
|
+
state.pendingEndMarkName = "";
|
|
254
|
+
const sent = !state.stopped && !!state.streamSid && sendTwilioJson(socket, {
|
|
255
|
+
event: "clear",
|
|
256
|
+
streamSid: state.streamSid,
|
|
257
|
+
}, hostConfig.maxBufferedAmountBytes);
|
|
258
|
+
if (sent) {
|
|
259
|
+
session.bus.push(Route.Background, {
|
|
260
|
+
kind: "metric.conversation",
|
|
261
|
+
contextId,
|
|
262
|
+
timestampMs: Date.now(),
|
|
263
|
+
name: "twilio.clear_sent",
|
|
264
|
+
value: "1",
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
onDrain: (contextId, playout, progress) => {
|
|
269
|
+
playout.enqueueControl(() => {
|
|
270
|
+
if (state.stopped || !state.streamSid) return;
|
|
271
|
+
progress.complete(contextId);
|
|
272
|
+
state.pendingEndMarkName = `${contextId}:end`;
|
|
273
|
+
sendPendingEndMark();
|
|
274
|
+
});
|
|
275
|
+
},
|
|
276
|
+
onStop: () => { state.stopped = true; },
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
state.clearPlayout = outbound.clearPlayout;
|
|
280
|
+
gracefulCloseRegistry.set(socket, (deadlineMs) => outbound.drainAndClose(socket, deadlineMs));
|
|
281
|
+
disposers.push(() => gracefulCloseRegistry.delete(socket));
|
|
282
|
+
return (reason) => state.clearPlayout(reason);
|
|
283
|
+
},
|
|
284
|
+
|
|
285
|
+
processMessage(data, isBinary, session, state) {
|
|
286
|
+
if (isBinary) throw new Error("Twilio Media Streams messages must be JSON text frames");
|
|
287
|
+
const message = parseTwilioMessage(parseJsonRecord(rawDataToText(data), "Twilio Media Streams message"));
|
|
288
|
+
const event = message.event;
|
|
289
|
+
rememberTwilioSequenceNumber(session, state, message.sequenceNumber);
|
|
290
|
+
|
|
291
|
+
if (event === "connected") return;
|
|
292
|
+
if (event === "start") {
|
|
293
|
+
if (state.stopped) throw new Error("Twilio start event received after stream stop");
|
|
294
|
+
const start = message.start ?? {};
|
|
295
|
+
validateTwilioStart(start, twilioSampleRateHz);
|
|
296
|
+
state.streamSid = start.streamSid ?? message.streamSid ?? "";
|
|
297
|
+
if (!state.streamSid) throw new Error("Twilio start event is missing streamSid");
|
|
298
|
+
state.contextId = contextIdFn(start);
|
|
299
|
+
state.started = true;
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (event === "media") {
|
|
303
|
+
if (state.stopped) return;
|
|
304
|
+
if (!state.started || !state.contextId) throw new Error("Twilio media event received before a valid start event");
|
|
305
|
+
const payload = message.media?.payload;
|
|
306
|
+
if (!payload) throw new Error("Twilio media event is missing media.payload");
|
|
307
|
+
rememberTwilioMediaChunk(session, state, message.media?.chunk);
|
|
308
|
+
const ulaw = decodeStrictBase64(payload, "media.payload");
|
|
309
|
+
const pcm8k = decodeMuLawToPcm16(ulaw);
|
|
310
|
+
rememberTwilioMediaTimestamp(session, state, message.media?.timestamp, pcm8k.length, twilioSampleRateHz);
|
|
311
|
+
const pcm16k = resamplePcm16Streaming(state.streamingResamplers, pcm8k, twilioSampleRateHz, inputSampleRateHz);
|
|
312
|
+
session.bus.push(Route.Main, {
|
|
313
|
+
kind: "user.audio_received",
|
|
314
|
+
contextId: state.contextId,
|
|
315
|
+
timestampMs: Date.now(),
|
|
316
|
+
audio: pcm16SamplesToBytes(pcm16k),
|
|
317
|
+
});
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (event === "stop") {
|
|
321
|
+
state.stopped = true;
|
|
322
|
+
state.started = false;
|
|
323
|
+
state.pendingMarks.clear();
|
|
324
|
+
state.clearPlayout("stop");
|
|
325
|
+
session.close().catch(() => undefined);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (event === "mark") {
|
|
329
|
+
if (state.stopped) return;
|
|
330
|
+
const markName = message.mark?.name ?? "";
|
|
331
|
+
if (markName) state.pendingMarks.delete(markName);
|
|
332
|
+
state.onPlaybackMarkReceived?.();
|
|
333
|
+
session.bus.push(Route.Background, {
|
|
334
|
+
kind: "metric.conversation",
|
|
335
|
+
contextId: state.contextId,
|
|
336
|
+
timestampMs: Date.now(),
|
|
337
|
+
name: "twilio.mark_received",
|
|
338
|
+
value: markName,
|
|
339
|
+
});
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (event === "dtmf") {
|
|
343
|
+
if (state.stopped) return;
|
|
344
|
+
if (!state.started || !state.contextId) {
|
|
345
|
+
session.bus.push(Route.Background, {
|
|
346
|
+
kind: "metric.conversation",
|
|
347
|
+
contextId: "",
|
|
348
|
+
timestampMs: Date.now(),
|
|
349
|
+
name: "twilio.dtmf.before_start",
|
|
350
|
+
value: message.dtmf?.digit ?? "",
|
|
351
|
+
});
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const rawDigit = message.dtmf?.digit ?? "";
|
|
355
|
+
const digit = parseDtmfDigit(rawDigit);
|
|
356
|
+
if (!digit) {
|
|
357
|
+
session.bus.push(Route.Background, {
|
|
358
|
+
kind: "metric.conversation",
|
|
359
|
+
contextId: state.contextId,
|
|
360
|
+
timestampMs: Date.now(),
|
|
361
|
+
name: "dtmf.unparsed",
|
|
362
|
+
value: rawDigit,
|
|
363
|
+
});
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
session.bus.push(Route.Critical, {
|
|
367
|
+
kind: "dtmf.received",
|
|
368
|
+
contextId: state.contextId,
|
|
369
|
+
timestampMs: Date.now(),
|
|
370
|
+
digit,
|
|
371
|
+
provider: "twilio",
|
|
372
|
+
rawDigit,
|
|
373
|
+
});
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
throw new Error(`Unsupported Twilio Media Streams event: ${String(event)}`);
|
|
377
|
+
},
|
|
378
|
+
|
|
379
|
+
onDisconnect(session) {
|
|
380
|
+
sessions.delete(session);
|
|
381
|
+
void session.close().catch(() => undefined);
|
|
382
|
+
},
|
|
383
|
+
|
|
384
|
+
onStartupTimeout(_state, session) {
|
|
385
|
+
sessions.delete(session);
|
|
386
|
+
void session.close().catch(() => undefined);
|
|
387
|
+
},
|
|
388
|
+
|
|
389
|
+
sendError(socket, state, message) {
|
|
390
|
+
sendTwilioError(socket, state.streamSid, message, hostConfig.maxBufferedAmountBytes);
|
|
391
|
+
},
|
|
392
|
+
|
|
393
|
+
sendStartupError(socket, state, err) {
|
|
394
|
+
sendTwilioError(socket, state.streamSid, err instanceof Error ? err.message : String(err), hostConfig.maxBufferedAmountBytes);
|
|
395
|
+
},
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
wsServer.on("connection", (socket, request) => {
|
|
399
|
+
void runWebSocketConnection(socket, request, hostConfig, adapter);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
if (ownsHttpServer || typeof options.port === "number") {
|
|
403
|
+
await new Promise<void>((resolveListen, reject) => {
|
|
404
|
+
httpServer.once("error", reject);
|
|
405
|
+
httpServer.listen(options.port ?? 0, options.host, () => {
|
|
406
|
+
httpServer.off("error", reject);
|
|
407
|
+
resolveListen();
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
let closing = false;
|
|
413
|
+
return {
|
|
414
|
+
httpServer,
|
|
415
|
+
wsServer,
|
|
416
|
+
address: () => httpServer.address(),
|
|
417
|
+
close: async (opts) => {
|
|
418
|
+
if (closing) return;
|
|
419
|
+
closing = true;
|
|
420
|
+
if (opts?.graceful === true && gracefulCloseRegistry.size > 0) {
|
|
421
|
+
const deadline = Date.now() + (opts.drainDeadlineMs ?? 10_000);
|
|
422
|
+
await Promise.allSettled(
|
|
423
|
+
[...gracefulCloseRegistry.values()].map((fn) => fn(deadline)),
|
|
424
|
+
);
|
|
425
|
+
for (const client of wsServer.clients) {
|
|
426
|
+
if (client.readyState === WebSocket.OPEN) client.terminate();
|
|
427
|
+
}
|
|
428
|
+
} else {
|
|
429
|
+
for (const client of wsServer.clients) client.terminate();
|
|
430
|
+
}
|
|
431
|
+
gracefulCloseRegistry.clear();
|
|
432
|
+
for (const session of sessions) await session.close().catch(() => undefined);
|
|
433
|
+
await new Promise<void>((resolveClose) => { wsServer.close(() => resolveClose()); });
|
|
434
|
+
routedWebSocket.detach();
|
|
435
|
+
if (ownsHttpServer || typeof options.port === "number") {
|
|
436
|
+
await new Promise<void>((resolveClose) => { httpServer.close(() => resolveClose()); });
|
|
437
|
+
}
|
|
438
|
+
},
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
type TwilioDtmfDigit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "*" | "#";
|
|
443
|
+
|
|
444
|
+
const DTMF_DIGITS = new Set<TwilioDtmfDigit>(["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "*", "#"]);
|
|
445
|
+
|
|
446
|
+
function parseDtmfDigit(raw: string): TwilioDtmfDigit | null {
|
|
447
|
+
const trimmed = raw.trim();
|
|
448
|
+
if (trimmed.length !== 1 || !DTMF_DIGITS.has(trimmed as TwilioDtmfDigit)) return null;
|
|
449
|
+
return trimmed as TwilioDtmfDigit;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function parseTwilioMessage(value: Record<string, unknown>): TwilioMediaMessage {
|
|
453
|
+
const start = optionalRecord(value.start, "Twilio start");
|
|
454
|
+
const media = optionalRecord(value.media, "Twilio media");
|
|
455
|
+
const mark = optionalRecord(value.mark, "Twilio mark");
|
|
456
|
+
const dtmf = optionalRecord(value.dtmf, "Twilio dtmf");
|
|
457
|
+
const mediaFormat = optionalRecord(start?.mediaFormat, "Twilio start.mediaFormat");
|
|
458
|
+
return {
|
|
459
|
+
event: requiredString(value.event, "Twilio event"),
|
|
460
|
+
streamSid: optionalString(value.streamSid, "Twilio streamSid"),
|
|
461
|
+
sequenceNumber: optionalString(value.sequenceNumber, "Twilio sequenceNumber"),
|
|
462
|
+
start: start
|
|
463
|
+
? {
|
|
464
|
+
streamSid: optionalString(start.streamSid, "Twilio start.streamSid"),
|
|
465
|
+
callSid: optionalString(start.callSid, "Twilio start.callSid"),
|
|
466
|
+
mediaFormat: mediaFormat
|
|
467
|
+
? {
|
|
468
|
+
encoding: optionalString(mediaFormat.encoding, "Twilio start.mediaFormat.encoding"),
|
|
469
|
+
sampleRate: optionalStringOrNumber(mediaFormat.sampleRate, "Twilio start.mediaFormat.sampleRate"),
|
|
470
|
+
channels: optionalStringOrNumber(mediaFormat.channels, "Twilio start.mediaFormat.channels"),
|
|
471
|
+
}
|
|
472
|
+
: undefined,
|
|
473
|
+
}
|
|
474
|
+
: undefined,
|
|
475
|
+
media: media
|
|
476
|
+
? {
|
|
477
|
+
payload: optionalString(media.payload, "Twilio media.payload"),
|
|
478
|
+
track: optionalString(media.track, "Twilio media.track"),
|
|
479
|
+
chunk: optionalString(media.chunk, "Twilio media.chunk"),
|
|
480
|
+
timestamp: optionalString(media.timestamp, "Twilio media.timestamp"),
|
|
481
|
+
}
|
|
482
|
+
: undefined,
|
|
483
|
+
mark: mark ? { name: optionalString(mark.name, "Twilio mark.name") } : undefined,
|
|
484
|
+
dtmf: dtmf ? { digit: optionalString(dtmf.digit, "Twilio dtmf.digit") } : undefined,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function rememberTwilioSequenceNumber(
|
|
489
|
+
session: VoiceAgentSession,
|
|
490
|
+
state: TwilioConnectionState,
|
|
491
|
+
sequenceValue: string | undefined,
|
|
492
|
+
): void {
|
|
493
|
+
const sequence = optionalPositiveIntegerString(sequenceValue, "Twilio sequenceNumber");
|
|
494
|
+
if (sequence === undefined) return;
|
|
495
|
+
const previous = state.lastInboundSequenceNumber;
|
|
496
|
+
if (previous !== null && sequence <= previous) {
|
|
497
|
+
throw new Error(`Twilio sequenceNumber must increase monotonically: ${String(previous)} -> ${String(sequence)}`);
|
|
498
|
+
}
|
|
499
|
+
if (previous !== null && sequence > previous + 1) {
|
|
500
|
+
session.bus.push(Route.Background, {
|
|
501
|
+
kind: "metric.conversation",
|
|
502
|
+
contextId: state.contextId,
|
|
503
|
+
timestampMs: Date.now(),
|
|
504
|
+
name: "twilio.sequence_gap",
|
|
505
|
+
value: JSON.stringify({ expected: previous + 1, actual: sequence, missed: sequence - previous - 1 }),
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
state.lastInboundSequenceNumber = sequence;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function rememberTwilioMediaChunk(
|
|
512
|
+
session: VoiceAgentSession,
|
|
513
|
+
state: TwilioConnectionState,
|
|
514
|
+
chunkValue: string | undefined,
|
|
515
|
+
): void {
|
|
516
|
+
const chunk = optionalPositiveIntegerString(chunkValue, "Twilio media.chunk");
|
|
517
|
+
if (chunk === undefined) return;
|
|
518
|
+
const previous = state.lastInboundMediaChunk;
|
|
519
|
+
if (previous !== null && chunk <= previous) {
|
|
520
|
+
throw new Error(`Twilio media.chunk must increase monotonically: ${String(previous)} -> ${String(chunk)}`);
|
|
521
|
+
}
|
|
522
|
+
if (previous !== null && chunk > previous + 1) {
|
|
523
|
+
session.bus.push(Route.Background, {
|
|
524
|
+
kind: "metric.conversation",
|
|
525
|
+
contextId: state.contextId,
|
|
526
|
+
timestampMs: Date.now(),
|
|
527
|
+
name: "twilio.media_chunk_gap",
|
|
528
|
+
value: JSON.stringify({ expected: previous + 1, actual: chunk, missed: chunk - previous - 1 }),
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
state.lastInboundMediaChunk = chunk;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function rememberTwilioMediaTimestamp(
|
|
535
|
+
session: VoiceAgentSession,
|
|
536
|
+
state: TwilioConnectionState,
|
|
537
|
+
timestampValue: string | undefined,
|
|
538
|
+
sampleCount: number,
|
|
539
|
+
sampleRateHz: number,
|
|
540
|
+
): void {
|
|
541
|
+
const timestampMs = optionalNonNegativeIntegerString(timestampValue, "Twilio media.timestamp");
|
|
542
|
+
if (timestampMs === undefined) return;
|
|
543
|
+
const previous = state.lastInboundMediaTimestampMs;
|
|
544
|
+
if (previous !== null && timestampMs < previous) {
|
|
545
|
+
session.bus.push(Route.Background, {
|
|
546
|
+
kind: "metric.conversation",
|
|
547
|
+
contextId: state.contextId,
|
|
548
|
+
timestampMs: Date.now(),
|
|
549
|
+
name: "twilio.media_timestamp_regression",
|
|
550
|
+
value: JSON.stringify({ previous, actual: timestampMs }),
|
|
551
|
+
});
|
|
552
|
+
} else if (previous !== null) {
|
|
553
|
+
const expected = previous + Math.round((sampleCount / sampleRateHz) * 1000);
|
|
554
|
+
if (timestampMs > expected) {
|
|
555
|
+
session.bus.push(Route.Background, {
|
|
556
|
+
kind: "metric.conversation",
|
|
557
|
+
contextId: state.contextId,
|
|
558
|
+
timestampMs: Date.now(),
|
|
559
|
+
name: "twilio.media_timestamp_gap",
|
|
560
|
+
value: JSON.stringify({ expected, actual: timestampMs, missedMs: timestampMs - expected }),
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
state.lastInboundMediaTimestampMs = timestampMs;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function validateTwilioStart(start: TwilioStartPayload, expectedSampleRateHz: number): void {
|
|
568
|
+
const format = start.mediaFormat;
|
|
569
|
+
if (!format) throw new Error("Twilio start event is missing mediaFormat");
|
|
570
|
+
const encoding = format.encoding?.trim().toLowerCase();
|
|
571
|
+
const validEncoding = encoding === "audio/x-mulaw" || encoding === "audio/mulaw"
|
|
572
|
+
|| encoding === "mulaw" || encoding === "mu-law" || encoding === "ulaw"
|
|
573
|
+
|| encoding === "pcmu" || encoding === "g711_ulaw";
|
|
574
|
+
if (!validEncoding) throw new Error(`Unsupported Twilio media encoding: ${format.encoding ?? "unknown"}`);
|
|
575
|
+
const sampleRate = numberFromString(format.sampleRate);
|
|
576
|
+
if (sampleRate !== expectedSampleRateHz) throw new Error(`Unsupported Twilio sample rate: ${String(format.sampleRate)}`);
|
|
577
|
+
const channels = numberFromString(format.channels);
|
|
578
|
+
if (channels !== 1) throw new Error(`Unsupported Twilio channel count: ${String(format.channels)}`);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function defaultTwilioContextId(start: TwilioStartPayload): string {
|
|
582
|
+
const callSid = start.callSid?.trim();
|
|
583
|
+
if (callSid) return `twilio-${callSid}`;
|
|
584
|
+
const streamSid = start.streamSid?.trim();
|
|
585
|
+
if (streamSid) return `twilio-${streamSid}`;
|
|
586
|
+
return `twilio-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function sendTwilioError(socket: WebSocket, streamSid: string, message: string, maxBufferedAmountBytes: number): void {
|
|
590
|
+
sendTwilioJson(socket, {
|
|
591
|
+
event: "syrinx_error",
|
|
592
|
+
streamSid: streamSid || undefined,
|
|
593
|
+
error: { component: "transport", category: "invalid_input", message },
|
|
594
|
+
}, maxBufferedAmountBytes);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function sendTwilioJson(socket: WebSocket, value: unknown, maxBufferedAmountBytes: number): boolean {
|
|
598
|
+
return sendJsonCapped(socket, value, maxBufferedAmountBytes);
|
|
599
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { WebSocket } from "ws";
|
|
5
|
+
import { sendJsonCapped } from "./websocket-close.js";
|
|
6
|
+
|
|
7
|
+
interface FakeSocket {
|
|
8
|
+
readyState: number;
|
|
9
|
+
bufferedAmount: number;
|
|
10
|
+
sent: string[];
|
|
11
|
+
closed: { code: number; reason: string } | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function makeSocket(overrides: Partial<FakeSocket> = {}): WebSocket {
|
|
15
|
+
const socket: FakeSocket & { send(d: string): void; close(c: number, r: string): void; once(): void } = {
|
|
16
|
+
readyState: WebSocket.OPEN,
|
|
17
|
+
bufferedAmount: 0,
|
|
18
|
+
sent: [],
|
|
19
|
+
closed: null,
|
|
20
|
+
...overrides,
|
|
21
|
+
send(data: string) { this.sent.push(data); },
|
|
22
|
+
close(code: number, reason: string) { this.closed = { code, reason }; this.readyState = WebSocket.CLOSING; },
|
|
23
|
+
once() { /* close-cleanup listener — unused in these paths */ },
|
|
24
|
+
};
|
|
25
|
+
return socket as unknown as WebSocket;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("sendJsonCapped", () => {
|
|
29
|
+
it("serializes and sends when OPEN and under the buffer cap", () => {
|
|
30
|
+
const socket = makeSocket();
|
|
31
|
+
const ok = sendJsonCapped(socket, { event: "media", n: 1 }, 1_000);
|
|
32
|
+
expect(ok).toBe(true);
|
|
33
|
+
expect((socket as unknown as FakeSocket).sent).toEqual([JSON.stringify({ event: "media", n: 1 })]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("sends nothing and returns false when the socket is not OPEN", () => {
|
|
37
|
+
const socket = makeSocket({ readyState: WebSocket.CONNECTING });
|
|
38
|
+
const ok = sendJsonCapped(socket, { event: "media" }, 1_000);
|
|
39
|
+
expect(ok).toBe(false);
|
|
40
|
+
expect((socket as unknown as FakeSocket).sent).toHaveLength(0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("closes 1013 and drops the message when the send would exceed the buffer cap", () => {
|
|
44
|
+
const payload = { event: "media", blob: "x".repeat(100) };
|
|
45
|
+
const cap = 50; // far below the serialized size
|
|
46
|
+
const socket = makeSocket({ bufferedAmount: 0 });
|
|
47
|
+
const ok = sendJsonCapped(socket, payload, cap);
|
|
48
|
+
expect(ok).toBe(false);
|
|
49
|
+
const fake = socket as unknown as FakeSocket;
|
|
50
|
+
expect(fake.sent).toHaveLength(0);
|
|
51
|
+
expect(fake.closed?.code).toBe(1013);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("accounts for already-buffered bytes against the cap", () => {
|
|
55
|
+
const data = JSON.stringify({ event: "media" });
|
|
56
|
+
const cap = Buffer.byteLength(data, "utf8") + 5;
|
|
57
|
+
// Already near the cap: adding this message tips it over.
|
|
58
|
+
const socket = makeSocket({ bufferedAmount: 10 });
|
|
59
|
+
const ok = sendJsonCapped(socket, { event: "media" }, cap);
|
|
60
|
+
expect(ok).toBe(false);
|
|
61
|
+
expect((socket as unknown as FakeSocket).closed?.code).toBe(1013);
|
|
62
|
+
});
|
|
63
|
+
});
|