@signalhousellc/sdk 1.0.52 → 1.0.54

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.
@@ -0,0 +1,160 @@
1
+ /**
2
+ * @typedef {Object} SipTrunkHost
3
+ * @property {string} host - IP address, hostname, FQDN, or CIDR.
4
+ * @property {string} [port] - Port number as a string (1025-65535). Destination hosts only.
5
+ */
6
+
7
+ /**
8
+ * @typedef {Object} CreateSipTrunkData
9
+ * @property {string} name - Trunk name (required, 1-255 chars).
10
+ * @property {string} region - SIP POP region the trunk lives in (required), e.g. "us-east-1".
11
+ * @property {"IP_AUTH"|"REGISTRATION"} connectionType - IP_AUTH authenticates by source-IP allow-list; REGISTRATION authenticates via SIP REGISTER with username/password.
12
+ * @property {string[]} [allowedIps] - Allowed source IPs (IP_AUTH only). Defaults to [].
13
+ * @property {"tcp"|"udp"|"tls"} [transport] - SIP transport protocol. Defaults to "tcp".
14
+ * @property {number} [maxSpendPerMinute] - Per-minute spend cap override for this trunk.
15
+ * @property {SipTrunkHost[]} [destinationHosts] - Where to send outbound SIP traffic. Defaults to [].
16
+ * @property {SipTrunkHost[]} [sourceHosts] - Allowed source hosts for inbound traffic. Defaults to [].
17
+ */
18
+
19
+ /**
20
+ * @typedef {Object} UpdateSipTrunkData
21
+ * @property {string} [name] - Trunk name (1-255 chars).
22
+ * @property {string} [region] - SIP POP region.
23
+ * @property {"IP_AUTH"|"REGISTRATION"} [connectionType] - Connection/auth type.
24
+ * @property {string[]} [allowedIps] - Allowed source IPs (IP_AUTH only).
25
+ * @property {"tcp"|"udp"|"tls"} [transport] - SIP transport protocol.
26
+ * @property {number} [maxSpendPerMinute] - Per-minute spend cap override.
27
+ * @property {SipTrunkHost[]} [destinationHosts] - Outbound SIP destinations.
28
+ * @property {SipTrunkHost[]} [sourceHosts] - Allowed inbound source hosts.
29
+ */
30
+
31
+ /**
32
+ * SIP Trunk management. Calls the voice-backend service mounted under /voice.
33
+ * Accessed via `sdk.voice.sipTrunks`.
34
+ */
35
+ export class SipTrunks {
36
+ constructor(client, enableAdmin) {
37
+ this.client = client;
38
+ this.enableAdmin = enableAdmin;
39
+ }
40
+
41
+ /**
42
+ * List all SIP trunks belonging to the current account.
43
+ * @async
44
+ * @roles api, admin, developer, billing, user
45
+ * @param {Object} [params] - Request parameters.
46
+ * @param {import('../../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request.
47
+ * @returns {Promise<Object>} The list of SIP trunks: `{ sipTrunks: [...] }`.
48
+ */
49
+ async list({ options = {} } = {}) {
50
+ return this.client(`/voice/sip-trunks`, { method: "GET", ...options });
51
+ }
52
+
53
+ /**
54
+ * Get a single SIP trunk by ID.
55
+ * @async
56
+ * @roles api, admin, developer, billing, user
57
+ * @param {Object} params - Request parameters.
58
+ * @param {string} params.id - The SIP trunk UUID.
59
+ * @param {import('../../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request.
60
+ * @throws {Error} If id is missing.
61
+ * @returns {Promise<Object>} The SIP trunk: `{ sipTrunk: {...} }`.
62
+ */
63
+ async get({ id, options = {} }) {
64
+ this.client._require({ id });
65
+ const safeId = encodeURIComponent(id);
66
+ return this.client(`/voice/sip-trunks/${safeId}`, { method: "GET", ...options });
67
+ }
68
+
69
+ /**
70
+ * Create a new SIP trunk.
71
+ * @async
72
+ * @roles api, admin, developer, billing, user
73
+ * @param {Object} params - Request parameters.
74
+ * @param {CreateSipTrunkData} params.trunkData - The SIP trunk to create.
75
+ * @param {import('../../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request.
76
+ * @throws {Error} If trunkData is missing.
77
+ * @returns {Promise<Object>} The created SIP trunk: `{ sipTrunk: {...} }`.
78
+ */
79
+ async create({ trunkData, options = {} }) {
80
+ this.client._require({ trunkData });
81
+ return this.client(`/voice/sip-trunks`, { method: "POST", body: trunkData, ...options });
82
+ }
83
+
84
+ /**
85
+ * Update an existing SIP trunk.
86
+ * @async
87
+ * @roles api, admin, developer, billing, user
88
+ * @param {Object} params - Request parameters.
89
+ * @param {string} params.id - The SIP trunk UUID.
90
+ * @param {UpdateSipTrunkData} params.updateData - Fields to update.
91
+ * @param {import('../../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request.
92
+ * @throws {Error} If id or updateData is missing.
93
+ * @returns {Promise<Object>} The updated SIP trunk: `{ sipTrunk: {...} }`.
94
+ */
95
+ async update({ id, updateData, options = {} }) {
96
+ this.client._require({ id, updateData });
97
+ const safeId = encodeURIComponent(id);
98
+ return this.client(`/voice/sip-trunks/${safeId}`, { method: "PATCH", body: updateData, ...options });
99
+ }
100
+
101
+ /**
102
+ * Delete a SIP trunk by ID.
103
+ * @async
104
+ * @roles api, admin, developer, billing, user
105
+ * @param {Object} params - Request parameters.
106
+ * @param {string} params.id - The SIP trunk UUID.
107
+ * @param {import('../../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request.
108
+ * @throws {Error} If id is missing.
109
+ * @returns {Promise<Object>} `{ success: boolean, message: string }`.
110
+ */
111
+ async delete({ id, options = {} }) {
112
+ this.client._require({ id });
113
+ const safeId = encodeURIComponent(id);
114
+ return this.client(`/voice/sip-trunks/${safeId}`, { method: "DELETE", ...options });
115
+ }
116
+
117
+ /**
118
+ * Toggle a SIP trunk's active/inactive status.
119
+ * @async
120
+ * @roles api, admin, developer, billing, user
121
+ * @param {Object} params - Request parameters.
122
+ * @param {string} params.id - The SIP trunk UUID.
123
+ * @param {import('../../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request.
124
+ * @throws {Error} If id is missing.
125
+ * @returns {Promise<Object>} The updated SIP trunk: `{ sipTrunk: {...} }`.
126
+ */
127
+ async toggleActive({ id, options = {} }) {
128
+ this.client._require({ id });
129
+ const safeId = encodeURIComponent(id);
130
+ return this.client(`/voice/sip-trunks/${safeId}/toggle-active`, { method: "POST", ...options });
131
+ }
132
+
133
+ /**
134
+ * Regenerate the SIP password for a REGISTRATION-type trunk.
135
+ * @async
136
+ * @roles api, admin, developer, billing, user
137
+ * @param {Object} params - Request parameters.
138
+ * @param {string} params.id - The SIP trunk UUID.
139
+ * @param {import('../../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request.
140
+ * @throws {Error} If id is missing.
141
+ * @returns {Promise<Object>} `{ password: string }`.
142
+ */
143
+ async regeneratePassword({ id, options = {} }) {
144
+ this.client._require({ id });
145
+ const safeId = encodeURIComponent(id);
146
+ return this.client(`/voice/sip-trunks/${safeId}/regenerate-password`, { method: "POST", ...options });
147
+ }
148
+
149
+ /**
150
+ * List available SIP Points of Presence (POPs) and their domains/ports.
151
+ * @async
152
+ * @roles api, admin, developer, billing, user
153
+ * @param {Object} [params] - Request parameters.
154
+ * @param {import('../../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request.
155
+ * @returns {Promise<Object>} `{ sipPops: [...] }`.
156
+ */
157
+ async getPops({ options = {} } = {}) {
158
+ return this.client(`/voice/sip-trunks/pops`, { method: "GET", ...options });
159
+ }
160
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * @typedef {Object} VoiceGrants
3
+ * @property {Object} [voice] - Voice grants object.
4
+ * @property {Object} [voice.incoming] - Inbound call grants.
5
+ * @property {boolean} [voice.incoming.allow] - Allow inbound calls to this identity.
6
+ * @property {Object} [voice.outgoing] - Outbound call grants.
7
+ * @property {string[]} [voice.outgoing.allowedNumbers] - E.164 numbers this identity may present as caller ID.
8
+ */
9
+
10
+ /**
11
+ * @typedef {Object} CreateTokenData
12
+ * @property {string} [identity] - Customer-facing logical identity name (e.g. "alice"). Persisted as `requested_identity` so server-initiated `calls.create({to_identity: "alice"})` can resolve it later. Defaults to "anonymous".
13
+ * @property {number} [ttl] - Token TTL in seconds. Clamped to the server's max. Defaults to VOICE_TOKEN_DEFAULT_TTL_SEC.
14
+ * @property {VoiceGrants} [grants] - Capability grants attached to the token.
15
+ */
16
+
17
+ /**
18
+ * @typedef {Object} SipCredentials
19
+ * @property {string} username - Server-generated SIP username (e.g. "wrtc_<hex>"). Use for the SIP-over-WSS registration.
20
+ * @property {string} password - Plaintext SIP password — sent ONCE, never recoverable. Use for digest auth on REGISTER + INVITE.
21
+ * @property {string} domain - SIP domain to register against (the part after `@` in the URI).
22
+ * @property {string} wss_url - WebSocket Secure URL the SDK opens to send SIP traffic.
23
+ */
24
+
25
+ /**
26
+ * @typedef {Object} CreateTokenResponse
27
+ * @property {string} token - JWT to pass to the SignalHouse browser voice SDK.
28
+ * @property {string} identity - The server-generated SIP username (wrtc_<hex>). Distinct from the logical `requested_identity` passed in.
29
+ * @property {string} expires_at - ISO-8601 expiry timestamp.
30
+ * @property {SipCredentials} sip_credentials - Ephemeral SIP creds embedded in the token; surfaced separately for direct SIP clients.
31
+ */
32
+
33
+ /**
34
+ * WebRTC voice tokens. Mints ephemeral SIP identities the browser/mobile voice
35
+ * SDK uses to register against the SignalHouse voice fabric. Wraps voice-
36
+ * backend's `/voice/tokens` endpoint. Accessed via `sdk.voice.tokens`.
37
+ *
38
+ * Customer flow:
39
+ * 1. Backend calls `sdk.voice.tokens.create({ identity: "alice", ttl: 1800 })`
40
+ * and returns the resulting `token` to the customer's browser/app.
41
+ * 2. Browser SDK registers using the embedded `sip_credentials`.
42
+ * 3. Outbound calls from the SDK go through OpenSIPS → Asterisk → carrier;
43
+ * the server-side `calls.create({ to_identity: "alice", ... })` flow can
44
+ * also ring this identity for click-to-call patterns.
45
+ */
46
+ export class Tokens {
47
+ constructor(client, enableAdmin) {
48
+ this.client = client;
49
+ this.enableAdmin = enableAdmin;
50
+ }
51
+
52
+ /**
53
+ * Mint an ephemeral voice token + SIP credentials.
54
+ * @async
55
+ * @roles api, admin, developer, billing, user
56
+ * @param {Object} [params] - Request parameters.
57
+ * @param {CreateTokenData} [params.tokenData] - Token mint options (identity, ttl, grants). All fields optional.
58
+ * @param {import('../../SignalHouseSDK').RequestOptions} [params.options] - Additional options for the request.
59
+ * @returns {Promise<CreateTokenResponse>} The token + embedded SIP credentials.
60
+ */
61
+ async create({ tokenData = {}, options = {} } = {}) {
62
+ return this.client(`/voice/tokens`, { method: "POST", body: tokenData, ...options });
63
+ }
64
+ }
@@ -0,0 +1,364 @@
1
+ /**
2
+ * @typedef {"inbound"|"outbound"} CallDirection
3
+ * @typedef {"initial"|"ringing"|"in_progress"|"ended"|"failed"} CallStatus
4
+ */
5
+
6
+ /**
7
+ * Wraps a single SIP session — outbound or inbound — and exposes a
8
+ * Twilio-shape Call API on top of the underlying JsSIP RTCSession.
9
+ *
10
+ * Lifecycle (outbound):
11
+ * new (status="initial") → ringing → in_progress → ended | failed
12
+ *
13
+ * Lifecycle (inbound, before .accept()):
14
+ * new (status="ringing") → in_progress (on accept) → ended | failed
15
+ *
16
+ * Custom SIP headers from inbound INVITEs are exposed on the instance
17
+ * (notably `initiatorId` from `X-SignalHouse-Initiator`) so the host app
18
+ * can correlate a server-initiated callback to its triggering REST call.
19
+ *
20
+ * Methods reject when the session is no longer in a state that supports
21
+ * them (e.g., `.accept()` on an already-accepted call).
22
+ */
23
+ export class Call {
24
+ /**
25
+ * @param {Object} init - Initializer.
26
+ * @param {Object} init.session - The JsSIP RTCSession.
27
+ * @param {CallDirection} init.direction
28
+ * @param {Object} [init.request] - Inbound INVITE request, when direction === "inbound".
29
+ */
30
+ constructor({ session, direction, request }) {
31
+ this._session = session;
32
+ this._listeners = new Map();
33
+ this._remoteAudio = null;
34
+
35
+ this.direction = direction;
36
+ this.id = session.id || session._request?.call_id || null;
37
+ this.status = direction === "inbound" ? "ringing" : "initial";
38
+
39
+ // From / To URIs. For inbound the session._request carries them; for
40
+ // outbound JsSIP populates from session.remote_identity / local_identity
41
+ // once the dialog is established.
42
+ this.from = session.remote_identity?.uri?.toString?.() ?? request?.from?.uri?.toString?.() ?? null;
43
+ this.to = session.local_identity?.uri?.toString?.() ?? request?.to?.uri?.toString?.() ?? null;
44
+
45
+ // Surface useful inbound headers. Most important: X-SignalHouse-Initiator,
46
+ // which carries the call_id of the REST POST /v1/calls that triggered a
47
+ // server-side two-leg-with-bridge ring. Host app can compare against its
48
+ // pending dial set to decide whether to auto-accept.
49
+ this.headers = {};
50
+ if (request && typeof request.getHeader === "function") {
51
+ for (const name of [
52
+ "X-SignalHouse-Initiator",
53
+ "X-Call-UUID",
54
+ "X-Call-Type",
55
+ "X-Route-Type",
56
+ "X-Dest",
57
+ ]) {
58
+ const v = request.getHeader(name);
59
+ if (v) this.headers[name] = v;
60
+ }
61
+ }
62
+ this.initiatorId = this.headers["X-SignalHouse-Initiator"] || null;
63
+ this.callUuid = this.headers["X-Call-UUID"] || null;
64
+
65
+ this._wireSessionEvents();
66
+ }
67
+
68
+ // ─── Public API ─────────────────────────────────────────────────────────
69
+
70
+ /**
71
+ * Accept an inbound call. No-op (warning) if outbound or already accepted.
72
+ * @param {Object} [opts]
73
+ * @param {MediaStream} [opts.mediaStream] - Pre-acquired stream from getUserMedia.
74
+ * If omitted, the SDK calls getUserMedia({audio: true}) and uses the result.
75
+ * @param {RTCConfiguration} [opts.pcConfig] - WebRTC peer-connection config.
76
+ * Falls back to {iceServers: [{urls: "stun:stun.l.google.com:19302"}]}.
77
+ * @returns {Promise<void>}
78
+ */
79
+ async accept({ mediaStream, pcConfig } = {}) {
80
+ if (this.direction !== "inbound") {
81
+ console.warn("[Call.accept] no-op: only inbound calls can be accepted");
82
+ return;
83
+ }
84
+ if (this.status !== "ringing") {
85
+ console.warn(`[Call.accept] no-op: call is not ringing (status=${this.status})`);
86
+ return;
87
+ }
88
+ const stream = mediaStream || (await navigator.mediaDevices.getUserMedia({ audio: true, video: false }));
89
+ this._session.answer({
90
+ mediaConstraints: { audio: true, video: false },
91
+ mediaStream: stream,
92
+ // Default: no STUN (rtpengine trust-address handles NAT). Override via pcConfig.
93
+ pcConfig: pcConfig || { iceServers: [] },
94
+ });
95
+ }
96
+
97
+ /**
98
+ * Reject an inbound call with 486 Busy (or supplied SIP code).
99
+ * @param {Object} [opts]
100
+ * @param {number} [opts.statusCode=486]
101
+ * @param {string} [opts.reason="Busy Here"]
102
+ */
103
+ reject({ statusCode = 486, reason = "Busy Here" } = {}) {
104
+ if (this.direction !== "inbound" || this.status !== "ringing") {
105
+ console.warn("[Call.reject] no-op: only ringing inbound calls can be rejected");
106
+ return;
107
+ }
108
+ try {
109
+ this._session.terminate({ status_code: statusCode, reason_phrase: reason });
110
+ } catch (err) {
111
+ // JsSIP throws if the session is already gone — non-fatal here
112
+ console.warn(`[Call.reject] terminate threw: ${err?.message || err}`);
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Hang up an in-progress or ringing outbound call. Idempotent.
118
+ */
119
+ hangup() {
120
+ if (this.status === "ended" || this.status === "failed") return;
121
+ try {
122
+ this._session.terminate();
123
+ } catch (err) {
124
+ console.warn(`[Call.hangup] terminate threw: ${err?.message || err}`);
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Send DTMF digits during an in-progress call. Accepts 0-9, *, #, w (wait).
130
+ * @param {string} digits
131
+ */
132
+ sendDigits(digits) {
133
+ if (this.status !== "in_progress") {
134
+ console.warn(`[Call.sendDigits] no-op: call is not in_progress (status=${this.status})`);
135
+ return;
136
+ }
137
+ try {
138
+ this._session.sendDTMF(String(digits));
139
+ } catch (err) {
140
+ console.warn(`[Call.sendDigits] sendDTMF threw: ${err?.message || err}`);
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Mute or unmute the local microphone. Twilio-shape: pass `true` to mute,
146
+ * `false` to unmute. Stops/resumes sending audio RTP at the media layer —
147
+ * no SIP signaling, so it works regardless of server support.
148
+ * @param {boolean} [shouldMute=true]
149
+ */
150
+ mute(shouldMute = true) {
151
+ if (this.status !== "in_progress") {
152
+ console.warn(`[Call.mute] no-op: call is not in_progress (status=${this.status})`);
153
+ return;
154
+ }
155
+ try {
156
+ if (shouldMute) this._session.mute({ audio: true });
157
+ else this._session.unmute({ audio: true });
158
+ } catch (err) {
159
+ console.warn(`[Call.mute] threw: ${err?.message || err}`);
160
+ }
161
+ }
162
+
163
+ /** Unmute the local microphone. Convenience for `mute(false)`. */
164
+ unmute() {
165
+ this.mute(false);
166
+ }
167
+
168
+ /**
169
+ * @returns {boolean} whether the local microphone is currently muted.
170
+ */
171
+ isMuted() {
172
+ try {
173
+ return Boolean(this._session.isMuted?.()?.audio);
174
+ } catch {
175
+ return false;
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Put the call on hold, or resume it. Sends a re-INVITE with the audio
181
+ * direction set to sendonly (hold) / sendrecv (resume); the media server
182
+ * (rtpengine) handles it. Pass `false` to resume.
183
+ * @param {boolean} [shouldHold=true]
184
+ * @returns {boolean} whether the (un)hold was initiated.
185
+ */
186
+ hold(shouldHold = true) {
187
+ if (this.status !== "in_progress") {
188
+ console.warn(`[Call.hold] no-op: call is not in_progress (status=${this.status})`);
189
+ return false;
190
+ }
191
+ try {
192
+ return shouldHold ? this._session.hold() : this._session.unhold();
193
+ } catch (err) {
194
+ console.warn(`[Call.hold] threw: ${err?.message || err}`);
195
+ return false;
196
+ }
197
+ }
198
+
199
+ /** Resume a held call. Convenience for `hold(false)`. */
200
+ unhold() {
201
+ return this.hold(false);
202
+ }
203
+
204
+ /**
205
+ * @returns {boolean} whether this leg is locally on hold.
206
+ */
207
+ isOnHold() {
208
+ try {
209
+ return Boolean(this._session.isOnHold?.()?.local);
210
+ } catch {
211
+ return false;
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Blind-transfer (cold transfer) the call to another destination via SIP
217
+ * REFER. The far end is re-routed to `target` and this leg ends once the
218
+ * transfer is accepted. `target` may be a phone number (E.164 or bare
219
+ * digits) or a full SIP URI — bare numbers are normalized against the
220
+ * registered SIP domain by the underlying stack.
221
+ *
222
+ * NOTE: completing the transfer requires the SIP server to honor the REFER
223
+ * (route the new leg / bridge the parties). The client only initiates it.
224
+ * @param {string} target - Destination number or SIP URI.
225
+ */
226
+ transfer(target) {
227
+ if (this.status !== "in_progress") {
228
+ console.warn(`[Call.transfer] no-op: call is not in_progress (status=${this.status})`);
229
+ return;
230
+ }
231
+ if (!target) {
232
+ console.warn("[Call.transfer] no-op: a transfer target is required");
233
+ return;
234
+ }
235
+ try {
236
+ this._session.refer(target);
237
+ } catch (err) {
238
+ console.warn(`[Call.transfer] refer threw: ${err?.message || err}`);
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Attach an event listener. Events:
244
+ * "accepted" — 200 OK received (outbound) or sent (inbound after accept)
245
+ * "confirmed" — ACK exchanged, media flowing
246
+ * "ended" — normal hangup; payload: {cause}
247
+ * "failed" — error before/during; payload: {cause, status_code, reason}
248
+ * "track" — remote MediaStreamTrack added; payload: {track, streams}
249
+ * @param {string} event
250
+ * @param {Function} handler
251
+ */
252
+ on(event, handler) {
253
+ if (!this._listeners.has(event)) this._listeners.set(event, new Set());
254
+ this._listeners.get(event).add(handler);
255
+ }
256
+
257
+ /**
258
+ * Remove a previously-registered listener.
259
+ */
260
+ off(event, handler) {
261
+ this._listeners.get(event)?.delete(handler);
262
+ }
263
+
264
+ // ─── Internal: wire JsSIP RTCSession events to Call events ───────────────
265
+
266
+ _emit(event, payload) {
267
+ for (const fn of this._listeners.get(event) || []) {
268
+ try { fn(payload); }
269
+ catch (err) { console.error(`[Call.${event} handler]`, err); }
270
+ }
271
+ }
272
+
273
+ _wireSessionEvents() {
274
+ const s = this._session;
275
+
276
+ // Attach the RTCPeerConnection "track" listener as soon as the PC
277
+ // exists. The browser can fire `track` when the remote SDP is applied,
278
+ // which happens BEFORE JsSIP emits "confirmed" — waiting until then
279
+ // can drop the event entirely (call connects, no audio).
280
+ //
281
+ // For outbound calls JsSIP creates the PC inside `ua.call()`, so
282
+ // session.connection may already exist by the time our Call is
283
+ // constructed — handle that synchronously. For inbound calls the PC
284
+ // is created inside session.answer(); JsSIP emits "peerconnection"
285
+ // when it's ready — attach there.
286
+ if (s.connection) this._attachTrackListener(s.connection);
287
+ s.on("peerconnection", (e) => {
288
+ const pc = e?.peerconnection || s.connection;
289
+ if (pc) this._attachTrackListener(pc);
290
+ });
291
+
292
+ s.on("progress", (e) => {
293
+ this.status = "ringing";
294
+ this._emit("progress", { response: e.response });
295
+ });
296
+
297
+ s.on("accepted", () => {
298
+ this.status = "in_progress";
299
+ this._emit("accepted");
300
+ });
301
+
302
+ s.on("confirmed", () => {
303
+ this.status = "in_progress";
304
+ // Defensive: in case "peerconnection" never fired but a PC exists by now.
305
+ if (this._session.connection) this._attachTrackListener(this._session.connection);
306
+ this._emit("confirmed");
307
+ });
308
+
309
+ s.on("ended", (e) => {
310
+ this.status = "ended";
311
+ this._cleanupRemoteAudio();
312
+ this._emit("ended", { cause: e?.cause });
313
+ });
314
+
315
+ s.on("failed", (e) => {
316
+ this.status = "failed";
317
+ this._cleanupRemoteAudio();
318
+ this._emit("failed", {
319
+ cause: e?.cause,
320
+ statusCode: e?.message?.status_code,
321
+ reason: e?.message?.reason_phrase,
322
+ });
323
+ });
324
+ }
325
+
326
+ /**
327
+ * Idempotent — wires the RTCPeerConnection "track" handler. Uses
328
+ * addEventListener (not pc.ontrack) so host apps can attach their own
329
+ * track listener without clobbering ours, and vice versa.
330
+ *
331
+ * When a remote track arrives:
332
+ * - Emit "track" so the host app can route audio anywhere it wants.
333
+ * - If `call.disableDefaultAudioOutput` is falsy, attach the stream to
334
+ * a hidden <audio> element so the user just hears the call. Host
335
+ * apps that want custom audio routing should set
336
+ * `call.disableDefaultAudioOutput = true` before accepting/connecting.
337
+ */
338
+ _attachTrackListener(pc) {
339
+ if (this._trackListenerAttached) return;
340
+ this._trackListenerAttached = true;
341
+
342
+ pc.addEventListener("track", (evt) => {
343
+ this._emit("track", { track: evt.track, streams: evt.streams });
344
+ if (this.disableDefaultAudioOutput) return;
345
+ if (!this._remoteAudio) {
346
+ this._remoteAudio = document.createElement("audio");
347
+ this._remoteAudio.autoplay = true;
348
+ this._remoteAudio.setAttribute("data-signalhouse-call", this.id || "");
349
+ document.body.appendChild(this._remoteAudio);
350
+ }
351
+ this._remoteAudio.srcObject = evt.streams[0];
352
+ });
353
+ }
354
+
355
+ _cleanupRemoteAudio() {
356
+ if (this._remoteAudio) {
357
+ try {
358
+ this._remoteAudio.srcObject = null;
359
+ this._remoteAudio.remove();
360
+ } catch {}
361
+ this._remoteAudio = null;
362
+ }
363
+ }
364
+ }