Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 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
89c5317
feat!: derive stream events from subscribe offer metadata; first-clas…
Jul 15, 2026
5ebef44
Merge branch 'main' into metadata-for-stream-events
smoghe-bw Jul 20, 2026
9a62eda
Merge branch 'main' into metadata-for-stream-events
smoghe-bw Jul 22, 2026
3014787
Merge remote-tracking branch 'origin/metadata-for-stream-events' into…
Jul 22, 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
24 changes: 24 additions & 0 deletions src/bandwidthRtc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,30 @@ class BandwidthRtc {
}
return this.delegate.hangupConnection(endpoint, type);
}

/**
* Accept an incoming stream surfaced by onStreamAvailable with autoAccepted=false.
* The gateway opens its egress gate so call audio flows in both directions. No-op
* when autoAccept was left on (the gateway already opened the gate).
*/
acceptStream(): Promise<void> {
if (!this.delegate) {
throw new BandwidthRtcError("You must call 'connect' before 'acceptStream'");
}
return this.delegate.acceptStream();
}

/**
* Decline an incoming stream surfaced by onStreamAvailable with autoAccepted=false.
* The gateway keeps its egress gate closed and cancels the call, ending the
* remote leg; onStreamUnavailable follows.
*/
declineStream(): Promise<void> {
if (!this.delegate) {
throw new BandwidthRtcError("You must call 'connect' before 'declineStream'");
}
return this.delegate.declineStream();
}
}

interface JwtPayload {
Expand Down
20 changes: 20 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,31 @@ export interface RtcOptions {
websocketUrl?: string;
iceServers?: RTCIceServer[];
iceTransportPolicy?: RTCIceTransportPolicy;
/**
* When true (default), the gateway opens its egress gate as soon as the call
* bridges, so audio flows immediately and onStreamAvailable reports
* autoAccepted=true. Set to false to park the call (ringing, no audio) and
* prompt the user: call acceptStream to open the gate or declineStream to end
* the call. Passed to the gateway via setMediaPreferences.
*/
autoAccept?: boolean;
}

export interface RtcStream {
mediaTypes: MediaType[];
mediaStream: MediaStream;
/** Identity of the caller, from the gateway's subscribe offer track metadata. */
from?: string;
/** Kind of the caller identity (e.g. "call", "endpoint"). */
fromType?: string;
/**
* True when the gateway auto-accepted the stream (audio already flowing). When
* false, the call is parked/ringing: prompt the user and call acceptStream or
* declineStream.
*/
autoAccepted?: boolean;
/** Free-form tags forwarded by the gateway, if any. */
tags?: string;
}

export class BandwidthRtcError extends Error {}
Expand Down
97 changes: 71 additions & 26 deletions src/v1/bandwidthRtc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
StreamMetadata,
StreamPublishMetadata,
SubscribeSdpOffer,
TrackMetadata,
SetMediaPreferencesWebRtcResponse,
SdpAnswer,
} from "./types";
Expand Down Expand Up @@ -91,6 +92,13 @@ export class BandwidthRtc {
private streamUnavailableHandler?: { (event: RtcStream): void };
private readyHandler?: { (readyMetadata: ReadyMetadata): void };

// Caller identity for pending subscribe tracks, keyed by track id. The gateway
// negotiates a fresh subscribe track per call and rides the call's metadata on
// the same sdpOffer that adds it (SubscribeSdpOffer.trackMetadata). We stash it
// here when the offer arrives and attach it to onStreamAvailable when the
// matching ontrack fires. Entries are cleared once consumed.
private subscribeTrackMetadata: Map<string, TrackMetadata> = new Map();

/**
* 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 +129,7 @@ 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));

await this.signaling.connect(authParams, options);
logger.info("Successfully connected");
}
Expand All @@ -135,7 +144,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 +249,7 @@ export class BandwidthRtc {
}
} else {
publishedStreams.push({
mediaStream: stream.mediaStream,
mediaStream: stream.mediaStream!,
});
}
}
Expand Down Expand Up @@ -324,7 +333,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 +348,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 +376,14 @@ export class BandwidthRtc {
return this.signaling.hangupConnection(endpoint, type);
}

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

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

// 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 @@ -463,6 +480,12 @@ export class BandwidthRtc {
throw new BandwidthRtcError("No subscribing RTCPeerConnection, cannot handle SDP offer");
}

// Stash per-track caller identity before applying the offer: setRemoteDescription
// can fire ontrack synchronously, and the handler looks the metadata up by track id.
for (const [trackId, metadata] of Object.entries(subscribeSdpOffer.trackMetadata ?? {})) {
this.subscribeTrackMetadata.set(trackId, metadata);
}

await this.subscribingPeerConnection!.setRemoteDescription({
type: "offer",
sdp: remoteSdpOffer,
Expand Down Expand Up @@ -525,33 +548,55 @@ 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 (this.streamAvailableHandler) {
logger.debug("onStreamAvailable", stream.id);
this.streamAvailableHandler({
if (availableTracks?.size !== 0) {
logger.debug("Waiting on tracks to end", availableTracks);
return;
}
// The gateway removed this call's subscribe track and re-offered, so the
// m-section went inactive: the stream is unavailable. Each call uses a
// unique stream, so clean up its entry as we fire.
streamTracks.delete(stream);
logger.debug("onStreamUnavailable", stream.id);
this.streamUnavailableHandler?.({
mediaTypes: [MediaType.AUDIO],
mediaStream: stream,
});
} else {
logger.debug("Waiting on additional tracks");
}
};
}

// The gateway adds a fresh subscribe track per call and rides that call's
// caller identity on the same offer, keyed by track id. ontrack firing means
// the offer gained an active m-section — the stream is now available.
const stream = event.streams[0];
if (!stream) {
logger.debug("ontrack fired with no associated stream, not firing onStreamAvailable");
return;
}
let metadata = this.subscribeTrackMetadata.get(track.id);
if (metadata) {
this.subscribeTrackMetadata.delete(track.id);
} else if (this.subscribeTrackMetadata.size === 1) {
// The offer's msid track id normally equals the browser's track.id, but
// guard against a browser that rewrites it: a call adds exactly one track
// and rides exactly one metadata entry, so the sole pending entry is ours.
const [onlyTrackId] = this.subscribeTrackMetadata.keys();
metadata = this.subscribeTrackMetadata.get(onlyTrackId);
this.subscribeTrackMetadata.delete(onlyTrackId);
}
logger.debug("onStreamAvailable", stream.id, metadata);
this.streamAvailableHandler?.({
mediaTypes: [MediaType.AUDIO],
mediaStream: stream,
from: metadata?.from,
fromType: metadata?.fromType,
autoAccepted: metadata?.autoAccepted,
tags: metadata?.tags,
});
};
this.subscribingPeerConnection = await this.setupPeerConnection(
PEER_CONNECTION_TYPE_SUBSCRIBE,
Expand Down
Loading