@talkif/webrtc 0.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/dist/index.js ADDED
@@ -0,0 +1,908 @@
1
+ // src/data-channel.ts
2
+ var KEEPALIVE_INTERVAL_MS = 2e3;
3
+ var DataChannel = class {
4
+ channel;
5
+ keepalive = null;
6
+ handlers;
7
+ constructor(pc, handlers) {
8
+ this.handlers = handlers;
9
+ this.channel = pc.createDataChannel("pipecat", { ordered: true });
10
+ this.channel.onopen = () => this.startKeepalive();
11
+ this.channel.onclose = () => this.stopKeepalive();
12
+ this.channel.onmessage = (event) => this.handleMessage(event.data);
13
+ }
14
+ startKeepalive() {
15
+ this.stopKeepalive();
16
+ this.keepalive = setInterval(() => {
17
+ if (this.channel.readyState === "open") {
18
+ this.channel.send("ping");
19
+ }
20
+ }, KEEPALIVE_INTERVAL_MS);
21
+ }
22
+ stopKeepalive() {
23
+ if (this.keepalive) {
24
+ clearInterval(this.keepalive);
25
+ this.keepalive = null;
26
+ }
27
+ }
28
+ handleMessage(data) {
29
+ if (typeof data !== "string") return;
30
+ let parsed;
31
+ try {
32
+ parsed = JSON.parse(data);
33
+ } catch {
34
+ return;
35
+ }
36
+ if (typeof parsed !== "object" || parsed === null) return;
37
+ const record = parsed;
38
+ if (record["type"] === "signalling" && typeof record["message"] === "object" && record["message"] !== null) {
39
+ this.handlers.onSignalling(record["message"]);
40
+ return;
41
+ }
42
+ this.handlers.onAppMessage(record);
43
+ }
44
+ /** Send an arbitrary JSON app message to the bot pipeline. */
45
+ sendAppMessage(message) {
46
+ if (this.channel.readyState !== "open") return false;
47
+ this.channel.send(JSON.stringify(message));
48
+ return true;
49
+ }
50
+ /** Tell the bot to enable/disable one of our media receivers. */
51
+ sendTrackStatus(message) {
52
+ if (this.channel.readyState !== "open") return false;
53
+ this.channel.send(JSON.stringify({ type: "signalling", message: { type: "trackStatus", ...message } }));
54
+ return true;
55
+ }
56
+ close() {
57
+ this.stopKeepalive();
58
+ this.channel.onopen = null;
59
+ this.channel.onclose = null;
60
+ this.channel.onmessage = null;
61
+ if (this.channel.readyState === "open" || this.channel.readyState === "connecting") {
62
+ this.channel.close();
63
+ }
64
+ }
65
+ };
66
+
67
+ // src/emitter.ts
68
+ var Emitter = class {
69
+ listeners = /* @__PURE__ */ new Map();
70
+ on(event, listener) {
71
+ let set = this.listeners.get(event);
72
+ if (!set) {
73
+ set = /* @__PURE__ */ new Set();
74
+ this.listeners.set(event, set);
75
+ }
76
+ set.add(listener);
77
+ return () => this.off(event, listener);
78
+ }
79
+ off(event, listener) {
80
+ this.listeners.get(event)?.delete(listener);
81
+ }
82
+ emit(event, payload) {
83
+ const set = this.listeners.get(event);
84
+ if (!set) return;
85
+ for (const listener of [...set]) {
86
+ listener(payload);
87
+ }
88
+ }
89
+ removeAll() {
90
+ this.listeners.clear();
91
+ }
92
+ };
93
+
94
+ // src/events.ts
95
+ var BACKOFF_MS = [500, 1e3, 2e3, 4e3, 8e3];
96
+ var CallEventsClient = class {
97
+ options;
98
+ ws = null;
99
+ closed = false;
100
+ attempts = 0;
101
+ everConnected = false;
102
+ lastSeq = 0;
103
+ reconnectTimer = null;
104
+ constructor(options) {
105
+ this.options = options;
106
+ }
107
+ connect() {
108
+ if (this.closed || this.ws) return;
109
+ void this.open();
110
+ }
111
+ close() {
112
+ this.closed = true;
113
+ if (this.reconnectTimer) {
114
+ clearTimeout(this.reconnectTimer);
115
+ this.reconnectTimer = null;
116
+ }
117
+ this.ws?.close(1e3);
118
+ this.ws = null;
119
+ }
120
+ wsUrl() {
121
+ const base = this.options.baseUrl.replace(/\/$/, "").replace(/^http/, "ws");
122
+ return `${base}/api/v1/public/ws/events`;
123
+ }
124
+ async open() {
125
+ let token;
126
+ try {
127
+ token = await this.options.getToken();
128
+ } catch {
129
+ this.scheduleReconnect();
130
+ return;
131
+ }
132
+ if (this.closed) return;
133
+ const WS = this.options.webSocket ?? WebSocket;
134
+ const ws = new WS(this.wsUrl());
135
+ this.ws = ws;
136
+ ws.onopen = () => {
137
+ ws.send(JSON.stringify({ type: "auth", token }));
138
+ ws.send(
139
+ JSON.stringify({ type: "subscribe", ...this.everConnected ? { replay: true } : {} })
140
+ );
141
+ };
142
+ ws.onmessage = (message) => {
143
+ if (typeof message.data !== "string") return;
144
+ let envelope;
145
+ try {
146
+ envelope = JSON.parse(message.data);
147
+ } catch {
148
+ return;
149
+ }
150
+ if (typeof envelope.type !== "string" || typeof envelope.seq !== "number") return;
151
+ if (envelope.type === "subscribed") {
152
+ this.everConnected = true;
153
+ this.attempts = 0;
154
+ this.lastSeq = envelope.seq;
155
+ return;
156
+ }
157
+ if (envelope.type === "pong" || envelope.type === "unsubscribed") {
158
+ this.lastSeq = envelope.seq;
159
+ return;
160
+ }
161
+ if (this.lastSeq > 0 && envelope.seq > this.lastSeq + 1) {
162
+ this.options.onGap?.(this.lastSeq + 1, envelope.seq);
163
+ }
164
+ this.lastSeq = envelope.seq;
165
+ this.options.onEvent(envelope);
166
+ };
167
+ ws.onclose = (event) => {
168
+ if (this.ws !== ws) return;
169
+ this.ws = null;
170
+ this.lastSeq = 0;
171
+ if (event.code === 4403) {
172
+ this.closed = true;
173
+ return;
174
+ }
175
+ this.scheduleReconnect();
176
+ };
177
+ ws.onerror = () => {
178
+ };
179
+ }
180
+ scheduleReconnect() {
181
+ if (this.closed || this.reconnectTimer) return;
182
+ const delay = BACKOFF_MS[Math.min(this.attempts, BACKOFF_MS.length - 1)] ?? 8e3;
183
+ this.attempts += 1;
184
+ this.reconnectTimer = setTimeout(() => {
185
+ this.reconnectTimer = null;
186
+ void this.open();
187
+ }, delay);
188
+ }
189
+ };
190
+
191
+ // src/types.ts
192
+ var TERMINAL_CALL_STATUSES = /* @__PURE__ */ new Set([
193
+ "COMPLETED",
194
+ "FAILED",
195
+ "NO_ANSWER",
196
+ "BUSY",
197
+ "CANCELED"
198
+ ]);
199
+ var TalkifCallError = class extends Error {
200
+ code;
201
+ cause;
202
+ constructor(code, message, cause) {
203
+ super(message);
204
+ this.name = "TalkifCallError";
205
+ this.code = code;
206
+ this.cause = cause;
207
+ }
208
+ };
209
+ function isPublicConfig(config) {
210
+ return "publishableKey" in config;
211
+ }
212
+
213
+ // src/turnstile.ts
214
+ var TURNSTILE_SCRIPT_URL = "https://challenges.cloudflare.com/turnstile/v0/api.js";
215
+ var scriptPromise = null;
216
+ function loadScript() {
217
+ if (typeof document === "undefined") {
218
+ return Promise.reject(new Error("Turnstile requires a browser environment"));
219
+ }
220
+ if (window.turnstile) return Promise.resolve();
221
+ scriptPromise ??= new Promise((resolve, reject) => {
222
+ const existing = document.querySelector(
223
+ `script[src="${TURNSTILE_SCRIPT_URL}"]`
224
+ );
225
+ if (existing) {
226
+ existing.addEventListener("load", () => resolve());
227
+ existing.addEventListener("error", () => reject(new Error("Turnstile script failed to load")));
228
+ if (window.turnstile) resolve();
229
+ return;
230
+ }
231
+ const script = document.createElement("script");
232
+ script.src = TURNSTILE_SCRIPT_URL;
233
+ script.async = true;
234
+ script.defer = true;
235
+ script.onload = () => resolve();
236
+ script.onerror = () => reject(new Error("Turnstile script failed to load"));
237
+ document.head.appendChild(script);
238
+ });
239
+ return scriptPromise;
240
+ }
241
+ var TurnstileManager = class {
242
+ siteKey;
243
+ container = null;
244
+ widgetId = null;
245
+ pending = null;
246
+ constructor(siteKey) {
247
+ this.siteKey = siteKey;
248
+ }
249
+ /** Obtain a fresh Turnstile token. Rejects if the challenge fails. */
250
+ async getToken() {
251
+ await loadScript();
252
+ const turnstile = window.turnstile;
253
+ if (!turnstile) throw new Error("Turnstile unavailable after script load");
254
+ if (!this.container) {
255
+ this.container = document.createElement("div");
256
+ this.container.style.position = "fixed";
257
+ this.container.style.width = "0";
258
+ this.container.style.height = "0";
259
+ this.container.style.overflow = "hidden";
260
+ this.container.style.pointerEvents = "none";
261
+ this.container.setAttribute("aria-hidden", "true");
262
+ document.body.appendChild(this.container);
263
+ }
264
+ if (this.widgetId === null) {
265
+ this.widgetId = turnstile.render(this.container, {
266
+ sitekey: this.siteKey,
267
+ size: "invisible",
268
+ execution: "execute",
269
+ retry: "auto",
270
+ callback: (token) => {
271
+ this.pending?.resolve(token);
272
+ this.pending = null;
273
+ },
274
+ "error-callback": (code) => {
275
+ this.pending?.reject(new Error(`Turnstile challenge failed${code ? `: ${code}` : ""}`));
276
+ this.pending = null;
277
+ },
278
+ "expired-callback": () => {
279
+ }
280
+ });
281
+ }
282
+ return new Promise((resolve, reject) => {
283
+ if (this.pending) {
284
+ this.pending.reject(new Error("Turnstile challenge superseded"));
285
+ }
286
+ this.pending = { resolve, reject };
287
+ const id = this.widgetId;
288
+ if (id === null) {
289
+ reject(new Error("Turnstile widget not rendered"));
290
+ this.pending = null;
291
+ return;
292
+ }
293
+ try {
294
+ turnstile.reset(id);
295
+ turnstile.execute(id, { sitekey: this.siteKey });
296
+ } catch (err) {
297
+ this.pending = null;
298
+ reject(err instanceof Error ? err : new Error("Turnstile execute failed"));
299
+ }
300
+ });
301
+ }
302
+ dispose() {
303
+ if (this.widgetId !== null && window.turnstile) {
304
+ try {
305
+ window.turnstile.remove(this.widgetId);
306
+ } catch {
307
+ }
308
+ }
309
+ this.widgetId = null;
310
+ this.pending = null;
311
+ if (this.container) {
312
+ this.container.remove();
313
+ this.container = null;
314
+ }
315
+ }
316
+ };
317
+
318
+ // src/signaling.ts
319
+ var SignalingError = class extends Error {
320
+ status;
321
+ body;
322
+ constructor(status, message, body) {
323
+ super(message);
324
+ this.name = "SignalingError";
325
+ this.status = status;
326
+ this.body = body;
327
+ }
328
+ };
329
+ var SESSION_REFRESH_MARGIN_MS = 6e4;
330
+ var PublicSession = class {
331
+ config;
332
+ token = null;
333
+ expiresAt = 0;
334
+ flowIdValue = null;
335
+ inflight = null;
336
+ turnstile = null;
337
+ constructor(config) {
338
+ this.config = config;
339
+ if (config.turnstileSiteKey && !config.turnstileToken) {
340
+ this.turnstile = new TurnstileManager(config.turnstileSiteKey);
341
+ }
342
+ }
343
+ /** A fresh Turnstile token, from the manual supplier or the built-in
344
+ * manager; undefined when no gate is configured (dev/local). */
345
+ async turnstileToken() {
346
+ if (this.config.turnstileToken) {
347
+ return this.config.turnstileToken();
348
+ }
349
+ if (this.turnstile) {
350
+ return this.turnstile.getToken();
351
+ }
352
+ return void 0;
353
+ }
354
+ /** Tear down the Turnstile widget (call when the client is disposed). */
355
+ dispose() {
356
+ this.turnstile?.dispose();
357
+ this.turnstile = null;
358
+ }
359
+ /** Flow bound to the publishable key (known after the first exchange). */
360
+ get flowId() {
361
+ return this.flowIdValue;
362
+ }
363
+ async getToken() {
364
+ if (this.token && Date.now() < this.expiresAt - SESSION_REFRESH_MARGIN_MS) {
365
+ return this.token;
366
+ }
367
+ this.inflight ??= this.exchange().finally(() => {
368
+ this.inflight = null;
369
+ });
370
+ return this.inflight;
371
+ }
372
+ invalidate() {
373
+ this.token = null;
374
+ this.expiresAt = 0;
375
+ }
376
+ async exchange() {
377
+ const doFetch = this.config.fetch ?? fetch;
378
+ const turnstileToken = await this.turnstileToken();
379
+ const response = await doFetch(
380
+ `${this.config.baseUrl.replace(/\/$/, "")}/api/v1/public/calls/session`,
381
+ {
382
+ method: "POST",
383
+ headers: { "content-type": "application/json" },
384
+ body: JSON.stringify({
385
+ publishableKey: this.config.publishableKey,
386
+ ...turnstileToken !== void 0 ? { turnstileToken } : {}
387
+ })
388
+ }
389
+ );
390
+ const parsed = await response.json().catch(() => null);
391
+ if (!response.ok) {
392
+ throw new SignalingError(
393
+ response.status,
394
+ "Session exchange failed \u2014 check the publishable key and that this origin is on its allowlist",
395
+ parsed
396
+ );
397
+ }
398
+ const session = parsed;
399
+ this.token = session.token;
400
+ this.expiresAt = Date.now() + session.expiresIn * 1e3;
401
+ this.flowIdValue = session.flowId;
402
+ return session.token;
403
+ }
404
+ };
405
+ var SignalingClient = class {
406
+ config;
407
+ session;
408
+ constructor(config) {
409
+ this.config = config;
410
+ this.session = isPublicConfig(config) ? new PublicSession(config) : null;
411
+ }
412
+ get isPublic() {
413
+ return this.session !== null;
414
+ }
415
+ get base() {
416
+ const baseUrl = this.config.baseUrl.replace(/\/$/, "");
417
+ if (isPublicConfig(this.config)) {
418
+ return `${baseUrl}/api/v1/public/calls`;
419
+ }
420
+ return `${baseUrl}/api/v1/accounts/${this.config.accountId}/calls/webrtc`;
421
+ }
422
+ async authorization() {
423
+ if (this.session) {
424
+ return `Bearer ${await this.session.getToken()}`;
425
+ }
426
+ return this.config.auth();
427
+ }
428
+ async request(method, path, body, retriedAuth = false) {
429
+ const doFetch = this.config.fetch ?? fetch;
430
+ const authorization = await this.authorization();
431
+ const response = await doFetch(`${this.base}${path}`, {
432
+ method,
433
+ headers: {
434
+ authorization,
435
+ ...body !== void 0 ? { "content-type": "application/json" } : {}
436
+ },
437
+ ...body !== void 0 ? { body: JSON.stringify(body) } : {}
438
+ });
439
+ if (response.status === 401 && this.session && !retriedAuth) {
440
+ this.session.invalidate();
441
+ return this.request(method, path, body, true);
442
+ }
443
+ let parsed = null;
444
+ const text = await response.text();
445
+ if (text) {
446
+ try {
447
+ parsed = JSON.parse(text);
448
+ } catch {
449
+ parsed = text;
450
+ }
451
+ }
452
+ if (!response.ok) {
453
+ const message = typeof parsed === "object" && parsed !== null && "detail" in parsed ? String(parsed.detail) : typeof parsed === "object" && parsed !== null && "message" in parsed ? String(parsed.message) : `Signaling request failed: ${method} ${path} \u2192 ${response.status}`;
454
+ throw new SignalingError(response.status, message, parsed);
455
+ }
456
+ return parsed;
457
+ }
458
+ createCall(body) {
459
+ if (this.isPublic) {
460
+ return this.request("POST", "/calls", {});
461
+ }
462
+ return this.request("POST", "", body);
463
+ }
464
+ createTestCall(body) {
465
+ if (this.isPublic) {
466
+ throw new TalkifCallError(
467
+ "signaling-failed",
468
+ "Draft test calls are not available on the public surface"
469
+ );
470
+ }
471
+ return this.request("POST", "/test", body);
472
+ }
473
+ getIceServers() {
474
+ return this.request("GET", "/ice-servers");
475
+ }
476
+ sendOffer(callId, body) {
477
+ const path = this.isPublic ? `/calls/${callId}/offer` : `/${callId}/offer`;
478
+ return this.request("POST", path, body);
479
+ }
480
+ getCall(callId) {
481
+ const path = this.isPublic ? `/calls/${callId}` : `/${callId}`;
482
+ return this.request("GET", path);
483
+ }
484
+ };
485
+ function asSignalingCallError(error) {
486
+ if (error instanceof TalkifCallError) return error;
487
+ if (error instanceof SignalingError) {
488
+ const code = error.status === 401 ? "session-rejected" : error.status === 409 ? "call-active" : error.status === 429 ? "rate-limited" : error.status === 503 ? "no-agent" : "signaling-failed";
489
+ return new TalkifCallError(code, error.message, error);
490
+ }
491
+ const message = error instanceof Error ? error.message : "Signaling request failed";
492
+ return new TalkifCallError("signaling-failed", message, error);
493
+ }
494
+
495
+ // src/call.ts
496
+ var PIPELINE_HEALTH_TIMEOUT_MS = 15e3;
497
+ var ICE_FIRST_RELAY_TIMEOUT_MS = 1500;
498
+ var TalkifCall = class {
499
+ signaling;
500
+ config;
501
+ emitter = new Emitter();
502
+ events = null;
503
+ pc = null;
504
+ localStream = null;
505
+ remoteAudio = null;
506
+ ownsAudioElement = false;
507
+ dataChannel = null;
508
+ iceServers = [];
509
+ durationTimer = null;
510
+ healthTimer = null;
511
+ pipelineConfirmed = false;
512
+ renegotiating = false;
513
+ _state = "idle";
514
+ _callId = null;
515
+ _botId = null;
516
+ _muted = false;
517
+ _durationSecs = 0;
518
+ constructor(config) {
519
+ this.config = config;
520
+ this.signaling = new SignalingClient(config);
521
+ }
522
+ // -- public surface -------------------------------------------------------
523
+ get state() {
524
+ return this._state;
525
+ }
526
+ get callId() {
527
+ return this._callId;
528
+ }
529
+ get botId() {
530
+ return this._botId;
531
+ }
532
+ get muted() {
533
+ return this._muted;
534
+ }
535
+ get durationSecs() {
536
+ return this._durationSecs;
537
+ }
538
+ on(event, listener) {
539
+ return this.emitter.on(event, listener);
540
+ }
541
+ off(event, listener) {
542
+ this.emitter.off(event, listener);
543
+ }
544
+ /**
545
+ * External health signal (e.g. the dashboard's SSE layer saw a
546
+ * RUNNING/CONNECTED status). Cancels the REST health watchdog.
547
+ */
548
+ confirmPipelineHealthy() {
549
+ this.pipelineConfirmed = true;
550
+ if (this.healthTimer) {
551
+ clearTimeout(this.healthTimer);
552
+ this.healthTimer = null;
553
+ }
554
+ }
555
+ /**
556
+ * External terminal signal (e.g. SSE reported COMPLETED). Tears down and
557
+ * transitions to 'ended'.
558
+ */
559
+ endedExternally() {
560
+ if (this._state !== "connecting" && this._state !== "connected") return;
561
+ this.teardown();
562
+ this.setState("ended");
563
+ this.emitter.emit("ended", { reason: "external" });
564
+ }
565
+ /** Send an arbitrary JSON app message to the bot over the data channel. */
566
+ sendAppMessage(message) {
567
+ return this.dataChannel?.sendAppMessage(message) ?? false;
568
+ }
569
+ setMuted(muted) {
570
+ if (!this.localStream) return;
571
+ for (const track of this.localStream.getAudioTracks()) {
572
+ track.enabled = !muted;
573
+ }
574
+ this._muted = muted;
575
+ this.emitter.emit("mutechange", { muted });
576
+ }
577
+ /** User-initiated hangup. */
578
+ hangup() {
579
+ if (this._state === "idle" || this._state === "ended" || this._state === "error") return;
580
+ this.teardown();
581
+ this.setState("ended");
582
+ this.emitter.emit("ended", { reason: "local-hangup" });
583
+ }
584
+ /** Full teardown regardless of state — safe to call multiple times. */
585
+ dispose() {
586
+ this.teardown();
587
+ this.signaling.session?.dispose();
588
+ this.emitter.removeAll();
589
+ }
590
+ // -- lifecycle ------------------------------------------------------------
591
+ async start(options) {
592
+ if (this._state !== "idle") {
593
+ throw new TalkifCallError("signaling-failed", `Cannot start a call from state '${this._state}' \u2014 create a new TalkifCall`);
594
+ }
595
+ this.setState("requesting");
596
+ if (!this.signaling.isPublic && !options.flowId) {
597
+ throw new TalkifCallError("signaling-failed", "flowId is required in authenticated mode");
598
+ }
599
+ try {
600
+ const flowId = options.flowId ?? "";
601
+ const [callResponse, local] = await Promise.all([
602
+ options.draftDefinition !== void 0 ? this.signaling.createTestCall({
603
+ flowId,
604
+ definition: options.draftDefinition,
605
+ ...options.metadata !== void 0 ? { metadata: options.metadata } : {}
606
+ }) : this.signaling.createCall({
607
+ flowId,
608
+ ...options.metadata !== void 0 ? { metadata: options.metadata } : {}
609
+ }),
610
+ this.prepareLocal(options)
611
+ ]);
612
+ this._callId = callResponse.callId;
613
+ this.pc = local.pc;
614
+ this.localStream = local.stream;
615
+ this.setState("connecting");
616
+ this.pc.ontrack = (event) => {
617
+ const stream = event.streams[0];
618
+ if (!stream) return;
619
+ this.attachRemoteAudio(stream, options);
620
+ this.emitter.emit("track", { stream });
621
+ };
622
+ this.pc.onconnectionstatechange = () => {
623
+ const state = this.pc?.connectionState;
624
+ if (state === "failed" || state === "disconnected") {
625
+ this.fail(new TalkifCallError("connection-failed", "WebRTC connection failed"));
626
+ }
627
+ };
628
+ this.dataChannel = new DataChannel(this.pc, {
629
+ onSignalling: (message) => this.handleSignalling(message),
630
+ onAppMessage: (message) => this.emitter.emit("appmessage", { message })
631
+ });
632
+ const answer = await this.signaling.sendOffer(callResponse.callId, {
633
+ sdp: this.mustLocalSdp(),
634
+ iceServers: this.iceServers
635
+ });
636
+ await this.pc.setRemoteDescription({ type: "answer", sdp: answer.sdp });
637
+ this._botId = answer.botId ?? null;
638
+ this.setState("connected");
639
+ this.emitter.emit("connected", { callId: callResponse.callId, botId: this._botId });
640
+ this.startDurationTimer();
641
+ this.armHealthWatchdog(callResponse.callId);
642
+ this.connectEvents();
643
+ } catch (error) {
644
+ this.fail(
645
+ error instanceof TalkifCallError ? error : asSignalingCallError(error)
646
+ );
647
+ throw error;
648
+ }
649
+ }
650
+ async prepareLocal(options) {
651
+ const { iceServers } = await this.signaling.getIceServers();
652
+ this.iceServers = iceServers;
653
+ if (!navigator.mediaDevices?.getUserMedia) {
654
+ throw new TalkifCallError("media-unavailable", "Microphone access unavailable. This requires HTTPS or localhost.");
655
+ }
656
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: options.audio ?? true }).catch((cause) => {
657
+ throw new TalkifCallError("media-unavailable", "Microphone permission denied or unavailable", cause);
658
+ });
659
+ const pc = new RTCPeerConnection({
660
+ iceServers: iceServers.map((s) => ({
661
+ urls: s.urls,
662
+ ...s.username !== void 0 ? { username: s.username } : {},
663
+ ...s.credential !== void 0 ? { credential: s.credential } : {}
664
+ })),
665
+ iceTransportPolicy: "relay"
666
+ });
667
+ for (const track of stream.getTracks()) {
668
+ pc.addTrack(track, stream);
669
+ }
670
+ const offer = await pc.createOffer();
671
+ await pc.setLocalDescription(offer);
672
+ if (!offer.sdp) {
673
+ throw new TalkifCallError("signaling-failed", "Failed to create SDP offer");
674
+ }
675
+ await this.waitForFirstRelayCandidate(pc);
676
+ return { pc, stream };
677
+ }
678
+ waitForFirstRelayCandidate(pc) {
679
+ return new Promise((resolve) => {
680
+ if (pc.iceGatheringState === "complete") {
681
+ resolve();
682
+ return;
683
+ }
684
+ let settled = false;
685
+ const finish = () => {
686
+ if (settled) return;
687
+ settled = true;
688
+ clearTimeout(timeout);
689
+ pc.removeEventListener("icecandidate", onCandidate);
690
+ pc.removeEventListener("icegatheringstatechange", onGatheringComplete);
691
+ resolve();
692
+ };
693
+ const timeout = setTimeout(finish, ICE_FIRST_RELAY_TIMEOUT_MS);
694
+ const onCandidate = (e) => {
695
+ if (e.candidate && e.candidate.type === "relay") finish();
696
+ };
697
+ const onGatheringComplete = () => {
698
+ if (pc.iceGatheringState === "complete") finish();
699
+ };
700
+ pc.addEventListener("icecandidate", onCandidate);
701
+ pc.addEventListener("icegatheringstatechange", onGatheringComplete);
702
+ });
703
+ }
704
+ attachRemoteAudio(stream, options) {
705
+ if (options.audioElement === null) return;
706
+ if (!this.remoteAudio) {
707
+ if (options.audioElement) {
708
+ this.remoteAudio = options.audioElement;
709
+ this.ownsAudioElement = false;
710
+ } else {
711
+ this.remoteAudio = new Audio();
712
+ this.remoteAudio.autoplay = true;
713
+ this.ownsAudioElement = true;
714
+ }
715
+ }
716
+ this.remoteAudio.srcObject = stream;
717
+ }
718
+ // -- data-channel protocol ------------------------------------------------
719
+ handleSignalling(message) {
720
+ switch (message.type) {
721
+ case "peerLeft":
722
+ if (this._state === "connected" || this._state === "connecting") {
723
+ this.teardown();
724
+ this.setState("ended");
725
+ this.emitter.emit("ended", { reason: "peer-left" });
726
+ }
727
+ break;
728
+ case "renegotiate":
729
+ void this.renegotiate();
730
+ break;
731
+ case "trackStatus":
732
+ break;
733
+ }
734
+ }
735
+ /**
736
+ * Bot asked for a new offer/answer cycle. Re-offer through the same relay
737
+ * endpoint. If the backend rejects re-offers (older deployments), the call
738
+ * keeps running on the existing session — surfaced via `renegotiated`.
739
+ */
740
+ async renegotiate() {
741
+ if (!this.pc || !this._callId || this.renegotiating) return;
742
+ this.renegotiating = true;
743
+ try {
744
+ const offer = await this.pc.createOffer({ iceRestart: true });
745
+ await this.pc.setLocalDescription(offer);
746
+ await this.waitForFirstRelayCandidate(this.pc);
747
+ const answer = await this.signaling.sendOffer(this._callId, {
748
+ sdp: this.mustLocalSdp(),
749
+ iceServers: this.iceServers
750
+ });
751
+ await this.pc.setRemoteDescription({ type: "answer", sdp: answer.sdp });
752
+ this.emitter.emit("renegotiated", { ok: true });
753
+ } catch {
754
+ this.emitter.emit("renegotiated", { ok: false });
755
+ } finally {
756
+ this.renegotiating = false;
757
+ }
758
+ }
759
+ // -- realtime events (public mode) ----------------------------------------
760
+ /**
761
+ * Public mode only: open the realtime events WebSocket for this call.
762
+ * Events double as the pipeline-health signal, and a terminal `status`
763
+ * event ends the call from the server side (e.g. max-duration reached).
764
+ */
765
+ connectEvents() {
766
+ const session = this.signaling.session;
767
+ if (!session) return;
768
+ this.events = new CallEventsClient({
769
+ baseUrl: this.config.baseUrl,
770
+ getToken: () => session.getToken(),
771
+ onEvent: (event) => this.handleCallEvent(event)
772
+ });
773
+ this.events.connect();
774
+ }
775
+ handleCallEvent(event) {
776
+ this.confirmPipelineHealthy();
777
+ this.emitter.emit("callevent", { event });
778
+ switch (event.type) {
779
+ case "transcript": {
780
+ const role = typeof event.data.role === "string" ? event.data.role : "unknown";
781
+ const content = typeof event.data.content === "string" ? event.data.content : "";
782
+ this.emitter.emit("transcript", { role, content, data: event.data });
783
+ break;
784
+ }
785
+ case "interim":
786
+ this.emitter.emit("interim", { data: event.data });
787
+ break;
788
+ case "tts": {
789
+ const text = typeof event.data.text === "string" ? event.data.text : "";
790
+ if (!text) break;
791
+ const timestamp = typeof event.data.timestamp === "number" ? event.data.timestamp : 0;
792
+ this.emitter.emit("ttschunk", { text, timestamp, data: event.data });
793
+ break;
794
+ }
795
+ case "tts_word": {
796
+ const word = typeof event.data.word === "string" ? event.data.word : "";
797
+ if (!word) break;
798
+ const ptsMs = typeof event.data.pts_ms === "number" ? event.data.pts_ms : null;
799
+ const timestamp = typeof event.data.timestamp === "number" ? event.data.timestamp : 0;
800
+ this.emitter.emit("ttsword", { word, ptsMs, timestamp, data: event.data });
801
+ break;
802
+ }
803
+ case "status": {
804
+ const status = typeof event.data.status === "string" ? event.data.status : "";
805
+ if (TERMINAL_CALL_STATUSES.has(status.toUpperCase())) {
806
+ this.endedExternally();
807
+ }
808
+ break;
809
+ }
810
+ }
811
+ }
812
+ // -- health watchdog ------------------------------------------------------
813
+ armHealthWatchdog(callId) {
814
+ this.pipelineConfirmed = false;
815
+ this.healthTimer = setTimeout(() => {
816
+ this.healthTimer = null;
817
+ if (this.pipelineConfirmed) return;
818
+ void this.verifyPipelineHealth(callId);
819
+ }, PIPELINE_HEALTH_TIMEOUT_MS);
820
+ }
821
+ async verifyPipelineHealth(callId) {
822
+ try {
823
+ const call = await this.signaling.getCall(callId);
824
+ if (this.pipelineConfirmed || !this.pc) return;
825
+ if (TERMINAL_CALL_STATUSES.has(call.status)) {
826
+ this.fail(new TalkifCallError("pipeline-dead", `Backend reports terminal status '${call.status}'`));
827
+ return;
828
+ }
829
+ this.pipelineConfirmed = true;
830
+ } catch {
831
+ if (this.pipelineConfirmed || !this.pc) return;
832
+ if (this.pc.connectionState === "connected") {
833
+ this.pipelineConfirmed = true;
834
+ return;
835
+ }
836
+ this.fail(new TalkifCallError("pipeline-dead", "No health signal, REST unreachable, media not connected"));
837
+ }
838
+ }
839
+ // -- internals ------------------------------------------------------------
840
+ mustLocalSdp() {
841
+ const sdp = this.pc?.localDescription?.sdp;
842
+ if (!sdp) {
843
+ throw new TalkifCallError("signaling-failed", "Local SDP missing");
844
+ }
845
+ return sdp;
846
+ }
847
+ startDurationTimer() {
848
+ this._durationSecs = 0;
849
+ this.durationTimer = setInterval(() => {
850
+ this._durationSecs += 1;
851
+ this.emitter.emit("tick", { durationSecs: this._durationSecs });
852
+ }, 1e3);
853
+ }
854
+ fail(error) {
855
+ if (this._state === "ended" || this._state === "error") return;
856
+ this.teardown();
857
+ this.setState("error");
858
+ this.emitter.emit("error", { error });
859
+ }
860
+ setState(state) {
861
+ const previous = this._state;
862
+ if (previous === state) return;
863
+ this._state = state;
864
+ this.emitter.emit("statechange", { state, previous });
865
+ }
866
+ teardown() {
867
+ this.events?.close();
868
+ this.events = null;
869
+ if (this.healthTimer) {
870
+ clearTimeout(this.healthTimer);
871
+ this.healthTimer = null;
872
+ }
873
+ this.pipelineConfirmed = false;
874
+ if (this.durationTimer) {
875
+ clearInterval(this.durationTimer);
876
+ this.durationTimer = null;
877
+ }
878
+ this.dataChannel?.close();
879
+ this.dataChannel = null;
880
+ if (this.localStream) {
881
+ for (const track of this.localStream.getTracks()) track.stop();
882
+ this.localStream = null;
883
+ }
884
+ if (this.remoteAudio) {
885
+ this.remoteAudio.pause();
886
+ this.remoteAudio.srcObject = null;
887
+ if (this.ownsAudioElement) this.remoteAudio = null;
888
+ }
889
+ if (this.pc) {
890
+ this.pc.ontrack = null;
891
+ this.pc.onconnectionstatechange = null;
892
+ this.pc.close();
893
+ this.pc = null;
894
+ }
895
+ }
896
+ };
897
+ export {
898
+ CallEventsClient,
899
+ PublicSession,
900
+ SignalingClient,
901
+ SignalingError,
902
+ TERMINAL_CALL_STATUSES,
903
+ TalkifCall,
904
+ TalkifCallError,
905
+ TurnstileManager,
906
+ isPublicConfig
907
+ };
908
+ //# sourceMappingURL=index.js.map