voicecc 1.1.36 → 1.2.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/bin/voicecc.js +94 -1
- package/dashboard/dist/assets/index-DCeOdulF.js +28 -0
- package/dashboard/dist/index.html +1 -1
- package/dashboard/routes/agents.ts +28 -8
- package/dashboard/routes/browser-call.ts +3 -2
- package/dashboard/routes/chat.ts +75 -55
- package/dashboard/routes/providers.ts +5 -74
- package/dashboard/routes/twilio.ts +104 -5
- package/dashboard/routes/voice.ts +98 -0
- package/dashboard/server.ts +48 -1
- package/package.json +2 -3
- package/server/index.ts +96 -8
- package/server/services/twilio-manager.ts +29 -10
- package/dashboard/dist/assets/index-C62C9Gp0.js +0 -28
- package/dashboard/dist/audio-processor.js +0 -126
- package/server/services/heartbeat.ts +0 -403
- package/server/voice/assets/chime.wav +0 -0
- package/server/voice/assets/startup.pcm +0 -0
- package/server/voice/audio-adapter.ts +0 -60
- package/server/voice/audio-inactivity.test.ts +0 -108
- package/server/voice/audio-inactivity.ts +0 -91
- package/server/voice/browser-audio-playback.test.ts +0 -149
- package/server/voice/browser-audio.ts +0 -147
- package/server/voice/browser-server.ts +0 -311
- package/server/voice/chat-server.ts +0 -236
- package/server/voice/chime.test.ts +0 -69
- package/server/voice/chime.ts +0 -36
- package/server/voice/claude-session.ts +0 -293
- package/server/voice/endpointing.ts +0 -163
- package/server/voice/mic-vpio +0 -0
- package/server/voice/narration.ts +0 -204
- package/server/voice/prompt-builder.ts +0 -108
- package/server/voice/session-lock.ts +0 -123
- package/server/voice/stt-elevenlabs.ts +0 -210
- package/server/voice/stt-provider.ts +0 -106
- package/server/voice/tts-elevenlabs-hiss.test.ts +0 -183
- package/server/voice/tts-elevenlabs.ts +0 -397
- package/server/voice/tts-provider.ts +0 -155
- package/server/voice/twilio-audio.ts +0 -338
- package/server/voice/twilio-server.ts +0 -540
- package/server/voice/types.ts +0 -282
- package/server/voice/vad.ts +0 -101
- package/server/voice/voice-loop-bugs.test.ts +0 -348
- package/server/voice/voice-server.ts +0 -129
- package/server/voice/voice-session.ts +0 -539
|
@@ -1,348 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests that reproduce known voice loop bugs.
|
|
3
|
-
*
|
|
4
|
-
* Run: npx tsx --test server/voice/voice-loop-bugs.test.ts
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { test } from "node:test";
|
|
8
|
-
import { strict as assert } from "node:assert";
|
|
9
|
-
|
|
10
|
-
import { createClaudeSession } from "./claude-session.js";
|
|
11
|
-
import type { TextChunk, ClaudeSessionConfig, ClaudeStreamEvent } from "./types.js";
|
|
12
|
-
|
|
13
|
-
// ============================================================================
|
|
14
|
-
// HELPERS -- Mock SDK query function
|
|
15
|
-
// ============================================================================
|
|
16
|
-
|
|
17
|
-
/** A single SDK event to yield, with optional delay before yielding. */
|
|
18
|
-
interface MockStep {
|
|
19
|
-
/** The SDK message object to yield */
|
|
20
|
-
event: Record<string, unknown>;
|
|
21
|
-
/** Delay in ms before yielding this event (default: 0) */
|
|
22
|
-
delayMs?: number;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** Events to yield for a single user turn. */
|
|
26
|
-
interface MockTurn {
|
|
27
|
-
steps: MockStep[];
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function sleep(ms: number): Promise<void> {
|
|
31
|
-
return new Promise((r) => setTimeout(r, ms));
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Create a mock query function that replaces the real Claude SDK.
|
|
36
|
-
* Consumes user messages from the prompt iterable and yields pre-configured
|
|
37
|
-
* SDK events for each turn, with optional delays between events.
|
|
38
|
-
* @param turns - Array of turn configurations, consumed in order
|
|
39
|
-
* @returns A function matching the SDK query() signature (cast with `as any`)
|
|
40
|
-
*/
|
|
41
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
42
|
-
function createMockQueryFn(turns: MockTurn[]): any {
|
|
43
|
-
return function mockQueryFn({ prompt }: { prompt: AsyncIterable<unknown> }) {
|
|
44
|
-
let turnIndex = 0;
|
|
45
|
-
|
|
46
|
-
async function* generateEvents(): AsyncGenerator<Record<string, unknown>> {
|
|
47
|
-
for await (const _userMsg of prompt) {
|
|
48
|
-
const turn = turns[turnIndex++];
|
|
49
|
-
if (!turn) return;
|
|
50
|
-
|
|
51
|
-
for (const step of turn.steps) {
|
|
52
|
-
if (step.delayMs) await sleep(step.delayMs);
|
|
53
|
-
yield step.event;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const gen = generateEvents();
|
|
59
|
-
|
|
60
|
-
// Return an async iterable with an interrupt() method (matches SDK Query interface)
|
|
61
|
-
return Object.assign(gen, {
|
|
62
|
-
interrupt(): void { /* no-op: mock does not support real interruption */ },
|
|
63
|
-
});
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// -- SDK message factory helpers --
|
|
68
|
-
|
|
69
|
-
function makeSystemEvent(sessionId: string): Record<string, unknown> {
|
|
70
|
-
return { type: "system", session_id: sessionId };
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function makeBlockStart(index = 0): Record<string, unknown> {
|
|
74
|
-
return {
|
|
75
|
-
type: "stream_event",
|
|
76
|
-
event: {
|
|
77
|
-
type: "content_block_start",
|
|
78
|
-
index,
|
|
79
|
-
content_block: { type: "text", text: "" },
|
|
80
|
-
},
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function makeTextDelta(text: string, index = 0): Record<string, unknown> {
|
|
85
|
-
return {
|
|
86
|
-
type: "stream_event",
|
|
87
|
-
event: {
|
|
88
|
-
type: "content_block_delta",
|
|
89
|
-
index,
|
|
90
|
-
delta: { type: "text_delta", text },
|
|
91
|
-
},
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function makeBlockStop(index = 0): Record<string, unknown> {
|
|
96
|
-
return {
|
|
97
|
-
type: "stream_event",
|
|
98
|
-
event: { type: "content_block_stop", index },
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function makeAssistant(text: string): Record<string, unknown> {
|
|
103
|
-
return {
|
|
104
|
-
type: "assistant",
|
|
105
|
-
message: { content: [{ type: "text", text }] },
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function makeResult(): Record<string, unknown> {
|
|
110
|
-
return { type: "result", is_error: false, subtype: "success" };
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// ============================================================================
|
|
114
|
-
// BUG 2: Stale Claude session events after interrupt
|
|
115
|
-
// ============================================================================
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* Verifies that after interrupting a Claude session mid-turn, the next
|
|
119
|
-
* sendMessage call only receives events from the new turn -- no stale
|
|
120
|
-
* text deltas or assistant messages from the interrupted turn leak through.
|
|
121
|
-
*/
|
|
122
|
-
test("BUG: after interrupt, next sendMessage receives stale events from previous turn", { timeout: 10_000 }, async () => {
|
|
123
|
-
const mockQueryFn = createMockQueryFn([
|
|
124
|
-
{
|
|
125
|
-
steps: [
|
|
126
|
-
{ event: makeSystemEvent("test-session") },
|
|
127
|
-
{ event: makeBlockStart(0) },
|
|
128
|
-
{ event: makeTextDelta("Turn one response", 0) },
|
|
129
|
-
{ event: makeBlockStop(0) },
|
|
130
|
-
{ event: makeAssistant("Turn one response"), delayMs: 50 },
|
|
131
|
-
{ event: makeResult() },
|
|
132
|
-
],
|
|
133
|
-
},
|
|
134
|
-
{
|
|
135
|
-
steps: [
|
|
136
|
-
{ event: makeBlockStart(0) },
|
|
137
|
-
{ event: makeTextDelta("Turn two response", 0) },
|
|
138
|
-
{ event: makeBlockStop(0) },
|
|
139
|
-
{ event: makeAssistant("Turn two response") },
|
|
140
|
-
{ event: makeResult() },
|
|
141
|
-
],
|
|
142
|
-
},
|
|
143
|
-
]);
|
|
144
|
-
|
|
145
|
-
const config: ClaudeSessionConfig = {
|
|
146
|
-
allowedTools: [],
|
|
147
|
-
permissionMode: "bypassPermissions",
|
|
148
|
-
systemPrompt: "test",
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock needs type flexibility
|
|
152
|
-
const session = await createClaudeSession(config, mockQueryFn as any);
|
|
153
|
-
|
|
154
|
-
try {
|
|
155
|
-
const turn1Events: ClaudeStreamEvent[] = [];
|
|
156
|
-
for await (const event of session.sendMessage("turn 1 question")) {
|
|
157
|
-
turn1Events.push(event);
|
|
158
|
-
if (event.type === "text_delta") break;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
assert.ok(
|
|
162
|
-
turn1Events.some((e) => e.type === "text_delta" && e.content.includes("Turn one")),
|
|
163
|
-
"Turn 1 should have yielded a text delta"
|
|
164
|
-
);
|
|
165
|
-
|
|
166
|
-
session.interrupt();
|
|
167
|
-
await sleep(10);
|
|
168
|
-
|
|
169
|
-
const turn2Events: ClaudeStreamEvent[] = [];
|
|
170
|
-
for await (const event of session.sendMessage("turn 2 question")) {
|
|
171
|
-
turn2Events.push(event);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
const staleDeltas = turn2Events.filter(
|
|
175
|
-
(e) => e.type === "text_delta" && e.content.includes("Turn one")
|
|
176
|
-
);
|
|
177
|
-
assert.equal(
|
|
178
|
-
staleDeltas.length, 0,
|
|
179
|
-
`Found stale turn-1 text in turn-2 events: ${JSON.stringify(staleDeltas)}. ` +
|
|
180
|
-
`Events from the interrupted turn leaked through the event queue into the next turn.`
|
|
181
|
-
);
|
|
182
|
-
|
|
183
|
-
const turn2Deltas = turn2Events.filter(
|
|
184
|
-
(e) => e.type === "text_delta" && e.content.includes("Turn two")
|
|
185
|
-
);
|
|
186
|
-
assert.ok(
|
|
187
|
-
turn2Deltas.length > 0,
|
|
188
|
-
`Turn 2 should have yielded text deltas with 'Turn two' content, ` +
|
|
189
|
-
`but got: ${JSON.stringify(turn2Events.filter((e) => e.type === "text_delta"))}`
|
|
190
|
-
);
|
|
191
|
-
} finally {
|
|
192
|
-
await session.close();
|
|
193
|
-
}
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
// ============================================================================
|
|
197
|
-
// BUG 5: Narration summaries queue up and burst at tool_end
|
|
198
|
-
// ============================================================================
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Verifies that periodic summaries from long-running tools are emitted
|
|
202
|
-
* immediately as they're generated (via the timer), not queued up and
|
|
203
|
-
* drained all at once when the tool ends.
|
|
204
|
-
*/
|
|
205
|
-
test("BUG: narration summaries should emit immediately, not queue and burst at end", { timeout: 15_000 }, async () => {
|
|
206
|
-
const { createNarrator } = await import("./narration.js");
|
|
207
|
-
|
|
208
|
-
const emittedTexts: Array<{ text: string; timestamp: number }> = [];
|
|
209
|
-
const startTime = Date.now();
|
|
210
|
-
|
|
211
|
-
const narrator = createNarrator({
|
|
212
|
-
summaryIntervalMs: 100,
|
|
213
|
-
}, (text: string) => {
|
|
214
|
-
emittedTexts.push({ text, timestamp: Date.now() - startTime });
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
const toolStart: ClaudeStreamEvent = { type: "tool_start", toolName: "Write", content: "" };
|
|
218
|
-
const initialTexts = narrator.processEvent(toolStart);
|
|
219
|
-
|
|
220
|
-
for (const text of initialTexts) {
|
|
221
|
-
emittedTexts.push({ text, timestamp: Date.now() - startTime });
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
await new Promise((r) => setTimeout(r, 450));
|
|
225
|
-
|
|
226
|
-
const toolEnd: ClaudeStreamEvent = { type: "tool_end", content: "" };
|
|
227
|
-
const endTexts = narrator.processEvent(toolEnd);
|
|
228
|
-
|
|
229
|
-
for (const text of endTexts) {
|
|
230
|
-
emittedTexts.push({ text, timestamp: Date.now() - startTime });
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
const stillWorkingMessages = emittedTexts.filter(e => e.text.includes("Still working"));
|
|
234
|
-
|
|
235
|
-
assert.ok(
|
|
236
|
-
stillWorkingMessages.length >= 3,
|
|
237
|
-
`Expected at least 3 "Still working" messages over 450ms with 100ms interval, got ${stillWorkingMessages.length}`
|
|
238
|
-
);
|
|
239
|
-
|
|
240
|
-
const timestamps = stillWorkingMessages.map(e => e.timestamp);
|
|
241
|
-
const allAtEnd = timestamps.every(t => t > 400);
|
|
242
|
-
|
|
243
|
-
assert.ok(
|
|
244
|
-
!allAtEnd,
|
|
245
|
-
`All ${stillWorkingMessages.length} "Still working" messages arrived after 400ms (timestamps: ${timestamps}). ` +
|
|
246
|
-
`They were queued in pendingSummaries and drained in a burst at tool_end, ` +
|
|
247
|
-
`instead of being emitted immediately as the timer fired.`
|
|
248
|
-
);
|
|
249
|
-
|
|
250
|
-
narrator.reset();
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
// ============================================================================
|
|
254
|
-
// BUG: "Thinking" is spoken at the end of thinking, not the beginning
|
|
255
|
-
// ============================================================================
|
|
256
|
-
|
|
257
|
-
/**
|
|
258
|
-
* When the model uses extended thinking, the user should hear "Thinking..."
|
|
259
|
-
* BEFORE the thinking completes -- not after. The SDK delivers the thinking
|
|
260
|
-
* content_block_start event only once thinking tokens start streaming, which
|
|
261
|
-
* can be seconds into the turn. The "Thinking..." announcement should arrive
|
|
262
|
-
* early in the turn, before the bulk of thinking finishes.
|
|
263
|
-
*
|
|
264
|
-
* This test simulates a thinking block that takes 2 seconds, followed by text.
|
|
265
|
-
* "Thinking..." must appear within the first second -- not after the 2s delay.
|
|
266
|
-
*
|
|
267
|
-
* STATUS: No fix yet. The SDK provides no early signal that thinking has started.
|
|
268
|
-
* The content_block_start type="thinking" event only arrives after ~1.7s+ of API
|
|
269
|
-
* latency (on top of any actual thinking time). See timing investigation in:
|
|
270
|
-
* testscripts/2026.03.10-thinking-timing/test-sdk-event-timing.mjs
|
|
271
|
-
* testscripts/2026.03.10-thinking-timing/timing.log
|
|
272
|
-
*/
|
|
273
|
-
test.skip("BUG: 'Thinking' should be announced early, not after thinking completes", { timeout: 10_000 }, async () => {
|
|
274
|
-
// Simulate realistic SDK behaviour: the thinking content_block_start arrives
|
|
275
|
-
// late (after ~2s of thinking), then text follows shortly after.
|
|
276
|
-
function makeThinkingBlockStart(index = 0): Record<string, unknown> {
|
|
277
|
-
return {
|
|
278
|
-
type: "stream_event",
|
|
279
|
-
event: {
|
|
280
|
-
type: "content_block_start",
|
|
281
|
-
index,
|
|
282
|
-
content_block: { type: "thinking", thinking: "" },
|
|
283
|
-
},
|
|
284
|
-
};
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
function makeThinkingBlockStop(index = 0): Record<string, unknown> {
|
|
288
|
-
return {
|
|
289
|
-
type: "stream_event",
|
|
290
|
-
event: { type: "content_block_stop", index },
|
|
291
|
-
};
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
const mockQueryFn = createMockQueryFn([
|
|
295
|
-
{
|
|
296
|
-
steps: [
|
|
297
|
-
{ event: makeSystemEvent("test-session") },
|
|
298
|
-
// Thinking block arrives after 2s (simulates SDK buffering)
|
|
299
|
-
{ event: makeThinkingBlockStart(0), delayMs: 2000 },
|
|
300
|
-
{ event: makeThinkingBlockStop(0) },
|
|
301
|
-
// Then the actual text response
|
|
302
|
-
{ event: makeBlockStart(1) },
|
|
303
|
-
{ event: makeTextDelta("Here is my answer.", 1) },
|
|
304
|
-
{ event: makeBlockStop(1) },
|
|
305
|
-
{ event: makeAssistant("Here is my answer.") },
|
|
306
|
-
{ event: makeResult() },
|
|
307
|
-
],
|
|
308
|
-
},
|
|
309
|
-
]);
|
|
310
|
-
|
|
311
|
-
const config: ClaudeSessionConfig = {
|
|
312
|
-
allowedTools: [],
|
|
313
|
-
permissionMode: "bypassPermissions",
|
|
314
|
-
systemPrompt: "test",
|
|
315
|
-
};
|
|
316
|
-
|
|
317
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
318
|
-
const session = await createClaudeSession(config, mockQueryFn as any);
|
|
319
|
-
|
|
320
|
-
try {
|
|
321
|
-
const events: Array<{ event: ClaudeStreamEvent; elapsedMs: number }> = [];
|
|
322
|
-
const t0 = Date.now();
|
|
323
|
-
|
|
324
|
-
for await (const event of session.sendMessage("complex question")) {
|
|
325
|
-
events.push({ event, elapsedMs: Date.now() - t0 });
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
// Find the "Thinking" announcement
|
|
329
|
-
const thinkingEvent = events.find(
|
|
330
|
-
(e) => e.event.type === "text_delta" && e.event.content.toLowerCase().includes("thinking")
|
|
331
|
-
);
|
|
332
|
-
|
|
333
|
-
assert.ok(
|
|
334
|
-
thinkingEvent,
|
|
335
|
-
`Expected a "Thinking..." text delta, but none was found. Events: ${JSON.stringify(events.map(e => e.event))}`
|
|
336
|
-
);
|
|
337
|
-
|
|
338
|
-
// The "Thinking..." must arrive within the first second, NOT after the 2s delay
|
|
339
|
-
assert.ok(
|
|
340
|
-
thinkingEvent!.elapsedMs < 1000,
|
|
341
|
-
`"Thinking..." was announced at ${thinkingEvent!.elapsedMs}ms, but should arrive within 1000ms. ` +
|
|
342
|
-
`It's being announced when the SDK delivers the thinking event (after thinking completes), ` +
|
|
343
|
-
`not proactively at the start of the turn.`
|
|
344
|
-
);
|
|
345
|
-
} finally {
|
|
346
|
-
await session.close();
|
|
347
|
-
}
|
|
348
|
-
});
|
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Unified HTTP + WebSocket voice server.
|
|
3
|
-
*
|
|
4
|
-
* Runs on TWILIO_PORT (default 8080) and handles both Twilio and browser
|
|
5
|
-
* audio connections on the same port. All other HTTP requests are proxied
|
|
6
|
-
* to the dashboard server.
|
|
7
|
-
*
|
|
8
|
-
* Routes:
|
|
9
|
-
* - POST /twilio/incoming-call → Twilio webhook handler
|
|
10
|
-
* - POST /register-call → outbound call token registration
|
|
11
|
-
* - WS /media/:token → Twilio media stream
|
|
12
|
-
* - WS /audio?token=<token> → Browser audio stream
|
|
13
|
-
* - * → proxy to dashboard
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { join } from "node:path";
|
|
17
|
-
import { homedir } from "node:os";
|
|
18
|
-
import { config } from "dotenv";
|
|
19
|
-
config({ path: process.env.VOICECC_DIR ? join(process.env.VOICECC_DIR, ".env") : join(homedir(), ".voicecc", ".env") });
|
|
20
|
-
|
|
21
|
-
import { createServer, request as httpRequest } from "http";
|
|
22
|
-
import { WebSocketServer } from "ws";
|
|
23
|
-
|
|
24
|
-
import { handleTwilioHttpRequest, handleTwilioUpgrade } from "./twilio-server.js";
|
|
25
|
-
import { handleBrowserUpgrade } from "./browser-server.js";
|
|
26
|
-
|
|
27
|
-
import type { IncomingMessage, ServerResponse } from "http";
|
|
28
|
-
import type { Duplex } from "stream";
|
|
29
|
-
|
|
30
|
-
// ============================================================================
|
|
31
|
-
// CONSTANTS
|
|
32
|
-
// ============================================================================
|
|
33
|
-
|
|
34
|
-
const DEFAULT_PORT = 8080;
|
|
35
|
-
|
|
36
|
-
/** Ping interval to keep WebSocket connections alive through tunnel (ms) */
|
|
37
|
-
const PING_INTERVAL_MS = 30_000;
|
|
38
|
-
|
|
39
|
-
// ============================================================================
|
|
40
|
-
// MAIN ENTRYPOINT
|
|
41
|
-
// ============================================================================
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Start the unified voice server.
|
|
45
|
-
*
|
|
46
|
-
* Creates an HTTP server that routes Twilio webhooks and browser/Twilio
|
|
47
|
-
* WebSocket upgrades, proxying everything else to the dashboard.
|
|
48
|
-
*
|
|
49
|
-
* @param dashboardPort - Dashboard server port for proxying
|
|
50
|
-
* @returns Resolves when the server is listening
|
|
51
|
-
*/
|
|
52
|
-
export async function startVoiceServer(dashboardPort: number): Promise<number> {
|
|
53
|
-
const port = parseInt(process.env.TWILIO_PORT ?? "", 10) || DEFAULT_PORT;
|
|
54
|
-
|
|
55
|
-
const server = createServer((req, res) => {
|
|
56
|
-
// Try Twilio HTTP handlers first
|
|
57
|
-
if (handleTwilioHttpRequest(req, res)) {
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Proxy everything else to the dashboard
|
|
62
|
-
proxyToDashboard(req, res, dashboardPort);
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
const wss = new WebSocketServer({ noServer: true });
|
|
66
|
-
|
|
67
|
-
// Route WebSocket upgrades by path
|
|
68
|
-
server.on("upgrade", (req: IncomingMessage, socket: Duplex, head: Buffer) => {
|
|
69
|
-
const url = new URL(req.url ?? "", `http://${req.headers.host}`);
|
|
70
|
-
|
|
71
|
-
if (url.pathname.startsWith("/media/")) {
|
|
72
|
-
handleTwilioUpgrade(req, socket, head, wss);
|
|
73
|
-
} else if (url.pathname === "/audio") {
|
|
74
|
-
handleBrowserUpgrade(req, socket, head, wss);
|
|
75
|
-
} else {
|
|
76
|
-
socket.destroy();
|
|
77
|
-
}
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
// Periodic ping to keep connections alive through tunnel
|
|
81
|
-
setInterval(() => {
|
|
82
|
-
wss.clients.forEach((ws) => {
|
|
83
|
-
if (ws.readyState === ws.OPEN) {
|
|
84
|
-
ws.ping();
|
|
85
|
-
}
|
|
86
|
-
});
|
|
87
|
-
}, PING_INTERVAL_MS);
|
|
88
|
-
|
|
89
|
-
return new Promise<number>((resolve) => {
|
|
90
|
-
server.listen(port, () => {
|
|
91
|
-
console.log(`Voice server listening on port ${port}`);
|
|
92
|
-
resolve(port);
|
|
93
|
-
});
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// ============================================================================
|
|
98
|
-
// HELPER FUNCTIONS
|
|
99
|
-
// ============================================================================
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Proxy an HTTP request to the dashboard server on localhost.
|
|
103
|
-
*
|
|
104
|
-
* @param req - Original incoming request
|
|
105
|
-
* @param res - Response to write the proxied result to
|
|
106
|
-
* @param dashboardPort - Port the dashboard server is listening on
|
|
107
|
-
*/
|
|
108
|
-
function proxyToDashboard(req: IncomingMessage, res: ServerResponse, dashboardPort: number): void {
|
|
109
|
-
const proxyReq = httpRequest(
|
|
110
|
-
{
|
|
111
|
-
hostname: "127.0.0.1",
|
|
112
|
-
port: dashboardPort,
|
|
113
|
-
path: req.url,
|
|
114
|
-
method: req.method,
|
|
115
|
-
headers: { ...req.headers, "x-forwarded-for": "127.0.0.1" },
|
|
116
|
-
},
|
|
117
|
-
(proxyRes) => {
|
|
118
|
-
res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
|
|
119
|
-
proxyRes.pipe(res);
|
|
120
|
-
},
|
|
121
|
-
);
|
|
122
|
-
|
|
123
|
-
proxyReq.on("error", () => {
|
|
124
|
-
res.writeHead(502, { "Content-Type": "text/plain" });
|
|
125
|
-
res.end("Dashboard unavailable");
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
req.pipe(proxyReq);
|
|
129
|
-
}
|