Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
a95a03d
feat: add connectStatus to readyMetadata on connectStatus event
Apr 24, 2026
af93d6e
feat(rtc): add onConnectStatus event handler
Apr 27, 2026
e2567ea
style: remove extra blank lines in bandwidthRtc.ts and signaling.ts
Apr 27, 2026
0ca235c
test(signaling): add websocket event handler and disconnect tests
Apr 27, 2026
e13ceb6
feat: wire streamAvailable/streamUnavailable WS notifications to onSt…
Jun 2, 2026
be447ae
feat: expose acceptStream/declineStream on outer BandwidthRtc class
Jun 2, 2026
cb7b226
fix: add types field to package.json for TypeScript consumers
Jun 2, 2026
1b86b4e
fix: disable sourceMap output to prevent source-map-loader errors in …
Jun 2, 2026
04d77d3
feat: auto-acceptStream when no onStreamAvailable handler is registered
Jun 2, 2026
1c4ff3a
revert: remove auto-acceptStream shim (gateway handles compat instead)
Jun 2, 2026
042d632
refactor: remove commented-out offerSdp overload
Jun 2, 2026
1dfe346
refactor: remove ConnectStatus enum and connect status fields from Re…
Jun 2, 2026
903a1d5
chore: format tsconfig.json for prettier compliance
Jun 2, 2026
9db8cd0
feat: add autoOpenEgressGate to RtcOptions and pass via setMediaPrefe…
Jun 2, 2026
e587b4d
fix: route gateway streamAvailable notification to new onInboundStrea…
Jun 15, 2026
2f76547
refactor: rename autoOpenEgressGate to autoAccept
Jun 16, 2026
31035fd
Revert "fix: route gateway streamAvailable notification to new onInbo…
Jun 17, 2026
f829bd0
feat: correlate ontrack with WS streamAvailable for backwards-compati…
Jun 25, 2026
166837e
merge: resolve conflict between stream correlation and ICE restart ch…
Jun 25, 2026
18c3b07
feat: harden audio stream availability signaling and fallback events
Jun 30, 2026
05ee381
Merge remote-tracking branch 'origin/main' into metadata-for-stream-e…
Jul 1, 2026
00236b9
fix: correlate per-call ontrack with WS stream events for new gateway
Jul 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 0 additions & 12 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 14 additions & 0 deletions src/bandwidthRtc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,20 @@ class BandwidthRtc {
}
return this.delegate.hangupConnection(endpoint, type);
}

acceptStream(callId?: string): Promise<void> {
if (!this.delegate) {
throw new BandwidthRtcError("You must call 'connect' before 'acceptStream'");
}
return this.delegate.acceptStream(callId);
}

declineStream(callId?: string): Promise<void> {
if (!this.delegate) {
throw new BandwidthRtcError("You must call 'connect' before 'declineStream'");
}
return this.delegate.declineStream(callId);
}
}

interface JwtPayload {
Expand Down
9 changes: 9 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,20 @@ export interface RtcOptions {
websocketUrl?: string;
iceServers?: RTCIceServer[];
iceTransportPolicy?: RTCIceTransportPolicy;
/**
* 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.
*/
autoAccept?: boolean;
}

export interface RtcStream {
mediaTypes: MediaType[];
mediaStream: MediaStream;
callId?: string;
autoAccepted?: boolean;
}

export class BandwidthRtcError extends Error {}
Expand Down
131 changes: 107 additions & 24 deletions src/v1/bandwidthRtc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,17 @@ export class BandwidthRtc {
private streamUnavailableHandler?: { (event: RtcStream): void };
private readyHandler?: { (readyMetadata: ReadyMetadata): void };

// Pending state for correlating the per-call WebRTC ontrack with the WS
// streamAvailable notification. The gateway negotiates a fresh subscribe track
// per call (ontrack) and sends a WS streamAvailable carrying callId/autoAccepted;
// we hold whichever arrives first and fire a single combined onStreamAvailable
// once we have both. Both are cleared at call end so the next call starts clean.
private pendingSubscribeStream: MediaStream | undefined;
private pendingCallInfo: { callId: string; autoAccepted: boolean } | undefined;
// Ensures onStreamAvailable/onStreamUnavailable each fire exactly once per call
// across the WS, ontrack, onunmute, and onremovetrack paths.
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
Expand Down Expand Up @@ -121,6 +132,43 @@ 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, autoAccepted }: { callId: string; autoAccepted: boolean }) => {
if (this.callIsActive) {
// Already fired for this call (e.g. via the onunmute fallback). Don't
// double-fire; the metadata-bearing path just loses the race here.
return;
}
if (this.pendingSubscribeStream) {
// ontrack for this call already fired — fire the combined event now.
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.callIsActive) {
return;
}
this.callIsActive = false;
this.pendingCallInfo = undefined;
// Clear the per-call stream so the next call cannot reuse a stale one; each
// call now negotiates a fresh track/stream (unique msid) with the gateway.
const mediaStream = this.pendingSubscribeStream;
this.pendingSubscribeStream = undefined;
this.streamUnavailableHandler?.({
mediaTypes: [MediaType.AUDIO],
mediaStream: mediaStream!,
callId,
});
});

