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/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", 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 { diff --git a/src/types.ts b/src/types.ts index 0a2bb04..1c7f71a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; + mediaStream?: MediaStream; + callId?: string; + autoAccepted?: boolean; } export class BandwidthRtcError extends Error {} diff --git a/src/v1/bandwidthRtc.ts b/src/v1/bandwidthRtc.ts index dab9d9f..b34b199 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, autoAccepted }: { callId: string; autoAccepted: boolean }) => { + if (this.streamAvailableHandler) { + this.streamAvailableHandler({ mediaTypes: [MediaType.AUDIO], callId, autoAccepted }); + } + }); + 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"); @@ -124,7 +134,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 { @@ -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.test.ts b/src/v1/signaling.test.ts index cdbb933..8c4b84b 100644 --- a/src/v1/signaling.test.ts +++ b/src/v1/signaling.test.ts @@ -111,6 +111,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 () => { diff --git a/src/v1/signaling.ts b/src/v1/signaling.ts index 36ed864..a7b7a19 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}`); @@ -83,6 +85,14 @@ class Signaling extends EventEmitter { resolve(); }); + ws.on("streamAvailable", (event: { callId: string; endpointId: string; autoAccepted: boolean }) => { + 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"); @@ -114,9 +124,11 @@ class Signaling extends EventEmitter { } private setMediaPreferences(): Promise<{}> { - logger.debug(`Calling "setMediaPreferences"`, { protocol: "WEBRTC" }); + const autoAccept = this.rtcOptions?.autoAccept ?? true; + logger.debug(`Calling "setMediaPreferences"`, { protocol: "WEBRTC", autoAccept }); return this.ws?.call("setMediaPreferences", { protocol: "WEBRTC", + autoAccept, }) as Promise; } @@ -173,19 +185,21 @@ 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; } - // 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", { diff --git a/tsconfig.json b/tsconfig.json index 8a5f04f..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": true /* Generates corresponding '.map' file. */, + "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. */