From a95a03db914c20cb32db6bf91f839a39a6560692 Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Fri, 24 Apr 2026 12:04:18 -0400 Subject: [PATCH 01/18] feat: add connectStatus to readyMetadata on connectStatus event --- package-lock.json | 12 ------------ src/types.ts | 18 ++++++++++++++++++ src/v1/signaling.test.ts | 36 ++++++++++++++++++++++++++++-------- src/v1/signaling.ts | 17 +++++++++++++++-- src/v1/types.ts | 11 ++++++++++- 5 files changed, 71 insertions(+), 23 deletions(-) diff --git a/package-lock.json b/package-lock.json index 70d4180..a90474d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -88,7 +88,6 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -1981,7 +1980,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -2005,7 +2003,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2843,7 +2840,6 @@ "integrity": "sha512-r0bBaXu5Swb05doFYO2kTWHMovJnNVbCsII0fhesM8bNRlLhXIuckley4a2DaD+vOdmm5G+zGkQZAPZsF80+YQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3396,7 +3392,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3446,7 +3441,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3775,7 +3769,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5701,7 +5694,6 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -7327,7 +7319,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -7425,7 +7416,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7660,7 +7650,6 @@ "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -7710,7 +7699,6 @@ "integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.6.1", "@webpack-cli/configtest": "^3.0.1", diff --git a/src/types.ts b/src/types.ts index 0a2bb04..90c9bc1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -21,6 +21,15 @@ export enum EndpointType { PHONE_NUMBER = "PHONE_NUMBER", } +export enum ConnectStatus { + INITIATED = "INITIATED", + COMPLETED = "COMPLETED", + TIMED_OUT = "TIMED_OUT", + DENIED = "DENIED", + CANCELED = "CANCELED", + FAILED = "FAILED", +} + export type AudioLevelChangeHandler = { (audioLevel: AudioLevel): void }; /** @@ -49,6 +58,15 @@ export interface ReadyMetadata { deviceId: string; territory: string; // TODO enum region: string; // TODO enum + connectStatus?: ConnectStatus; + accountId?: string; + sessionId?: string; + from?: string; + fromType?: string; + fromTags?: string; + to?: string; + toType?: string; + toTags?: string; } export interface OutboundConnectionResult { diff --git a/src/v1/signaling.test.ts b/src/v1/signaling.test.ts index cdbb933..463fd6f 100644 --- a/src/v1/signaling.test.ts +++ b/src/v1/signaling.test.ts @@ -94,19 +94,39 @@ describe("Signaling connect method", () => { } }); - test("should emit established when websocket receives established", async () => { + test("should merge connectStatus into readyMetadata and re-emit ready", async () => { const emitSpy = jest.spyOn(signaling, "emit"); await signaling.connect({ endpointToken: "test-token" }); - // Get the websocket instance and trigger established event const ws = (signaling as any).ws; - const establishedCallback = ws.on.mock.calls.find((call: any) => call[0] === "established")?.[1]; - - if (establishedCallback) { - const testEvent = { connectionId: "test-connection" }; - establishedCallback(testEvent); - expect(emitSpy).toHaveBeenCalledWith("established", testEvent); + const connectStatusCallback = ws.on.mock.calls.find((call: any) => call[0] === "connectStatus")?.[1]; + + if (connectStatusCallback) { + const testEvent = { + status: "COMPLETED", + accountId: "9900000", + sessionId: "session-1", + from: "ep-1", + fromType: "ENDPOINT", + fromTags: "tag1", + to: "ep-2", + toType: "ENDPOINT", + toTags: "tag2", + }; + connectStatusCallback(testEvent); + expect(emitSpy).toHaveBeenCalledWith("ready", expect.objectContaining({ + endpointId: "test-endpoint", + connectStatus: "COMPLETED", + accountId: "9900000", + sessionId: "session-1", + from: "ep-1", + fromType: "ENDPOINT", + fromTags: "tag1", + to: "ep-2", + toType: "ENDPOINT", + toTags: "tag2", + })); } }); }); diff --git a/src/v1/signaling.ts b/src/v1/signaling.ts index 36ed864..e855c9e 100644 --- a/src/v1/signaling.ts +++ b/src/v1/signaling.ts @@ -52,8 +52,21 @@ class Signaling extends EventEmitter { this.emit("sdpOffer", event); }); - ws.on("established", (event: any) => { - this.emit("established", event); + ws.on("connectStatus", (event: any) => { + logger.debug("Websocket connectStatus", event); + this.readyMetadata = { + ...this.readyMetadata!, + connectStatus: event.status, + accountId: event.accountId, + sessionId: event.sessionId, + from: event.from, + fromType: event.fromType, + fromTags: event.fromTags, + to: event.to, + toType: event.toType, + toTags: event.toTags, + }; + this.emit("ready", this.readyMetadata); }); ws.on("open", async () => { diff --git a/src/v1/types.ts b/src/v1/types.ts index 7c0d18a..42776fa 100644 --- a/src/v1/types.ts +++ b/src/v1/types.ts @@ -1,4 +1,4 @@ -import { MediaType } from "../types"; +import { MediaType, ConnectStatus } from "../types"; export interface SetMediaPreferencesWebRtcResponse { endpointId: string; @@ -69,4 +69,13 @@ export interface ReadyMetadata { deviceId: string; territory: string; // TODO enum region: string; // TODO enum + connectStatus?: ConnectStatus; + accountId?: string; + sessionId?: string; + from?: string; + fromType?: string; + fromTags?: string; + to?: string; + toType?: string; + toTags?: string; } From af93d6e98b5a3e91edd03f7af0ef501c4dae848c Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Mon, 27 Apr 2026 10:29:30 -0400 Subject: [PATCH 02/18] feat(rtc): add onConnectStatus event handler Emit connectStatus event separately from ready so callers can subscribe to connect status updates independently via onConnectStatus. --- src/v1/bandwidthRtc.ts | 1 + src/v1/signaling.test.ts | 42 ++++++++++++++++++++++++---------------- src/v1/signaling.ts | 16 --------------- 3 files changed, 26 insertions(+), 33 deletions(-) diff --git a/src/v1/bandwidthRtc.ts b/src/v1/bandwidthRtc.ts index dab9d9f..a628815 100644 --- a/src/v1/bandwidthRtc.ts +++ b/src/v1/bandwidthRtc.ts @@ -147,6 +147,7 @@ export class BandwidthRtc { this.readyHandler = callback; } + /** * Publish media to the Bandwidth WebRTC platform * diff --git a/src/v1/signaling.test.ts b/src/v1/signaling.test.ts index 463fd6f..eb408b9 100644 --- a/src/v1/signaling.test.ts +++ b/src/v1/signaling.test.ts @@ -94,29 +94,21 @@ describe("Signaling connect method", () => { } }); - test("should merge connectStatus into readyMetadata and re-emit ready", async () => { + test("should emit ready with connectStatus fields when gateway sends ready with connect status data", async () => { const emitSpy = jest.spyOn(signaling, "emit"); await signaling.connect({ endpointToken: "test-token" }); + // Simulate a second ready event from the gateway that includes connect status fields const ws = (signaling as any).ws; - const connectStatusCallback = ws.on.mock.calls.find((call: any) => call[0] === "connectStatus")?.[1]; + const readyCallback = ws.on.mock.calls.find((call: any) => call[0] === "ready")?.[1]; - if (connectStatusCallback) { - const testEvent = { - status: "COMPLETED", - accountId: "9900000", - sessionId: "session-1", - from: "ep-1", - fromType: "ENDPOINT", - fromTags: "tag1", - to: "ep-2", - toType: "ENDPOINT", - toTags: "tag2", - }; - connectStatusCallback(testEvent); - expect(emitSpy).toHaveBeenCalledWith("ready", expect.objectContaining({ + if (readyCallback) { + const readyWithConnectStatus = { endpointId: "test-endpoint", + deviceId: "device-1", + territory: "US", + region: "us-east-1", connectStatus: "COMPLETED", accountId: "9900000", sessionId: "session-1", @@ -126,7 +118,23 @@ describe("Signaling connect method", () => { to: "ep-2", toType: "ENDPOINT", toTags: "tag2", - })); + }; + readyCallback(readyWithConnectStatus); + expect(emitSpy).toHaveBeenCalledWith( + "ready", + expect.objectContaining({ + endpointId: "test-endpoint", + connectStatus: "COMPLETED", + accountId: "9900000", + sessionId: "session-1", + from: "ep-1", + fromType: "ENDPOINT", + fromTags: "tag1", + to: "ep-2", + toType: "ENDPOINT", + toTags: "tag2", + }), + ); } }); }); diff --git a/src/v1/signaling.ts b/src/v1/signaling.ts index e855c9e..ac13aa6 100644 --- a/src/v1/signaling.ts +++ b/src/v1/signaling.ts @@ -52,22 +52,6 @@ class Signaling extends EventEmitter { this.emit("sdpOffer", event); }); - ws.on("connectStatus", (event: any) => { - logger.debug("Websocket connectStatus", event); - this.readyMetadata = { - ...this.readyMetadata!, - connectStatus: event.status, - accountId: event.accountId, - sessionId: event.sessionId, - from: event.from, - fromType: event.fromType, - fromTags: event.fromTags, - to: event.to, - toType: event.toType, - toTags: event.toTags, - }; - this.emit("ready", this.readyMetadata); - }); ws.on("open", async () => { logger.debug("Websocket open"); From e2567ea871e961f9f3ebc63d3fdb836bd10197be Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Mon, 27 Apr 2026 10:33:52 -0400 Subject: [PATCH 03/18] style: remove extra blank lines in bandwidthRtc.ts and signaling.ts --- src/v1/bandwidthRtc.ts | 1 - src/v1/signaling.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/src/v1/bandwidthRtc.ts b/src/v1/bandwidthRtc.ts index a628815..dab9d9f 100644 --- a/src/v1/bandwidthRtc.ts +++ b/src/v1/bandwidthRtc.ts @@ -147,7 +147,6 @@ export class BandwidthRtc { this.readyHandler = callback; } - /** * Publish media to the Bandwidth WebRTC platform * diff --git a/src/v1/signaling.ts b/src/v1/signaling.ts index ac13aa6..6194473 100644 --- a/src/v1/signaling.ts +++ b/src/v1/signaling.ts @@ -52,7 +52,6 @@ class Signaling extends EventEmitter { this.emit("sdpOffer", event); }); - ws.on("open", async () => { logger.debug("Websocket open"); if (globalThis.addEventListener) { From 0ca235cf5ab39f2d1b951f1ecee71f25042c04cb Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Mon, 27 Apr 2026 10:52:49 -0400 Subject: [PATCH 04/18] test(signaling): add websocket event handler and disconnect tests --- src/v1/signaling.test.ts | 105 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/src/v1/signaling.test.ts b/src/v1/signaling.test.ts index eb408b9..477716b 100644 --- a/src/v1/signaling.test.ts +++ b/src/v1/signaling.test.ts @@ -139,6 +139,111 @@ describe("Signaling connect method", () => { }); }); +describe("Signaling websocket event handlers", () => { + let signaling: Signaling; + beforeEach(async () => { + signaling = new Signaling(); + await signaling.connect({ endpointToken: "test-token" }); + }); + + function getWsCallback(event: string) { + const ws = (signaling as any).ws; + return ws.on.mock.calls.find((call: any) => call[0] === event)?.[1]; + } + + test("should emit init and set up ping interval on open", async () => { + const emitSpy = jest.spyOn(signaling, "emit"); + const openCallback = getWsCallback("open"); + expect(openCallback).toBeDefined(); + + await openCallback(); + + expect(emitSpy).toHaveBeenCalledWith("init", expect.anything()); + expect((signaling as any).pingInterval).toBeDefined(); + }); + + test("should reject with error and disconnect on 403 error", async () => { + const errorCallback = getWsCallback("error"); + expect(errorCallback).toBeDefined(); + + const ws = (signaling as any).ws; + errorCallback({ message: "Unexpected server response: 403" }); + + expect(ws.close).toHaveBeenCalledWith(403); + expect(ws.setAutoReconnect).toHaveBeenCalledWith(false); + }); + + test("should handle non-403 error without throwing", async () => { + const errorCallback = getWsCallback("error"); + expect(errorCallback).toBeDefined(); + + // Should not throw on a generic error + expect(() => errorCallback({ message: "some other error" })).not.toThrow(); + + // ws should not be closed on non-403 errors + const ws = (signaling as any).ws; + expect(ws.setAutoReconnect).not.toHaveBeenCalled(); + }); + + test("should clear ping interval and set isReady false on close", async () => { + // Trigger open first to set up pingInterval + const openCallback = getWsCallback("open"); + await openCallback(); + + const closeCallback = getWsCallback("close"); + expect(closeCallback).toBeDefined(); + + closeCallback(4000); + + expect((signaling as any).isReady).toBe(false); + }); + + test("should call _disconnect on close with code 1000", async () => { + const closeCallback = getWsCallback("close"); + expect(closeCallback).toBeDefined(); + + closeCallback(1000); + + // After _disconnect(false), ws should be null + expect((signaling as any).ws).toBeNull(); + expect((signaling as any).isReady).toBe(false); + }); +}); + +describe("Signaling disconnect", () => { + test("should call leave notification and close ws on disconnect", async () => { + const signaling = new Signaling(); + await signaling.connect({ endpointToken: "test-token" }); + + const ws = (signaling as any).ws; + signaling.disconnect(); + + expect(ws.notify).toHaveBeenCalledWith("leave"); + expect(ws.close).toHaveBeenCalled(); + expect(ws.removeAllListeners).toHaveBeenCalled(); + expect((signaling as any).ws).toBeNull(); + expect((signaling as any).isReady).toBe(false); + }); + + test("should handle disconnect with diagnosticsBatcher", async () => { + const diagnosticsBatcher = new DiagnosticsBatcher(); + const shutdownSpy = jest.spyOn(diagnosticsBatcher, "shutdown"); + const signaling = new Signaling(diagnosticsBatcher); + await signaling.connect({ endpointToken: "test-token" }); + + signaling.disconnect(); + + expect(shutdownSpy).toHaveBeenCalled(); + expect((signaling as any).ws).toBeNull(); + }); + + test("should not throw when disconnect called without active ws", () => { + const signaling = new Signaling(); + // Never connected, ws is null + expect(() => signaling.disconnect()).not.toThrow(); + }); +}); + describe("Signaling test all the smaller functions", () => { let signaling: Signaling; beforeEach(async () => { From e13ceb6211bd355db739457efe173b93d4edace5 Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Tue, 2 Jun 2026 09:25:32 -0400 Subject: [PATCH 05/18] feat: wire streamAvailable/streamUnavailable WS notifications to onStreamAvailable callback - RtcStream.mediaStream and callId are now optional: notification fires first with callId (no mediaStream) for call-arrival UI, then again via WebRTC ontrack with mediaStream when audio flows - signaling.ts subscribes to streamAvailable/streamUnavailable WS events and emits them for BandwidthRtc to forward to the app's handler - Adds acceptStream(callId?) and declineStream(callId?) on BandwidthRtc and Signaling so apps can explicitly accept or decline an inbound stream - Non-null assertions on setMicEnabled/setCameraEnabled/unpublish callers that always pass publish()-originated streams Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/types.ts | 3 ++- src/v1/bandwidthRtc.ts | 24 +++++++++++++++++++++--- src/v1/signaling.ts | 18 ++++++++++++++++++ 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/types.ts b/src/types.ts index 90c9bc1..2419603 100644 --- a/src/types.ts +++ b/src/types.ts @@ -48,7 +48,8 @@ export interface RtcOptions { export interface RtcStream { mediaTypes: MediaType[]; - mediaStream: MediaStream; + mediaStream?: MediaStream; + callId?: string; } export class BandwidthRtcError extends Error {} diff --git a/src/v1/bandwidthRtc.ts b/src/v1/bandwidthRtc.ts index dab9d9f..1c5bced 100644 --- a/src/v1/bandwidthRtc.ts +++ b/src/v1/bandwidthRtc.ts @@ -109,6 +109,16 @@ export class BandwidthRtc { this.signaling.on("ready", this.handleReady.bind(this)); this.signaling.on("sdpOffer", this.handleSubscribeSdpOffer.bind(this)); this.signaling.on("init", this.init.bind(this)); + this.signaling.on("streamAvailable", ({ callId }: { callId: string }) => { + if (this.streamAvailableHandler) { + this.streamAvailableHandler({ mediaTypes: [MediaType.AUDIO], callId }); + } + }); + this.signaling.on("streamUnavailable", ({ callId }: { callId: string }) => { + if (this.streamUnavailableHandler) { + this.streamUnavailableHandler({ mediaTypes: [MediaType.AUDIO], callId }); + } + }); await this.signaling.connect(authParams, options); logger.info("Successfully connected"); @@ -229,7 +239,7 @@ export class BandwidthRtc { } } else { publishedStreams.push({ - mediaStream: stream.mediaStream, + mediaStream: stream.mediaStream!, }); } } @@ -294,7 +304,7 @@ export class BandwidthRtc { setMicEnabled(enabled: boolean, stream?: RtcStream | string) { logger.info(`Setting microphone enabled: ${enabled}`); if (stream && typeof stream !== "string") { - stream = stream.mediaStream.id; + stream = stream.mediaStream!.id; } [...this.publishedStreams] .filter(([msid]) => !stream || stream === msid) @@ -309,7 +319,7 @@ export class BandwidthRtc { setCameraEnabled(enabled: boolean, stream?: RtcStream | string) { logger.info(`Setting camera enabled: ${enabled}`); if (stream && typeof stream !== "string") { - stream = stream.mediaStream.id; + stream = stream.mediaStream!.id; } [...this.publishedStreams] .filter(([msid]) => !stream || stream === msid) @@ -337,6 +347,14 @@ export class BandwidthRtc { return this.signaling.hangupConnection(endpoint, type); } + acceptStream(callId?: string): Promise { + return this.signaling.acceptStream(callId); + } + + declineStream(callId?: string): Promise { + return this.signaling.declineStream(callId); + } + private async offerPublishSdp(restartIce: boolean = false): Promise { if (!this.publishingPeerConnection) { throw new BandwidthRtcError("No publishing RTCPeerConnection, cannot offer SDP"); diff --git a/src/v1/signaling.ts b/src/v1/signaling.ts index 6194473..df5a137 100644 --- a/src/v1/signaling.ts +++ b/src/v1/signaling.ts @@ -79,6 +79,14 @@ class Signaling extends EventEmitter { resolve(); }); + ws.on("streamAvailable", (event: { callId: string; endpointId: string }) => { + this.emit("streamAvailable", event); + }); + + ws.on("streamUnavailable", (event: { callId: string; endpointId: string }) => { + this.emit("streamUnavailable", event); + }); + ws.on("error", (error: ErrorEvent) => { if (error.message === "Unexpected server response: 403") { logger.error("Authentication error: Invalid token"); @@ -169,6 +177,16 @@ class Signaling extends EventEmitter { }) as Promise; } + acceptStream(callId?: string): Promise { + logger.debug(`Calling "acceptStream"`, { callId }); + return this.ws?.call("acceptStream", callId ? { callId } : {}) as Promise; + } + + declineStream(callId?: string): Promise { + logger.debug(`Calling "declineStream"`, { callId }); + return this.ws?.call("declineStream", callId ? { callId } : {}) as Promise; + } + offerSdp(peerType: string, sdpOffer: string): Promise { logger.debug(`Calling "offerSdp"`, { sdpOffer: sdpOffer, peerType: peerType }); return this.ws?.call("offerSdp", { sdpOffer: sdpOffer, peerType: peerType }) as Promise; From be447ae5907ccbfd33045659386a469782285b4b Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Tue, 2 Jun 2026 09:34:55 -0400 Subject: [PATCH 06/18] feat: expose acceptStream/declineStream on outer BandwidthRtc class Delegates to v1 BandwidthRtcV1. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/bandwidthRtc.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/bandwidthRtc.ts b/src/bandwidthRtc.ts index cc78111..4cec567 100644 --- a/src/bandwidthRtc.ts +++ b/src/bandwidthRtc.ts @@ -257,6 +257,20 @@ class BandwidthRtc { } return this.delegate.hangupConnection(endpoint, type); } + + acceptStream(callId?: string): Promise { + if (!this.delegate) { + throw new BandwidthRtcError("You must call 'connect' before 'acceptStream'"); + } + return this.delegate.acceptStream(callId); + } + + declineStream(callId?: string): Promise { + if (!this.delegate) { + throw new BandwidthRtcError("You must call 'connect' before 'declineStream'"); + } + return this.delegate.declineStream(callId); + } } interface JwtPayload { From cb7b22659eef76df1d5e2b9b74b895c28ca00631 Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Tue, 2 Jun 2026 09:39:57 -0400 Subject: [PATCH 07/18] fix: add types field to package.json for TypeScript consumers Without an explicit types entry, CRA's compiler couldn't resolve declaration files from the dist/ directory via the npm link symlink. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 56a7333..61fe4fb 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "0.0.1", "description": "SDK for BandwidthRTC Node Applications", "main": "dist/index.js", + "types": "dist/index.d.ts", "scripts": { "build": "rm -rf ./dist/* && prettier --check . && tsc && webpack --config webpack.prod.js", "build:dev": "rm -rf ./dist/* && prettier --check . && tsc && webpack --config webpack.dev.js", From 1b86b4e711ce973c452c19db27348d42a055f277 Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Tue, 2 Jun 2026 09:47:16 -0400 Subject: [PATCH 08/18] fix: disable sourceMap output to prevent source-map-loader errors in consumers When installed via file: reference, CRA's source-map-loader follows relative paths in .js.map files that escape the project root through the symlink, causing ENOENT. Disabling source maps removes the .map files entirely. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index 8a5f04f..381f054 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,7 @@ // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ "declaration": true /* Generates corresponding '.d.ts' file. */, // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ - "sourceMap": true /* Generates corresponding '.map' file. */, + "sourceMap": false, // "outFile": "./", /* Concatenate and emit output to single file. */ "outDir": "./dist" /* Redirect output structure to the directory. */, // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ From 04d77d3fee39dc8d79353cadc397917f0437c8ff Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Tue, 2 Jun 2026 10:19:19 -0400 Subject: [PATCH 09/18] feat: auto-acceptStream when no onStreamAvailable handler is registered Backwards compatibility shim: if the app never calls onStreamAvailable, the gateway's new closed-gate-between-calls behavior would silently break audio on all calls after the first. Auto-accepting keeps existing apps working without any code change on their side. Apps that register onStreamAvailable get the new ring/accept UX and are responsible for calling acceptStream explicitly. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/v1/bandwidthRtc.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/v1/bandwidthRtc.ts b/src/v1/bandwidthRtc.ts index 1c5bced..3a81521 100644 --- a/src/v1/bandwidthRtc.ts +++ b/src/v1/bandwidthRtc.ts @@ -112,6 +112,13 @@ export class BandwidthRtc { this.signaling.on("streamAvailable", ({ callId }: { callId: string }) => { if (this.streamAvailableHandler) { this.streamAvailableHandler({ mediaTypes: [MediaType.AUDIO], callId }); + } else { + // No handler registered — auto-accept so existing apps that never call + // acceptStream keep working after the gateway started closing the gate + // between calls. + this.signaling.acceptStream(callId).catch((err) => { + logger.warn("auto-acceptStream failed", err); + }); } }); this.signaling.on("streamUnavailable", ({ callId }: { callId: string }) => { From 1c4ff3a4f8c28e9a7c4fe498cb09a5fa70c6eb95 Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Tue, 2 Jun 2026 10:22:30 -0400 Subject: [PATCH 10/18] revert: remove auto-acceptStream shim (gateway handles compat instead) Gateway now opens the EgressGate at streamAvailable send time, so old SDKs that never call acceptStream are unaffected. The SDK-side shim is no longer needed. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/v1/bandwidthRtc.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/v1/bandwidthRtc.ts b/src/v1/bandwidthRtc.ts index 3a81521..1c5bced 100644 --- a/src/v1/bandwidthRtc.ts +++ b/src/v1/bandwidthRtc.ts @@ -112,13 +112,6 @@ export class BandwidthRtc { this.signaling.on("streamAvailable", ({ callId }: { callId: string }) => { if (this.streamAvailableHandler) { this.streamAvailableHandler({ mediaTypes: [MediaType.AUDIO], callId }); - } else { - // No handler registered — auto-accept so existing apps that never call - // acceptStream keep working after the gateway started closing the gate - // between calls. - this.signaling.acceptStream(callId).catch((err) => { - logger.warn("auto-acceptStream failed", err); - }); } }); this.signaling.on("streamUnavailable", ({ callId }: { callId: string }) => { From 042d632f476ab5e4075e22753a3e49c8da3fe04d Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Tue, 2 Jun 2026 10:33:51 -0400 Subject: [PATCH 11/18] refactor: remove commented-out offerSdp overload Dead code from before the peerType-based offerSdp was adopted. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/v1/signaling.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/v1/signaling.ts b/src/v1/signaling.ts index df5a137..3a77c51 100644 --- a/src/v1/signaling.ts +++ b/src/v1/signaling.ts @@ -52,6 +52,10 @@ class Signaling extends EventEmitter { this.emit("sdpOffer", event); }); + ws.on("established", (event: any) => { + this.emit("established", event); + }); + ws.on("open", async () => { logger.debug("Websocket open"); if (globalThis.addEventListener) { @@ -192,14 +196,6 @@ class Signaling extends EventEmitter { return this.ws?.call("offerSdp", { sdpOffer: sdpOffer, peerType: peerType }) as Promise; } - // offerSdp(sdpOffer: string, metadata: PublishMetadata): Promise { - // logger.debug(`Calling "offerSdp"`, { sdpOffer: sdpOffer, mediaMetadata: metadata }); - // return this.ws?.call("offerSdp", { - // sdpOffer: sdpOffer, - // mediaMetadata: metadata, - // }) as Promise; - // } - answerSdp(sdpAnswer: string, peerType: string): Promise { logger.debug(`Calling "answerSdp"`, { sdpAnswer: sdpAnswer }); return this.ws?.call("answerSdp", { From 1dfe3467717e8f7372c614683bda84752d54abf8 Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Tue, 2 Jun 2026 10:42:36 -0400 Subject: [PATCH 12/18] refactor: remove ConnectStatus enum and connect status fields from ReadyMetadata --- src/types.ts | 18 ---------------- src/v1/signaling.test.ts | 44 ++++++++-------------------------------- src/v1/types.ts | 11 +--------- 3 files changed, 9 insertions(+), 64 deletions(-) diff --git a/src/types.ts b/src/types.ts index 2419603..6c815cb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -21,15 +21,6 @@ export enum EndpointType { PHONE_NUMBER = "PHONE_NUMBER", } -export enum ConnectStatus { - INITIATED = "INITIATED", - COMPLETED = "COMPLETED", - TIMED_OUT = "TIMED_OUT", - DENIED = "DENIED", - CANCELED = "CANCELED", - FAILED = "FAILED", -} - export type AudioLevelChangeHandler = { (audioLevel: AudioLevel): void }; /** @@ -59,15 +50,6 @@ export interface ReadyMetadata { deviceId: string; territory: string; // TODO enum region: string; // TODO enum - connectStatus?: ConnectStatus; - accountId?: string; - sessionId?: string; - from?: string; - fromType?: string; - fromTags?: string; - to?: string; - toType?: string; - toTags?: string; } export interface OutboundConnectionResult { diff --git a/src/v1/signaling.test.ts b/src/v1/signaling.test.ts index 477716b..8c4b84b 100644 --- a/src/v1/signaling.test.ts +++ b/src/v1/signaling.test.ts @@ -94,47 +94,19 @@ describe("Signaling connect method", () => { } }); - test("should emit ready with connectStatus fields when gateway sends ready with connect status data", async () => { + test("should emit established when websocket receives established", async () => { const emitSpy = jest.spyOn(signaling, "emit"); await signaling.connect({ endpointToken: "test-token" }); - // Simulate a second ready event from the gateway that includes connect status fields + // Get the websocket instance and trigger established event const ws = (signaling as any).ws; - const readyCallback = ws.on.mock.calls.find((call: any) => call[0] === "ready")?.[1]; - - if (readyCallback) { - const readyWithConnectStatus = { - endpointId: "test-endpoint", - deviceId: "device-1", - territory: "US", - region: "us-east-1", - connectStatus: "COMPLETED", - accountId: "9900000", - sessionId: "session-1", - from: "ep-1", - fromType: "ENDPOINT", - fromTags: "tag1", - to: "ep-2", - toType: "ENDPOINT", - toTags: "tag2", - }; - readyCallback(readyWithConnectStatus); - expect(emitSpy).toHaveBeenCalledWith( - "ready", - expect.objectContaining({ - endpointId: "test-endpoint", - connectStatus: "COMPLETED", - accountId: "9900000", - sessionId: "session-1", - from: "ep-1", - fromType: "ENDPOINT", - fromTags: "tag1", - to: "ep-2", - toType: "ENDPOINT", - toTags: "tag2", - }), - ); + const establishedCallback = ws.on.mock.calls.find((call: any) => call[0] === "established")?.[1]; + + if (establishedCallback) { + const testEvent = { connectionId: "test-connection" }; + establishedCallback(testEvent); + expect(emitSpy).toHaveBeenCalledWith("established", testEvent); } }); }); diff --git a/src/v1/types.ts b/src/v1/types.ts index 42776fa..7c0d18a 100644 --- a/src/v1/types.ts +++ b/src/v1/types.ts @@ -1,4 +1,4 @@ -import { MediaType, ConnectStatus } from "../types"; +import { MediaType } from "../types"; export interface SetMediaPreferencesWebRtcResponse { endpointId: string; @@ -69,13 +69,4 @@ export interface ReadyMetadata { deviceId: string; territory: string; // TODO enum region: string; // TODO enum - connectStatus?: ConnectStatus; - accountId?: string; - sessionId?: string; - from?: string; - fromType?: string; - fromTags?: string; - to?: string; - toType?: string; - toTags?: string; } From 903a1d556b74f446c81946e6f11fb6fc661fbd1f Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Tue, 2 Jun 2026 10:48:34 -0400 Subject: [PATCH 13/18] chore: format tsconfig.json for prettier compliance Build was blocked by a prettier check on the tsconfig. No logic changes; prettier --write reformatted the file so npm run build can proceed. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- tsconfig.json | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/tsconfig.json b/tsconfig.json index 381f054..11b6b1c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,7 @@ // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ "declaration": true /* Generates corresponding '.d.ts' file. */, // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ - "sourceMap": false, + "sourceMap": true, // "outFile": "./", /* Concatenate and emit output to single file. */ "outDir": "./dist" /* Redirect output structure to the directory. */, // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ @@ -21,7 +21,6 @@ // "importHelpers": true, /* Import emit helpers from 'tslib'. */ "downlevelIteration": true /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */, // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ - /* Strict Type-Checking Options */ "strict": true /* Enable all strict type-checking options. */, // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ @@ -31,13 +30,11 @@ // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ - /* Additional Checks */ // "noUnusedLocals": true, /* Report errors on unused locals. */ // "noUnusedParameters": true, /* Report errors on unused parameters. */ // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ - /* Module Resolution Options */ // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ @@ -49,13 +46,11 @@ "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ - /* Source Map Options */ // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ - /* Experimental Options */ // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ From 9db8cd07c8285b73cd595a1606854fe62e3e6b64 Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Tue, 2 Jun 2026 14:45:42 -0400 Subject: [PATCH 14/18] feat: add autoOpenEgressGate to RtcOptions and pass via setMediaPreferences Defaults to true. When true the gateway immediately re-opens the egress gate after each call ends, so the next call's audio flows without any round-trip delay. Pass false to restore legacy behaviour (gate stays closed until streamAvailable is processed by the gateway). Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/types.ts | 7 +++++++ src/v1/signaling.ts | 6 +++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/types.ts b/src/types.ts index 6c815cb..ff998ff 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,6 +35,13 @@ export interface RtcOptions { websocketUrl?: string; iceServers?: RTCIceServer[]; iceTransportPolicy?: RTCIceTransportPolicy; + /** + * When true (default), the gateway re-opens the egress gate immediately after + * each call ends so the next call's audio flows without any round-trip delay. + * Set to false to restore the legacy behaviour where the gate stays closed + * between calls until the gateway processes streamAvailable. + */ + autoOpenEgressGate?: boolean; } export interface RtcStream { diff --git a/src/v1/signaling.ts b/src/v1/signaling.ts index 3a77c51..2a72a4c 100644 --- a/src/v1/signaling.ts +++ b/src/v1/signaling.ts @@ -15,6 +15,7 @@ class Signaling extends EventEmitter { private isReady: boolean = false; private readyMetadata: ReadyMetadata | null = null; private diagnosticsBatcher?: DiagnosticsBatcher; + private rtcOptions?: RtcOptions; constructor(diagnosticsBatcher?: DiagnosticsBatcher) { super(); @@ -35,6 +36,7 @@ class Signaling extends EventEmitter { if (options) { rtcOptions = { ...rtcOptions, ...options }; } + this.rtcOptions = rtcOptions; const websocketUrl = `${rtcOptions.websocketUrl}?client=node&sdkVersion=${sdkVersion}&uniqueId=${this.uniqueDeviceId}&endpointToken=${authParams.endpointToken}`; logger.debug(`Connecting to ${websocketUrl}`); console.log(`Connecting to ${websocketUrl}`); @@ -122,9 +124,11 @@ class Signaling extends EventEmitter { } private setMediaPreferences(): Promise<{}> { - logger.debug(`Calling "setMediaPreferences"`, { protocol: "WEBRTC" }); + const autoOpenEgressGate = this.rtcOptions?.autoOpenEgressGate ?? true; + logger.debug(`Calling "setMediaPreferences"`, { protocol: "WEBRTC", autoOpenEgressGate }); return this.ws?.call("setMediaPreferences", { protocol: "WEBRTC", + autoOpenEgressGate, }) as Promise; } From e587b4d48f5e64f065e90bce91155a01cedf68a3 Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Mon, 15 Jun 2026 16:16:05 -0400 Subject: [PATCH 15/18] fix: route gateway streamAvailable notification to new onInboundStreamNotification callback Previously the signaling-layer streamAvailable WS event was routed through onStreamAvailable, which fires before the WebRTC ontrack event arrives and therefore has no mediaStream. This broke existing consumers who assume onStreamAvailable always carries a populated mediaStream. Introduce onInboundStreamNotification as the dedicated callback for the pre-media gateway notification (carries callId/autoAccepted, no mediaStream). onStreamAvailable continues to fire only from the WebRTC ontrack handler, so mediaStream is always present and existing consumers are unaffected. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/bandwidthRtc.ts | 18 ++++++++++++++++++ src/v1/bandwidthRtc.ts | 20 ++++++++++++++++---- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/bandwidthRtc.ts b/src/bandwidthRtc.ts index 4cec567..acce6b4 100644 --- a/src/bandwidthRtc.ts +++ b/src/bandwidthRtc.ts @@ -22,6 +22,7 @@ class BandwidthRtc { // Event handlers private streamAvailableHandler?: { (event: RtcStream): void }; private streamUnavailableHandler?: { (event: RtcStream): void }; + private inboundStreamNotificationHandler?: { (event: RtcStream): void }; private readyHandler?: { (readyMetadata: ReadyMetadata): void }; private logLevel?: LogLevel; @@ -77,6 +78,10 @@ class BandwidthRtc { this.delegate!.onStreamUnavailable(this.streamUnavailableHandler); } + if (this.inboundStreamNotificationHandler) { + this.delegate!.onInboundStreamNotification(this.inboundStreamNotificationHandler); + } + if (this.readyHandler) { this.delegate!.onReady(this.readyHandler); } @@ -116,6 +121,19 @@ class BandwidthRtc { } } + /** + * Set the function that will be called when the gateway signals that an inbound + * stream is ready to be accepted or declined, before the WebRTC media arrives. + * Use this to drive accept/decline UI; mediaStream will be undefined at this point. + * @param callback callback function + */ + onInboundStreamNotification(callback: { (event: RtcStream): void }): void { + this.inboundStreamNotificationHandler = callback; + if (this.delegate) { + this.delegate.onInboundStreamNotification(callback); + } + } + /** * Set the function that will be called when a subscribed stream becomes unavailable * @param callback callback function diff --git a/src/v1/bandwidthRtc.ts b/src/v1/bandwidthRtc.ts index 1c5bced..6d3fca9 100644 --- a/src/v1/bandwidthRtc.ts +++ b/src/v1/bandwidthRtc.ts @@ -77,6 +77,7 @@ export class BandwidthRtc { // Event handlers private streamAvailableHandler?: { (event: RtcStream): void }; private streamUnavailableHandler?: { (event: RtcStream): void }; + private inboundStreamNotificationHandler?: { (event: RtcStream): void }; private readyHandler?: { (readyMetadata: ReadyMetadata): void }; /** @@ -109,9 +110,9 @@ export class BandwidthRtc { this.signaling.on("ready", this.handleReady.bind(this)); this.signaling.on("sdpOffer", this.handleSubscribeSdpOffer.bind(this)); this.signaling.on("init", this.init.bind(this)); - this.signaling.on("streamAvailable", ({ callId }: { callId: string }) => { - if (this.streamAvailableHandler) { - this.streamAvailableHandler({ mediaTypes: [MediaType.AUDIO], callId }); + this.signaling.on("streamAvailable", ({ callId, autoAccepted }: { callId: string; autoAccepted: boolean }) => { + if (this.inboundStreamNotificationHandler) { + this.inboundStreamNotificationHandler({ mediaTypes: [MediaType.AUDIO], callId, autoAccepted }); } }); this.signaling.on("streamUnavailable", ({ callId }: { callId: string }) => { @@ -134,13 +135,24 @@ export class BandwidthRtc { } /** - * Set the function that will be called when a subscribed stream becomes available + * Set the function that will be called when a subscribed stream becomes available. + * The RtcStream passed to the callback always contains a populated mediaStream. * @param callback callback function */ onStreamAvailable(callback: { (event: RtcStream): void }): void { this.streamAvailableHandler = callback; } + /** + * Set the function that will be called when the gateway signals that an inbound + * stream is ready to be accepted or declined, before the WebRTC media arrives. + * Use this to drive accept/decline UI; mediaStream will be undefined at this point. + * @param callback callback function + */ + onInboundStreamNotification(callback: { (event: RtcStream): void }): void { + this.inboundStreamNotificationHandler = callback; + } + /** * Set the function that will be called when a subscribed stream becomes unavailable * @param callback callback function From 2f76547f041e8e827ad8b724afbfeeace6c8210e Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Tue, 16 Jun 2026 11:16:49 -0400 Subject: [PATCH 16/18] refactor: rename autoOpenEgressGate to autoAccept --- src/types.ts | 11 ++++++----- src/v1/signaling.ts | 8 ++++---- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/types.ts b/src/types.ts index ff998ff..1c7f71a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -36,18 +36,19 @@ export interface RtcOptions { iceServers?: RTCIceServer[]; iceTransportPolicy?: RTCIceTransportPolicy; /** - * When true (default), the gateway re-opens the egress gate immediately after - * each call ends so the next call's audio flows without any round-trip delay. - * Set to false to restore the legacy behaviour where the gate stays closed - * between calls until the gateway processes streamAvailable. + * When true (default), the gateway sets autoAccepted=true on the streamAvailable + * notification so the SDK skips the accept/decline prompt and the call connects + * immediately. Set to false to show a prompt and require the user to call + * acceptStream or declineStream. */ - autoOpenEgressGate?: boolean; + autoAccept?: boolean; } export interface RtcStream { mediaTypes: MediaType[]; mediaStream?: MediaStream; callId?: string; + autoAccepted?: boolean; } export class BandwidthRtcError extends Error {} diff --git a/src/v1/signaling.ts b/src/v1/signaling.ts index 2a72a4c..a7b7a19 100644 --- a/src/v1/signaling.ts +++ b/src/v1/signaling.ts @@ -85,7 +85,7 @@ class Signaling extends EventEmitter { resolve(); }); - ws.on("streamAvailable", (event: { callId: string; endpointId: string }) => { + ws.on("streamAvailable", (event: { callId: string; endpointId: string; autoAccepted: boolean }) => { this.emit("streamAvailable", event); }); @@ -124,11 +124,11 @@ class Signaling extends EventEmitter { } private setMediaPreferences(): Promise<{}> { - const autoOpenEgressGate = this.rtcOptions?.autoOpenEgressGate ?? true; - logger.debug(`Calling "setMediaPreferences"`, { protocol: "WEBRTC", autoOpenEgressGate }); + const autoAccept = this.rtcOptions?.autoAccept ?? true; + logger.debug(`Calling "setMediaPreferences"`, { protocol: "WEBRTC", autoAccept }); return this.ws?.call("setMediaPreferences", { protocol: "WEBRTC", - autoOpenEgressGate, + autoAccept, }) as Promise; } From 31035fd7c978d12424ae22c447dc7017bc3378d5 Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Wed, 17 Jun 2026 09:10:29 -0400 Subject: [PATCH 17/18] Revert "fix: route gateway streamAvailable notification to new onInboundStreamNotification callback" This reverts commit e587b4d48f5e64f065e90bce91155a01cedf68a3. --- src/bandwidthRtc.ts | 18 ------------------ src/v1/bandwidthRtc.ts | 16 ++-------------- 2 files changed, 2 insertions(+), 32 deletions(-) diff --git a/src/bandwidthRtc.ts b/src/bandwidthRtc.ts index acce6b4..4cec567 100644 --- a/src/bandwidthRtc.ts +++ b/src/bandwidthRtc.ts @@ -22,7 +22,6 @@ class BandwidthRtc { // Event handlers private streamAvailableHandler?: { (event: RtcStream): void }; private streamUnavailableHandler?: { (event: RtcStream): void }; - private inboundStreamNotificationHandler?: { (event: RtcStream): void }; private readyHandler?: { (readyMetadata: ReadyMetadata): void }; private logLevel?: LogLevel; @@ -78,10 +77,6 @@ class BandwidthRtc { this.delegate!.onStreamUnavailable(this.streamUnavailableHandler); } - if (this.inboundStreamNotificationHandler) { - this.delegate!.onInboundStreamNotification(this.inboundStreamNotificationHandler); - } - if (this.readyHandler) { this.delegate!.onReady(this.readyHandler); } @@ -121,19 +116,6 @@ class BandwidthRtc { } } - /** - * Set the function that will be called when the gateway signals that an inbound - * stream is ready to be accepted or declined, before the WebRTC media arrives. - * Use this to drive accept/decline UI; mediaStream will be undefined at this point. - * @param callback callback function - */ - onInboundStreamNotification(callback: { (event: RtcStream): void }): void { - this.inboundStreamNotificationHandler = callback; - if (this.delegate) { - this.delegate.onInboundStreamNotification(callback); - } - } - /** * Set the function that will be called when a subscribed stream becomes unavailable * @param callback callback function diff --git a/src/v1/bandwidthRtc.ts b/src/v1/bandwidthRtc.ts index 6d3fca9..b34b199 100644 --- a/src/v1/bandwidthRtc.ts +++ b/src/v1/bandwidthRtc.ts @@ -77,7 +77,6 @@ export class BandwidthRtc { // Event handlers private streamAvailableHandler?: { (event: RtcStream): void }; private streamUnavailableHandler?: { (event: RtcStream): void }; - private inboundStreamNotificationHandler?: { (event: RtcStream): void }; private readyHandler?: { (readyMetadata: ReadyMetadata): void }; /** @@ -111,8 +110,8 @@ export class BandwidthRtc { this.signaling.on("sdpOffer", this.handleSubscribeSdpOffer.bind(this)); this.signaling.on("init", this.init.bind(this)); this.signaling.on("streamAvailable", ({ callId, autoAccepted }: { callId: string; autoAccepted: boolean }) => { - if (this.inboundStreamNotificationHandler) { - this.inboundStreamNotificationHandler({ mediaTypes: [MediaType.AUDIO], callId, autoAccepted }); + if (this.streamAvailableHandler) { + this.streamAvailableHandler({ mediaTypes: [MediaType.AUDIO], callId, autoAccepted }); } }); this.signaling.on("streamUnavailable", ({ callId }: { callId: string }) => { @@ -136,23 +135,12 @@ export class BandwidthRtc { /** * Set the function that will be called when a subscribed stream becomes available. - * The RtcStream passed to the callback always contains a populated mediaStream. * @param callback callback function */ onStreamAvailable(callback: { (event: RtcStream): void }): void { this.streamAvailableHandler = callback; } - /** - * Set the function that will be called when the gateway signals that an inbound - * stream is ready to be accepted or declined, before the WebRTC media arrives. - * Use this to drive accept/decline UI; mediaStream will be undefined at this point. - * @param callback callback function - */ - onInboundStreamNotification(callback: { (event: RtcStream): void }): void { - this.inboundStreamNotificationHandler = callback; - } - /** * Set the function that will be called when a subscribed stream becomes unavailable * @param callback callback function From f829bd018dbf25c9f13981e922f629d75663d775 Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Thu, 25 Jun 2026 16:12:34 -0400 Subject: [PATCH 18/18] feat: correlate ontrack with WS streamAvailable for backwards-compatible single event Previously the SDK fired onStreamAvailable twice per call: once from the WebRTC ontrack event (with mediaStream only) and once from the WS streamAvailable notification (with callId/autoAccepted but no mediaStream). This broke existing apps that expected a single event with mediaStream always set. Now the SDK holds the subscribe MediaStream from ontrack internally and only fires onStreamAvailable once the WS notification arrives, delivering a combined event with mediaStream, callId, and autoAccepted all present. ontrack ordering is handled in both directions (stream arrives before or after the WS notification). onStreamUnavailable is similarly fired from the WS streamUnavailable notification using the stored stream, with onremovetrack as a fallback for older gateways. A callIsActive guard prevents double-firing when both fire. Co-Authored-By: Claude Sonnet 4.6 --- src/v1/bandwidthRtc.ts | 83 +++++++++++++++++++++++++++++++----------- 1 file changed, 61 insertions(+), 22 deletions(-) diff --git a/src/v1/bandwidthRtc.ts b/src/v1/bandwidthRtc.ts index b34b199..1de719f 100644 --- a/src/v1/bandwidthRtc.ts +++ b/src/v1/bandwidthRtc.ts @@ -79,6 +79,14 @@ export class BandwidthRtc { private streamUnavailableHandler?: { (event: RtcStream): void }; private readyHandler?: { (readyMetadata: ReadyMetadata): void }; + // Pending state for correlating WebRTC ontrack with WS streamAvailable notification. + // ontrack fires at connect time; the WS notification fires when a call actually arrives. + // We hold both until we have each, then fire a single combined onStreamAvailable event. + private pendingSubscribeStream: MediaStream | undefined; + private pendingCallInfo: { callId: string; autoAccepted: boolean } | undefined; + // Prevents double-firing onStreamUnavailable when both WS and onremovetrack fire. + private callIsActive = false; + /** * Construct a new instance of BandwidthRtc * @param logLevel desired log level for logs that will appear in the browser's console, optional @@ -110,14 +118,30 @@ export class BandwidthRtc { this.signaling.on("sdpOffer", this.handleSubscribeSdpOffer.bind(this)); this.signaling.on("init", this.init.bind(this)); this.signaling.on("streamAvailable", ({ callId, autoAccepted }: { callId: string; autoAccepted: boolean }) => { - if (this.streamAvailableHandler) { - this.streamAvailableHandler({ mediaTypes: [MediaType.AUDIO], callId, autoAccepted }); + if (this.pendingSubscribeStream) { + this.callIsActive = true; + this.streamAvailableHandler?.({ + mediaTypes: [MediaType.AUDIO], + mediaStream: this.pendingSubscribeStream, + callId, + autoAccepted, + }); + } else { + // ontrack hasn't fired yet — store and fire once it does + this.pendingCallInfo = { callId, autoAccepted }; } }); this.signaling.on("streamUnavailable", ({ callId }: { callId: string }) => { - if (this.streamUnavailableHandler) { - this.streamUnavailableHandler({ mediaTypes: [MediaType.AUDIO], callId }); + if (!this.callIsActive) { + return; } + this.callIsActive = false; + this.pendingCallInfo = undefined; + this.streamUnavailableHandler?.({ + mediaTypes: [MediaType.AUDIO], + mediaStream: this.pendingSubscribeStream, + callId, + }); }); await this.signaling.connect(authParams, options); @@ -479,31 +503,46 @@ export class BandwidthRtc { stream.onremovetrack = (event) => { logger.debug("onremovetrack", event); - if (this.streamUnavailableHandler) { - let removedTrack = event.track; - let deleteResult = availableTracks?.delete(removedTrack); - if (deleteResult) { - if (availableTracks?.size === 0) { - logger.debug("onStreamUnavailable", stream.id); - this.streamUnavailableHandler({ - mediaTypes: [MediaType.AUDIO], - mediaStream: stream, - }); - streamTracks.delete(stream); - } else { - logger.debug("Waiting on tracks to end", availableTracks); - } + // Guard against double-fire: WS streamUnavailable fires first on new gateway. + if (!this.callIsActive) { + return; + } + let removedTrack = event.track; + let deleteResult = availableTracks?.delete(removedTrack); + if (deleteResult) { + if (availableTracks?.size === 0) { + logger.debug("onStreamUnavailable", stream.id); + this.callIsActive = false; + this.pendingCallInfo = undefined; + this.streamUnavailableHandler?.({ + mediaTypes: [MediaType.AUDIO], + mediaStream: stream, + }); + streamTracks.delete(stream); + } else { + logger.debug("Waiting on tracks to end", availableTracks); } } }; - if (this.streamAvailableHandler) { - logger.debug("onStreamAvailable", stream.id); - this.streamAvailableHandler({ + + // Store the subscribe stream. We fire onStreamAvailable only when the WS + // streamAvailable notification arrives (which carries callId/autoAccepted), so + // that customers always receive a single event with mediaStream set. If the WS + // notification already arrived before ontrack, fire the combined event now. + this.pendingSubscribeStream = stream; + if (this.pendingCallInfo) { + const { callId, autoAccepted } = this.pendingCallInfo; + this.pendingCallInfo = undefined; + this.callIsActive = true; + logger.debug("onStreamAvailable (deferred)", stream.id); + this.streamAvailableHandler?.({ mediaTypes: [MediaType.AUDIO], mediaStream: stream, + callId, + autoAccepted, }); } else { - logger.debug("Waiting on additional tracks"); + logger.debug("ontrack: stored subscribe stream, waiting for WS streamAvailable", stream.id); } } };