await this.signaling.connect(authParams, options);
logger.info("Successfully connected");
}
Expand All @@ -135,7 +183,7 @@ 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.
* @param callback callback function
*/
onStreamAvailable(callback: { (event: RtcStream): void }): void {
Expand Down Expand Up @@ -240,7 +288,7 @@ export class BandwidthRtc {
}
} else {
publishedStreams.push({
mediaStream: stream.mediaStream,
mediaStream: stream.mediaStream!,
});
}
}
Expand Down Expand Up @@ -324,7 +372,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)
Expand All @@ -339,7 +387,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)
Expand Down Expand Up @@ -367,6 +415,14 @@ export class BandwidthRtc {
return this.signaling.hangupConnection(endpoint, type);
}

acceptStream(callId?: string): Promise<void> {
return this.signaling.acceptStream(callId);
}

declineStream(callId?: string): Promise<void> {
return this.signaling.declineStream(callId);
}

// Re-publishes the SDP with iceRestart=true to trigger ICE renegotiation after a connection failure.
private async retryIceOnFailed(pc: RTCPeerConnection, shouldRetry: boolean): Promise<void> {
if (!shouldRetry) {
Expand Down Expand Up @@ -507,6 +563,17 @@ export class BandwidthRtc {

track.onunmute = (event) => {
logger.debug("onunmute", event.target);
// Fallback for gateways that don't send WS streamAvailable: fire
// onStreamAvailable when audio actually starts flowing. No-op on new
// gateways because callIsActive is already true from the WS event.
if (!this.callIsActive && this.pendingSubscribeStream) {
this.callIsActive = true;
logger.debug("onStreamAvailable (onunmute fallback)", this.pendingSubscribeStream.id);
this.streamAvailableHandler?.({
mediaTypes: [MediaType.AUDIO],
mediaStream: this.pendingSubscribeStream,
});
}
};

track.onended = (event) => {
Expand All @@ -525,31 +592,47 @@ 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);
}
}
let removedTrack = event.track;
let deleteResult = availableTracks?.delete(removedTrack);
if (!deleteResult) {
return;
}
if (availableTracks?.size !== 0) {
logger.debug("Waiting on tracks to end", availableTracks);
return;
}
// Always clean up the per-call stream entry (each call uses a unique
// stream), even when the WS streamUnavailable already fired the event.
streamTracks.delete(stream);
// Fire only if the WS streamUnavailable hasn't already done so — on the
// new gateway WS fires first, so this is the fallback for gateways that
// don't send it.
if (!this.callIsActive) {
return;
}
logger.debug("onStreamUnavailable", stream.id);
this.callIsActive = false;
this.pendingCallInfo = undefined;
this.pendingSubscribeStream = undefined;
this.streamUnavailableHandler?.({
mediaTypes: [MediaType.AUDIO],
mediaStream: stream,
});
};
if (this.streamAvailableHandler) {
logger.debug("onStreamAvailable", stream.id);
this.streamAvailableHandler({

this.pendingSubscribeStream = stream;
if (this.pendingCallInfo) {
// WS arrived before ontrack — fire the combined event now.
const { callId, autoAccepted } = this.pendingCallInfo;
this.pendingCallInfo = undefined;
this.callIsActive = true;
logger.debug("onStreamAvailable (WS-enriched)", stream.id);
this.streamAvailableHandler?.({
mediaTypes: [MediaType.AUDIO],
mediaStream: stream,
callId,
autoAccepted,
});
} else {
logger.debug("Waiting on additional tracks");
}
}
};
Expand Down
Loading