From 63c7dfb269f07ecc081313025973237ea4576bf9 Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Wed, 25 Mar 2026 14:34:42 -0400 Subject: [PATCH 01/13] Refactor environment configuration and update DialPad component to use accountId --- .env | 4 ---- .env.example | 17 +++++++++++++++++ .gitignore | 7 +++---- package.json | 2 +- src/components/DialPad.js | 30 +++++++++++++----------------- 5 files changed, 34 insertions(+), 26 deletions(-) delete mode 100644 .env create mode 100644 .env.example diff --git a/.env b/.env deleted file mode 100644 index 7895789..0000000 --- a/.env +++ /dev/null @@ -1,4 +0,0 @@ -REACT_APP_ACCOUNT_USERNAME=+191xxxxxxx -REACT_APP_ACCOUNT_DISPLAY_NAME=+1919xxxxxx -REACT_APP_ACCOUNT_PASSWORD=+1919xxxxxxx -REACT_APP_AUTH_TOKEN=xxxxxxxx \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3f2f0da --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# Required: Your Bandwidth account ID +REACT_APP_ACCOUNT_ID= + +# Required: Your OAuth / Identity token +REACT_APP_AUTH_TOKEN= + +# Required: Source phone number (e.g. +15551234567) +REACT_APP_ACCOUNT_USERNAME= + +# Optional: Override the WebRTC gateway WebSocket URL +# REACT_APP_GATEWAY_URL=wss://gateway.pv.prod.global.aws.bandwidth.com/prod/gateway-service/api/v1/endpoints + +# Optional: Override the Bandwidth REST API base URL +# REACT_APP_HTTP_BASE_URL=https://api.bandwidth.com/v2 + +# Optional: Event callback URL for inbound call notifications +# REACT_APP_EVENT_CALLBACK_URL= diff --git a/.gitignore b/.gitignore index d2e8545..ec7d8e9 100644 --- a/.gitignore +++ b/.gitignore @@ -13,10 +13,9 @@ # misc .DS_Store -.env.local -.env.development.local -.env.test.local -.env.production.local +.env +.env.* +!.env.example npm-debug.log* yarn-debug.log* diff --git a/package.json b/package.json index 44f9250..c361558 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "@babel/plugin-proposal-private-property-in-object": "^7.16.7" }, "dependencies": { - "@bandwidth/bw-webrtc-sdk": "^1.1.4", + "@bandwidth/bw-webrtc-sdk": "file:../javascript-webrtc-sdk", "@emotion/react": "^11.8.1", "@emotion/styled": "^11.8.1", "@mui/icons-material": "^5.14.0", diff --git a/src/components/DialPad.js b/src/components/DialPad.js index 7d32f6d..ad073de 100644 --- a/src/components/DialPad.js +++ b/src/components/DialPad.js @@ -16,6 +16,7 @@ import { Button } from '@mui/material'; export default function DialPad() { const userId = process.env.REACT_APP_ACCOUNT_USERNAME; const authToken = process.env.REACT_APP_AUTH_TOKEN; + const accountId = process.env.REACT_APP_ACCOUNT_ID; const sourceNumber = userId; const { totalSeconds, seconds, minutes, hours, start, pause, reset } = useStopwatch({ autoStart: false }); @@ -71,23 +72,18 @@ export default function DialPad() { }, []) useEffect(() => { - const serverConfig = { - domain: 'gw.webrtc-app.bandwidth.com', - addresses: ['wss://gw.webrtc-app.bandwidth.com:10081'], - iceServers: [ - 'stun.l.google.com:19302', - 'stun1.l.google.com:19302', - 'stun2.l.google.com:19302', - ], - }; - const newPhone = new BandwidthUA(); + const newPhone = new BandwidthUA({ + accountId: accountId, + // Optional overrides: + // gatewayUrl: process.env.REACT_APP_GATEWAY_URL, + // httpBaseUrl: process.env.REACT_APP_HTTP_BASE_URL, + // eventCallbackUrl: process.env.REACT_APP_EVENT_CALLBACK_URL, + }); console.log(`version: `, newPhone.version()); + + // These are still accepted for backwards compatibility but are no longer + // required — the new SDK connects directly to the WebRTC gateway. newPhone.setWebSocketKeepAlive(5, false, false, 5, true); - newPhone.setServerConfig( - serverConfig.addresses, - serverConfig.domain, - serverConfig.iceServers, - ); //overriding the SDK logs newPhone.setBWLogger((...e) => { @@ -111,8 +107,8 @@ export default function DialPad() { case 'disconnected': console.log('phone>>> loginStateChanged: disconnected'); if (phone.isInitialized()) { - // after deinit() phone will disconnect SBC. - console.log('Cannot connect to SBC server'); + // after deinit() phone will disconnect from gateway. + console.log('Cannot connect to WebRTC gateway server'); } break; case 'login failed': From 500993093c77fc1243ff54d880586559772bb267 Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Wed, 25 Mar 2026 14:42:51 -0400 Subject: [PATCH 02/13] Update README.md for environment setup and initialization instructions --- README.md | 189 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 98 insertions(+), 91 deletions(-) diff --git a/README.md b/README.md index 91dfe17..a73a75c 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # In-App Calling Dialpad - # Table of Contents +# Table of Contents -* [Description](#description) -* [Pre-Requisites](#pre-requisites) -* [Initialization](#initialization) -* [Running the Application](#running-the-application) +- [Description](#description) +- [Pre-Requisites](#pre-requisites) +- [Initialization](#initialization) +- [Running the Application](#running-the-application) # Description @@ -17,58 +17,64 @@ In order to use this sample app, your account must have In-App Calling enabled. For more information about API credentials see our [Account Credentials](https://dev.bandwidth.com/docs/account/credentials) page. -### Environmental Variables +### Environment Setup -The sample app uses the below environmental variables. +1. Copy the example environment file: ```sh -REACT_APP_IN_APP_CALLING_TOKEN # You Identity Token -REACT_APP_ACCOUNT_USERNAME # Put from number here -REACT_APP_ACCOUNT_DISPLAY_NAME # Put from number/display name here -REACT_APP_ACCOUNT_PASSWORD # use some password or leave it empty +cp .env.example .env +``` + +2. Fill in your values in `.env`: + +```sh +REACT_APP_ACCOUNT_ID= # Your Bandwidth account ID +REACT_APP_AUTH_TOKEN= # Your OAuth / Identity token +REACT_APP_ACCOUNT_USERNAME= # Source phone number (e.g. +15551234567) +``` + +Optional overrides (uncomment in `.env` if needed): + +```sh +# REACT_APP_GATEWAY_URL= # Override the WebRTC gateway WebSocket URL +# REACT_APP_HTTP_BASE_URL= # Override the Bandwidth REST API base URL +# REACT_APP_EVENT_CALLBACK_URL= # Event callback URL for inbound call notifications ``` # Initialization -- **BandwidthUA**: The instance is available from the outset, Initialization required before making the call, follow the below code snippet for initialization -```sh -const serverConfig = { - domain: 'gw.webrtc-app.bandwidth.com', - addresses: ['wss://gw.webrtc-app.bandwidth.com:10081'], - iceServers: [ - 'stun.l.google.com:19302', - 'stun1.l.google.com:19302', - 'stun2.l.google.com:19302', - ], -}; -const phone = new BandwidthUA(); - -phone.setServerConfig( - serverConfig.addresses, - serverConfig.domain, - serverConfig.iceServers -); -phone.setWebSocketKeepAlive(5, false, false); +- **BandwidthUA**: The instance is available from the outset. Initialization is required before making a call. Follow the below code snippet for initialization: + +```js +import { BandwidthUA } from "@bandwidth/bw-webrtc-sdk"; + +const phone = new BandwidthUA({ + accountId: accountId, +}); + phone.checkAvailableDevices(); -phone.setAccount(`${sourceNumber}`, 'In-App Calling Sample', ''); +phone.setAccount(`${sourceNumber}`, "In-App Calling Sample", ""); phone.setOAuthToken(authToken); -phone.init(); +await phone.init(); ``` +> **Note:** In v1.2.0, `setServerConfig()` is no longer required. The SDK connects directly to the Bandwidth WebRTC backend. The only new requirement is passing `accountId` in the constructor. See the [SDK README](https://github.com/Bandwidth/javascript-webrtc-sdk#migration-from-v11x) for migration details. + # Usage ### Making a Call Making a call using the Bandwidth services involves a series of steps to ensure the call's proper initiation and management. -```sh -var activeCall = async phone.makeCall(`${destNumber}`, extraHeaders); +```js +const activeCall = await phone.makeCall(`${destNumber}`, extraHeaders); ``` -Keep the `activeCall` instance global in persistant state in order to reuse this instance for call termination, hold & mute. +Keep the `activeCall` instance in persistent state in order to reuse this instance for call termination, hold & mute. + ### Terminating a Call -```sh +```js activeCall.terminate(); ``` @@ -84,57 +90,58 @@ In the provided code, the `BandwidthUA.setListeners` is used. This listener has To use the listener, you implement it as an anonymous class and provide logic inside each method: -```sh +```js phone.setListeners({ - loginStateChanged: function (isLogin, cause) { - console.log(cause); - switch (cause) { - case 'connected': - console.log('phone>>> loginStateChanged: connected'); - break; - case 'disconnected': - console.log('phone>>> loginStateChanged: disconnected'); - break; - case 'login failed': - console.log('phone>>> loginStateChanged: login failed'); - break; - case 'login': - console.log('phone>>> loginStateChanged: login'); - break; - case 'logout': - console.log('phone>>> loginStateChanged: logout'); - break; - } - }, - - outgoingCallProgress: function (call, response) { - updateFBStatus("Call-Initiate"); - console.log('phone>>> outgoing call progress'); - }, - - callTerminated: function (call, message, cause) { - console.log(`phone>>> call terminated callback, cause=${cause}`); - }, - - callConfirmed: function (call, message, cause) { - console.log('phone>>> callConfirmed'); - }, - - callShowStreams: function (call, localStream, remoteStream) { - console.log('phone>>> callShowStreams'); - let remoteVideo = document.getElementById('remote-video-container'); - if (remoteVideo != undefined) { - remoteVideo.srcObject = remoteStream; - } - }, - - incomingCall: function (call, invite) { - console.log('phone>>> incomingCall'); - }, - - callHoldStateChanged: function (call, isHold, isRemote) { - console.log(`phone>>> callHoldStateChanged to ${isHold ? 'hold' : 'unhold'} `); - } + loginStateChanged: function (isLogin, cause) { + console.log(cause); + switch (cause) { + case "connected": + console.log("phone>>> loginStateChanged: connected"); + break; + case "disconnected": + console.log("phone>>> loginStateChanged: disconnected"); + break; + case "login failed": + console.log("phone>>> loginStateChanged: login failed"); + break; + case "login": + console.log("phone>>> loginStateChanged: login"); + break; + case "logout": + console.log("phone>>> loginStateChanged: logout"); + break; + } + }, + + outgoingCallProgress: function (call, response) { + console.log("phone>>> outgoing call progress"); + }, + + callTerminated: function (call, message, cause) { + console.log(`phone>>> call terminated callback, cause=${cause}`); + }, + + callConfirmed: function (call, message, cause) { + console.log("phone>>> callConfirmed"); + }, + + callShowStreams: function (call, localStream, remoteStream) { + console.log("phone>>> callShowStreams"); + let remoteVideo = document.getElementById("remote-video-container"); + if (remoteVideo != undefined) { + remoteVideo.srcObject = remoteStream; + } + }, + + incomingCall: function (call, invite) { + console.log("phone>>> incomingCall"); + }, + + callHoldStateChanged: function (call, isHold, isRemote) { + console.log( + `phone>>> callHoldStateChanged to ${isHold ? "hold" : "unhold"}` + ); + }, }); ``` @@ -142,21 +149,21 @@ phone.setListeners({ - **Overview:** We have used two major capabilities to make the inbound call - - Caller to Callee & Callback from Callee to Caller - - Bridging the both calls to connect caller and callee in a single call + - Caller to Callee & Callback from Callee to Caller + - Bridging the both calls to connect caller and callee in a single call - **Sequence Diagram:** Follow sequence diagram to implement the in call using the SDK -![InboundFLow](bandwidth-inbound-react.drawio.svg) + ![InboundFLow](bandwidth-inbound-react.drawio.svg) - **Notification Handler Service Sample:** https://github.com/Bandwidth-Samples/in-app-calling-inbound-demo # Running the Application -Use the following command/s to run the application: +Use the following command to run the application: ```sh -yarn start +npm start ``` # Error Handling From de49cd0a135a2eb7482deacf295651a479478e00 Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Tue, 21 Apr 2026 16:09:15 -0400 Subject: [PATCH 03/13] feat: self-contained backend + server-side OAuth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the Express backend from javascript-brtc-sdk-sample-app into ./server so the dialpad is a single-repo story. The backend now owns every call to the Bandwidth platform: - GET /access-token — mints a short-lived OAuth access token from BW_ID_CLIENT_ID/SECRET. The browser hands this to BandwidthUA.setOAuthToken. Client credentials never leave the server; no long-lived token in the bundle. - /bwapi/* — same-origin relay to api.bandwidth.com so the SDK's endpoint- creation POST avoids a browser CORS preflight. Replaces the old setupProxy.js. - /callbacks/bandwidth, /calls/answer, /calls/status — existing BRTC + Voice callback handlers, plus a small patch in placeCall() that auto-registers SDK-created endpoints (dialpad's SDK creates its own endpoints, they don't come from the server's /token pool). package.json picks up express/cors/dotenv/bandwidth-sdk/http-proxy-middleware deps, adds concurrently + tsx to run `npm start` → server (:3000) + React (:3001) together, and sets `"proxy": "http://localhost:3000"` so the dev server forwards non-asset requests to the backend. DialPad.js now fetches /access-token at mount and wires gatewayUrl / httpBaseUrl / eventCallbackUrl into the BandwidthUA constructor so env overrides actually propagate. setupProxy.js is removed — the backend is the only proxy. Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 41 ++- README.md | 56 +++- package.json | 18 +- server/index.ts | 576 ++++++++++++++++++++++++++++++++++++++ server/types.ts | 6 + src/components/DialPad.js | 60 ++-- 6 files changed, 719 insertions(+), 38 deletions(-) create mode 100644 server/index.ts create mode 100644 server/types.ts diff --git a/.env.example b/.env.example index 3f2f0da..381b183 100644 --- a/.env.example +++ b/.env.example @@ -1,17 +1,46 @@ +# ---------- Client-visible (React) ---------- +# These are baked into the bundle at build time. Anything here is public. + # Required: Your Bandwidth account ID REACT_APP_ACCOUNT_ID= -# Required: Your OAuth / Identity token -REACT_APP_AUTH_TOKEN= - # Required: Source phone number (e.g. +15551234567) REACT_APP_ACCOUNT_USERNAME= # Optional: Override the WebRTC gateway WebSocket URL # REACT_APP_GATEWAY_URL=wss://gateway.pv.prod.global.aws.bandwidth.com/prod/gateway-service/api/v1/endpoints -# Optional: Override the Bandwidth REST API base URL -# REACT_APP_HTTP_BASE_URL=https://api.bandwidth.com/v2 +# Optional: Override the Bandwidth REST API base URL as seen from the browser +# (defaults to the same-origin /bwapi path proxied through the Express backend). +# REACT_APP_HTTP_BASE_URL=/bwapi -# Optional: Event callback URL for inbound call notifications +# Optional: Event callback URL registered on the endpoint — the gateway posts +# outboundConnectionRequest here so the backend can bridge PSTN legs. Should +# point at /callbacks/bandwidth. # REACT_APP_EVENT_CALLBACK_URL= + +# ---------- Server-side only (Express backend in ./server) ---------- +# Never prefix with REACT_APP_ — anything prefixed ends up in the bundle. + +# Required: OAuth2 client credentials. The backend uses these at GET /access-token +# to mint short-lived access tokens for the browser, and internally when calling +# the Voice API. BW_ID_CLIENT_ID/SECRET never leave the server. +BW_ID_CLIENT_ID= +BW_ID_CLIENT_SECRET= + +# Required: account and application used by the backend to place PSTN legs. +ACCOUNT_ID= +APPLICATION_ID= +FROM_NUMBER= + +# Required: publicly reachable base URL that the Bandwidth platform can POST +# callbacks to (e.g. an ngrok / cloudflared tunnel → http://localhost:3000). +CALLBACK_BASE_URL= + +# Optional: override the Bandwidth REST / Identity / Voice hosts (e.g. stage) +# HTTP_BASE_URL=https://api.bandwidth.com/v2 +# BW_ID_HOSTNAME=https://api.bandwidth.com +# VOICE_URL=https://voice.bandwidth.com/api/v2 + +# Optional: backend port (default 3000) +# PORT=3000 diff --git a/README.md b/README.md index a73a75c..220768a 100644 --- a/README.md +++ b/README.md @@ -9,13 +9,24 @@ # Description -A simple dial pad application used to create calls using our WebRTC SDK. +A self-contained dial pad app that exercises the Bandwidth WebRTC SDK against the new BRTC platform. Runs two processes from a single `npm start`: + +- **Express backend** (`server/`) — registers the endpoint event callback, handles `outboundConnectionRequest`, places PSTN legs via the Voice API, and returns `` BXML to bridge. Listens on `PORT` (default 3000). +- **React dev server** (`src/`) — serves the UI on port 3001. `src/setupProxy.js` mints short-lived OAuth access tokens at `GET /token` and same-origin-proxies the BW REST API at `/bwapi` so the SDK's endpoint creation doesn't hit a browser CORS preflight. + +Inbound Bandwidth callbacks reach the backend via a public tunnel you provide (`CALLBACK_BASE_URL`, typically ngrok or cloudflared → `http://localhost:3000`). # Pre-Requisites -In order to use this sample app, your account must have In-App Calling enabled. You will also have to generate an auth token using our Identity API. +Your account must have In-App Calling enabled. For API credentials, see [Account Credentials](https://dev.bandwidth.com/docs/account/credentials). -For more information about API credentials see our [Account Credentials](https://dev.bandwidth.com/docs/account/credentials) page. +You need an OAuth2 client ID and secret. The dev server mints short-lived access tokens on demand (see `src/setupProxy.js`), so the browser never sees the client secret and no long-lived token is baked into the bundle. + +You also need a publicly reachable URL forwarding to `http://localhost:3000` (ngrok, cloudflared, or equivalent) and a Bandwidth Voice application whose `CallInitiatedCallbackUrl` points at `/callbacks/bandwidth`. You can update that with the [band CLI](https://github.com/Bandwidth/bw-cli): + +```sh +band app update --callback-url https:///callbacks/bandwidth +``` ### Environment Setup @@ -28,17 +39,29 @@ cp .env.example .env 2. Fill in your values in `.env`: ```sh +# Client-visible (React) — baked into the bundle REACT_APP_ACCOUNT_ID= # Your Bandwidth account ID -REACT_APP_AUTH_TOKEN= # Your OAuth / Identity token REACT_APP_ACCOUNT_USERNAME= # Source phone number (e.g. +15551234567) +REACT_APP_EVENT_CALLBACK_URL= # /callbacks/bandwidth + +# Server-side (Express backend in ./server) +BW_ID_CLIENT_ID= # Bandwidth OAuth2 client ID +BW_ID_CLIENT_SECRET= # Bandwidth OAuth2 client secret +ACCOUNT_ID= # Same as REACT_APP_ACCOUNT_ID, for the backend +APPLICATION_ID= # Voice application that owns FROM_NUMBER +FROM_NUMBER= # PSTN number the backend originates from +CALLBACK_BASE_URL= # Public tunnel base URL (e.g. https://xyz.trycloudflare.com) ``` Optional overrides (uncomment in `.env` if needed): ```sh # REACT_APP_GATEWAY_URL= # Override the WebRTC gateway WebSocket URL -# REACT_APP_HTTP_BASE_URL= # Override the Bandwidth REST API base URL -# REACT_APP_EVENT_CALLBACK_URL= # Event callback URL for inbound call notifications +# REACT_APP_HTTP_BASE_URL= # Override REST base URL (defaults to /bwapi proxy) +# HTTP_BASE_URL= # Full BW REST URL incl. /v2 (default https://api.bandwidth.com/v2) +# BW_ID_HOSTNAME= # Override the Identity host (default https://api.bandwidth.com) +# VOICE_URL= # Override the Voice API URL (default https://voice.bandwidth.com/api/v2) +# PORT= # Backend port (default 3000) ``` # Initialization @@ -54,7 +77,12 @@ const phone = new BandwidthUA({ phone.checkAvailableDevices(); phone.setAccount(`${sourceNumber}`, "In-App Calling Sample", ""); -phone.setOAuthToken(authToken); + +// Fetch a short-lived access token from the dev server's /token endpoint +// (backed by setupProxy.js — client credentials stay server-side). +const { access_token } = await (await fetch("/token")).json(); +phone.setOAuthToken(access_token); + await phone.init(); ``` @@ -160,12 +188,24 @@ phone.setListeners({ # Running the Application -Use the following command to run the application: +`npm start` runs both the backend and the React dev server concurrently: ```sh +npm install npm start +# → Express backend on http://localhost:3000 +# → React dev server on http://localhost:3001 (CRA defaults to 3001 when 3000 is taken) +``` + +You also need a tunnel forwarding `https://` → `http://localhost:3000`, e.g.: + +```sh +cloudflared tunnel --url http://localhost:3000 +# or: ngrok http 3000 ``` +Put the resulting URL into `CALLBACK_BASE_URL` and `REACT_APP_EVENT_CALLBACK_URL` (append `/callbacks/bandwidth` for the latter), and keep the Voice application's callback URL in sync via `band app update`. + # Error Handling Errors, especially in networked operations, are inevitable. Ensure you catch, manage, and inform users about these, fostering a seamless experience. diff --git a/package.json b/package.json index c361558..9591a0c 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "1.0.0", "description": "In-App Calling Dialpad Sample App", "private": true, + "proxy": "http://localhost:3000", "repository": { "type": "git", "url": "git+https://github.com/Bandwidth-Samples/in-app-calling-dialpad-node-react.git" @@ -13,7 +14,11 @@ "url": "https://github.com/Bandwidth-Samples/in-app-calling-dialpad-node-react/issues" }, "devDependencies": { - "@babel/plugin-proposal-private-property-in-object": "^7.16.7" + "@babel/plugin-proposal-private-property-in-object": "^7.16.7", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.23", + "concurrently": "^9.0.0", + "tsx": "^4.20.6" }, "dependencies": { "@bandwidth/bw-webrtc-sdk": "file:../javascript-webrtc-sdk", @@ -28,7 +33,12 @@ "@testing-library/react": "^13.0.0", "@testing-library/user-event": "^13.2.1", "axios": "^1.5.0", + "bandwidth-sdk": "^7.4.2", + "cors": "^2.8.6", + "dotenv": "^16.6.1", + "express": "^4.22.1", "firebase": "^10.12.3", + "http-proxy-middleware": "^3.0.5", "react": "^18.2.0", "react-dom": "^18.2.0", "react-query": "^3.39.2", @@ -37,7 +47,9 @@ "react-timer-hook": "^3.0.0" }, "scripts": { - "start": "react-scripts start", + "start": "concurrently -n server,react -c cyan,magenta \"npm:server\" \"npm:react\"", + "server": "tsx server/index.ts", + "react": "PORT=3001 react-scripts start", "build": "react-scripts build", "test": "react-scripts test" }, @@ -59,4 +71,4 @@ "last 1 safari version" ] } -} \ No newline at end of file +} diff --git a/server/index.ts b/server/index.ts new file mode 100644 index 0000000..e11f579 --- /dev/null +++ b/server/index.ts @@ -0,0 +1,576 @@ +import 'dotenv/config'; +import express, { Request, Response } from 'express'; +import cors from 'cors'; +import { createProxyMiddleware } from 'http-proxy-middleware'; +import { CallsApi, Configuration } from 'bandwidth-sdk'; + +const app = express(); +app.use(cors()); + +// Log ALL incoming requests BEFORE body parsing +app.use((req, res, next) => { + console.log(`\n>>> ${req.method} ${req.path} [${req.get('content-type') || 'no content-type'}]`); + next(); +}); + +// Same-origin relay to the Bandwidth REST API. Mounted BEFORE body parsers so +// the raw request body is streamed through untouched. A simple fetch-based +// relay avoids http-proxy-middleware version quirks (works with v2 and v3). +const BW_API_HOST = (() => { + const raw = process.env.HTTP_BASE_URL; + if (!raw) return 'https://api.bandwidth.com'; + try { + const u = new URL(raw); + return `${u.protocol}//${u.host}`; + } catch { + return 'https://api.bandwidth.com'; + } +})(); +app.use('/bwapi', async (req: Request, res: Response) => { + console.log(`[bwapi relay] ${req.method} ${req.url} -> ${BW_API_HOST}/v2${req.url}`); + const readBody = (): Promise => + new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (c: Buffer) => chunks.push(c)); + req.on('end', () => resolve(chunks.length ? Buffer.concat(chunks) : Buffer.alloc(0))); + req.on('error', reject); + }); + try { + const body = await readBody(); + console.log(`[bwapi relay] body length=${body.length}`); + const headers: Record = {}; + for (const [k, v] of Object.entries(req.headers)) { + if (typeof v === 'string' && k !== 'host' && k !== 'content-length') { + headers[k] = v; + } + } + const upstreamRes = await fetch(`${BW_API_HOST}/v2${req.url}`, { + method: req.method, + headers, + body: body.length ? body : undefined, + }); + console.log(`[bwapi relay] upstream status=${upstreamRes.status}`); + res.status(upstreamRes.status); + upstreamRes.headers.forEach((value, key) => { + if (key.toLowerCase() !== 'transfer-encoding' && key.toLowerCase() !== 'content-encoding') { + res.setHeader(key, value); + } + }); + const buf = Buffer.from(await upstreamRes.arrayBuffer()); + res.send(buf); + } catch (err: any) { + console.error('bwapi relay error:', err.message); + res.status(502).json({ error: err.message }); + } +}); + +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); +app.use(express.text({ type: '*/xml' })); + +// Log parsed body +app.use((req, res, next) => { + if (req.body && typeof req.body === 'object' && Object.keys(req.body).length > 0) { + console.log(JSON.stringify(req.body, null, 2)); + } else if (req.body && typeof req.body === 'string') { + console.log(req.body); + } + next(); +}); + +const PROD_VOICE_URL = 'https://voice.bandwidth.com/api/v2'; + +function getEnvVars() { + const env = process.env; + const hasUserPass = !!env.BW_USERNAME && !!env.BW_PASSWORD; + const hasClientCreds = !!env.BW_ID_CLIENT_ID && !!env.BW_ID_CLIENT_SECRET; + if (!hasUserPass && !hasClientCreds) { + throw new Error('You must set either BW_USERNAME/BW_PASSWORD or BW_ID_CLIENT_ID/BW_ID_CLIENT_SECRET in your environment.'); + } + const required = ['ACCOUNT_ID', 'APPLICATION_ID', 'FROM_NUMBER', 'CALLBACK_BASE_URL']; + for (const v of required) { + if (!env[v]) throw new Error(`Missing required environment variable: ${v}`); + } + return { + HTTP_BASE_URL: (env.HTTP_BASE_URL || 'https://api.bandwidth.com/v2') as string, + VOICE_URL: (env.VOICE_URL || PROD_VOICE_URL) as string, + CALLBACK_BASE_URL: env.CALLBACK_BASE_URL as string, + ACCOUNT_ID: env.ACCOUNT_ID as string, + APPLICATION_ID: env.APPLICATION_ID as string, + BW_USERNAME: env.BW_USERNAME as string | undefined, + BW_PASSWORD: env.BW_PASSWORD as string | undefined, + BW_ID_CLIENT_ID: env.BW_ID_CLIENT_ID as string | undefined, + BW_ID_CLIENT_SECRET: env.BW_ID_CLIENT_SECRET as string | undefined, + BW_ID_HOSTNAME: (env.BW_ID_HOSTNAME || 'https://api.bandwidth.com') as string, + FROM_NUMBER: env.FROM_NUMBER as string, + PORT: parseInt(env.PORT || '3000', 10), + }; +} + +const { + HTTP_BASE_URL, + VOICE_URL, + CALLBACK_BASE_URL, + ACCOUNT_ID, + APPLICATION_ID, + BW_USERNAME, + BW_PASSWORD, + BW_ID_CLIENT_ID, + BW_ID_CLIENT_SECRET, + BW_ID_HOSTNAME, + FROM_NUMBER, + PORT, +} = getEnvVars(); + +// Endpoint ID -> Available Status +let endpointAvailableMap = new Map(); +// Call ID -> Endpoint ID +let endpointCallIdMap = new Map(); +// Endpoint ID -> Call Status (tracks PSTN leg status for app polling) +let endpointCallStatusMap = new Map(); + +// --- OAuth Token Management --- + +let idToken: string = ''; +let idTokenExpiration: number = 0; + +async function getAuthToken(): Promise { + if (!idToken || Date.now() >= idTokenExpiration) { + const username = BW_ID_CLIENT_ID || BW_USERNAME!; + const password = BW_ID_CLIENT_SECRET || BW_PASSWORD!; + console.log('Fetching new OAuth access token'); + const response = await fetch(`${BW_ID_HOSTNAME}/api/v1/oauth2/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64'), + }, + body: new URLSearchParams({ grant_type: 'client_credentials' }), + }); + if (!response.ok) { + throw new Error(`Failed to fetch auth token: ${response.status} ${await response.text()}`); + } + const authData = await response.json(); + idToken = authData.access_token; + idTokenExpiration = Date.now() + (authData.expires_in - 10) * 1000; + console.log(`OAuth token obtained (expires in ${authData.expires_in}s)`); + } + return idToken; +} + +// --- Endpoint / Call Helpers --- + +async function placeCall(endpointId: string, toNumber: string, fromNumber: string): Promise { + // Dialpad-compat path: endpoint may have been created by the SDK (not this server's /token), + // so it won't be in the pool. Auto-register so status/hangup routes still work. + if (!endpointAvailableMap.has(endpointId)) { + console.log(`Auto-registering externally created endpoint ${endpointId}`); + endpointAvailableMap.set(endpointId, false); + } + + const token = await getAuthToken(); + const configuration = new Configuration({ accessToken: token }); + + if (VOICE_URL !== PROD_VOICE_URL) { + console.log(`Using custom voice URL: ${VOICE_URL}`); + configuration.basePath = VOICE_URL; + } + + const callsApi = new CallsApi(configuration); + const body = { + applicationId: APPLICATION_ID, + to: toNumber, + from: fromNumber, + answerUrl: `${CALLBACK_BASE_URL}/calls/answer`, + }; + + const response = await callsApi.createCall(ACCOUNT_ID, body); + const callId = response.data.callId; + console.log(`Placed outbound call ${callId} from endpoint ${endpointId} to ${toNumber}`); + endpointCallIdMap.set(callId, endpointId); + endpointCallStatusMap.set(endpointId, { callId, status: 'ringing' }); + return callId; +} + +function claimFirstAvailableEndpoint(): string { + for (const [endpointId, available] of endpointAvailableMap.entries()) { + if (available) { + claimEndpoint(endpointId); + return endpointId; + } + } + return ''; +} + +function claimEndpoint(endpointId: string) { + if (endpointAvailableMap.has(endpointId)) { + console.log(`Claiming endpoint from the pool ${endpointId}`); + endpointAvailableMap.set(endpointId, false); + } +} + +function releaseEndpoint(endpointId: string) { + if (endpointAvailableMap.has(endpointId)) { + console.log(`Releasing endpoint to the pool ${endpointId}`); + endpointAvailableMap.set(endpointId, true); + } +} + +function handleCallDisconnect(callId: string, cause?: string) { + const endpointId = endpointCallIdMap.get(callId); + if (endpointId) { + endpointCallStatusMap.set(endpointId, { callId, status: 'disconnected', cause }); + releaseEndpoint(endpointId); + } + endpointCallIdMap.delete(callId); +} + +function processInboundCall(callId: string): string { + const requestingEndpointId = endpointCallIdMap.get(callId); + if (!requestingEndpointId) { + const endpointId = claimFirstAvailableEndpoint(); + if (endpointId === '') { + return ` + + You are on-hold. Please wait for an available endpoint to connect to this call. + +`; + } + return ` + + Connecting + + ${endpointId} + +`; + } + return ` + + Connecting + + ${requestingEndpointId} + +`; +} + +async function deleteEndpoints(): Promise { + const authToken = await getAuthToken(); + const listResponse = await fetch(`${HTTP_BASE_URL}/accounts/${ACCOUNT_ID}/endpoints`, { + headers: { Authorization: 'Bearer ' + authToken }, + }); + if (!listResponse.ok) { + throw new Error(`Failed to list endpoints: ${listResponse.status} ${await listResponse.text()}`); + } + const listData = await listResponse.json(); + const endpoints: { endpointId: string }[] = listData.data ?? []; + console.log(`Deleting ${endpoints.length} endpoint(s)`); + await Promise.all( + endpoints.map(async ({ endpointId }) => { + const delResponse = await fetch(`${HTTP_BASE_URL}/accounts/${ACCOUNT_ID}/endpoints/${endpointId}`, { + method: 'DELETE', + headers: { Authorization: 'Bearer ' + authToken }, + }); + if (!delResponse.ok) { + console.error(`Failed to delete endpoint ${endpointId}: ${delResponse.status}`); + } else { + console.log(`Deleted endpoint ${endpointId}`); + endpointAvailableMap.delete(endpointId); + endpointCallIdMap.forEach((value, key) => { + if (value === endpointId) endpointCallIdMap.delete(key); + }); + } + }) + ); +} + +// --- Routes --- + +// GET /access-token - Mint a short-lived OAuth access token for the browser. +// The dialpad calls this on mount and hands the result to BandwidthUA.setOAuthToken. +// BW_CLIENT_ID / BW_CLIENT_SECRET never leave the server. +app.get('/access-token', async (_req: Request, res: Response) => { + try { + const accessToken = await getAuthToken(); + res.json({ access_token: accessToken }); + } catch (error: any) { + console.error('Error fetching access token:', error.message); + res.status(500).json({ error: error.message }); + } +}); + +// GET /token - Create a BRTC endpoint and return the JWT token +app.get('/token', async (req: Request, res: Response) => { + try { + const authToken = await getAuthToken(); + + const endpointUrl = `${HTTP_BASE_URL}/accounts/${ACCOUNT_ID}/endpoints`; + console.log(`Creating endpoint: POST ${endpointUrl}`); + + const endpointResponse = await fetch(endpointUrl, { + method: 'POST', + headers: { + Authorization: 'Bearer ' + authToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + type: 'WEBRTC', + direction: 'BIDIRECTIONAL', + eventCallbackUrl: `${CALLBACK_BASE_URL}/callbacks/bandwidth`, + eventFallbackUrl: `${CALLBACK_BASE_URL}/callbacks/bandwidth`, + tag: JSON.stringify({ source: 'javascript-sample-app' }), + }), + }); + + if (!endpointResponse.ok) { + const errorText = await endpointResponse.text(); + console.error(`Endpoint creation failed (${endpointResponse.status}): ${errorText}`); + return res.status(endpointResponse.status).json({ error: 'Failed to create endpoint', details: errorText }); + } + + const endpointData = await endpointResponse.json(); + const endpointId: string = endpointData.data.endpointId; + const token: string = endpointData.data.token; + + console.log(`Endpoint created: ${endpointId}`); + endpointAvailableMap.set(endpointId, false); + + res.json({ token, endpointId }); + } catch (error: any) { + console.error('Error creating endpoint:', error.message); + res.status(500).json({ error: error.message }); + } +}); + +// DELETE /api/endpoint/:endpointId - Delete a BRTC endpoint +app.delete('/api/endpoint/:endpointId', async (req: Request, res: Response) => { + const endpointId = req.params.endpointId; + if (!endpointId) { + res.status(400).send('Missing endpointId'); + return; + } + + endpointAvailableMap.delete(endpointId); + endpointCallIdMap.forEach((value, key) => { + if (value === endpointId) endpointCallIdMap.delete(key); + }); + + try { + const authToken = await getAuthToken(); + const endpointResponse = await fetch(`${HTTP_BASE_URL}/accounts/${ACCOUNT_ID}/endpoints/${endpointId}`, { + method: 'DELETE', + headers: { + Authorization: 'Bearer ' + authToken, + 'Content-Type': 'application/json', + }, + }); + if (!endpointResponse.ok) { + const errorText = await endpointResponse.text(); + throw new Error(`Endpoint deletion failed: ${endpointResponse.status} ${errorText}`); + } + res.sendStatus(200); + } catch (error: any) { + console.error('Error deleting endpoint:', error.message); + res.status(500).json({ error: error.message }); + } +}); + +// POST /callbacks/bandwidth - BRTC endpoint events AND incoming PSTN calls +app.post('/callbacks/bandwidth', async (req: Request, res: Response) => { + const event = req.body; + console.log('Callback event:', JSON.stringify(event, null, 2)); + + const endpointId: string = event.endpointId; + const eventType: string = event.event; + const toType: string = event.toType; + let to: string = event.to; + + // --- BRTC endpoint events --- + switch (eventType) { + case 'endpointIneligible': + claimEndpoint(endpointId); + return res.type('application/xml').send(``); + + case 'endpointEligible': + releaseEndpoint(endpointId); + return res.type('application/xml').send(``); + + case 'outboundConnectionRequest': + console.log(`Outbound call request for endpoint ${endpointId} to ${to} (${toType})`); + if (toType === 'PHONE_NUMBER') { + if (!to.startsWith('+')) to = `+${to}`; + try { + await placeCall(endpointId, to, FROM_NUMBER); + } catch (error: any) { + console.error('Error placing outbound call:', error.message); + } + } + return res.type('application/xml').send(``); + } + + // --- Incoming PSTN call (Voice API eventType: "initiate") --- + if (event.eventType === 'initiate' && event.direction === 'inbound') { + console.log(`Incoming PSTN call: ${event.from} -> ${event.to}, callId=${event.callId}`); + const xmlResponse = processInboundCall(event.callId); + if (event.callId) { + const claimedEndpointId = endpointCallIdMap.get(event.callId); + if (claimedEndpointId) { + endpointCallIdMap.set(event.callId, claimedEndpointId); + } + } + return res.type('application/xml').send(xmlResponse); + } + + res.type('application/xml').send(``); +}); + +// POST /callbacks/bandwidth/status - Voice API status events (disconnect, etc.) +app.post('/callbacks/bandwidth/status', (req: Request, res: Response) => { + const event = req.body; + console.log('Voice status event:', JSON.stringify(event, null, 2)); + + if (event.eventType === 'disconnect') { + console.log(`Call disconnected: ${event.callId}, cause: ${event.cause}`); + handleCallDisconnect(event.callId, event.cause); + } + + res.sendStatus(200); +}); + +// POST /calls/answer - BXML callback when an outbound call is answered +app.post('/calls/answer', (req: Request, res: Response) => { + const callId: string = req.body.callId; + const endpointId = endpointCallIdMap.get(callId); + if (endpointId) { + endpointCallStatusMap.set(endpointId, { callId, status: 'answered' }); + } + const xmlResponse = processInboundCall(callId); + console.log(`Call answer callback for callId: ${callId}`); + res.type('application/xml').send(xmlResponse); +}); + +// POST /calls/status - Call status updates (disconnect, redirect) +app.post('/calls/status', async (req: Request, res: Response) => { + const callId: string = req.body.callId; + const eventType: string = req.body.eventType; + + switch (eventType) { + case 'disconnect': + console.log(`Call disconnected with ID: ${callId}`); + handleCallDisconnect(callId, req.body.cause); + res.sendStatus(200); + break; + case 'redirect': + default: + console.log(`Call status update for callId: ${callId} (${eventType})`); + const xmlResponse = processInboundCall(callId); + res.type('application/xml').send(xmlResponse); + break; + } +}); + +// GET /api/endpoint/:endpointId/call-status - Get current PSTN call status for an endpoint +app.get('/api/endpoint/:endpointId/call-status', (req: Request, res: Response) => { + const endpointId = req.params.endpointId; + const status = endpointCallStatusMap.get(endpointId); + if (!status) { + return res.json({ status: 'idle' }); + } + res.json(status); +}); + +// POST /api/endpoint/:endpointId/hangup - Hang up the PSTN leg for an endpoint +app.post('/api/endpoint/:endpointId/hangup', async (req: Request, res: Response) => { + const endpointId = req.params.endpointId; + const callStatus = endpointCallStatusMap.get(endpointId); + if (!callStatus) { + return res.status(404).json({ error: 'No active call for this endpoint' }); + } + + try { + const token = await getAuthToken(); + const configuration = new Configuration({ accessToken: token }); + if (VOICE_URL !== PROD_VOICE_URL) { + configuration.basePath = VOICE_URL; + } + + const callsApi = new CallsApi(configuration); + await callsApi.updateCall(ACCOUNT_ID, callStatus.callId, { + state: 'completed', + }); + console.log(`Hung up PSTN leg ${callStatus.callId} for endpoint ${endpointId}`); + handleCallDisconnect(callStatus.callId, 'app-hangup'); + res.sendStatus(200); + } catch (error: any) { + console.error(`Error hanging up call for endpoint ${endpointId}:`, error.message); + res.status(500).json({ error: error.message }); + } +}); + +// DELETE /api/endpoints - Delete all endpoints on the account +app.delete('/api/endpoints', async (req: Request, res: Response) => { + try { + await deleteEndpoints(); + res.sendStatus(200); + } catch (error: any) { + console.error('Error deleting all endpoints:', error.message); + res.status(500).json({ error: error.message }); + } +}); + +// POST /simulate-incoming-call - Place a test call to a specific endpoint +app.post('/simulate-incoming-call', async (req: Request, res: Response) => { + const { endpointId, toNumber, fromNumber } = req.body; + try { + await placeCall(endpointId, toNumber || FROM_NUMBER, fromNumber || FROM_NUMBER); + res.sendStatus(200); + } catch (error: any) { + console.error('Error placing test call:', error.message); + res.status(500).json({ error: error.message }); + } +}); + +// GET /health +app.get('/health', (req: Request, res: Response) => { + res.json({ status: 'ok' }); +}); + +// GET /debug/endpoints +app.get('/debug/endpoints', (req: Request, res: Response) => { + const available: string[] = []; + const unavailable: string[] = []; + for (const [id, isAvailable] of endpointAvailableMap.entries()) { + (isAvailable ? available : unavailable).push(id); + } + res.json({ + total: endpointAvailableMap.size, + available, + unavailable, + callMappings: Object.fromEntries(endpointCallIdMap), + }); +}); + +// Catch-all +app.all('/{*path}', (req: Request, res: Response) => { + console.log(`\n!!! UNMATCHED ROUTE: ${req.method} ${req.path}`); + res.sendStatus(404); +}); + +app.listen(PORT, '0.0.0.0', () => { + console.log(`BRTC token server running on http://localhost:${PORT}`); + console.log(` Account: ${ACCOUNT_ID}`); + console.log(` App ID: ${APPLICATION_ID}`); + console.log(` From: ${FROM_NUMBER}`); + console.log(` Callback: ${CALLBACK_BASE_URL}`); + console.log(); + console.log(` GET /token - Create endpoint and get JWT`); + console.log(` DELETE /api/endpoint/:endpointId - Delete endpoint`); + console.log(` GET /api/endpoint/:endpointId/call-status - Get PSTN call status`); + console.log(` POST /api/endpoint/:endpointId/hangup - Hang up PSTN leg`); + console.log(` POST /callbacks/bandwidth - BRTC events + incoming PSTN calls`); + console.log(` POST /callbacks/bandwidth/status - Voice API status (disconnect)`); + console.log(` POST /calls/answer - Outbound call answer BXML callback`); + console.log(` POST /calls/status - Call status updates`); + console.log(` POST /simulate-incoming-call - Place a test call`); + console.log(` GET /health - Health check`); + console.log(` GET /debug/endpoints - Inspect endpoint pool`); +}); diff --git a/server/types.ts b/server/types.ts new file mode 100644 index 0000000..df94d9a --- /dev/null +++ b/server/types.ts @@ -0,0 +1,6 @@ +class Endpoint { + token: string; + endpointId: string; +} + +export {Endpoint} diff --git a/src/components/DialPad.js b/src/components/DialPad.js index ad073de..539f2cd 100644 --- a/src/components/DialPad.js +++ b/src/components/DialPad.js @@ -15,10 +15,21 @@ import { Button } from '@mui/material'; export default function DialPad() { const userId = process.env.REACT_APP_ACCOUNT_USERNAME; - const authToken = process.env.REACT_APP_AUTH_TOKEN; const accountId = process.env.REACT_APP_ACCOUNT_ID; const sourceNumber = userId; + // OAuth access tokens come from the Express backend's /access-token route + // (see server/index.ts), which does the client-credentials exchange server-side. + // The `proxy` field in package.json forwards this to http://localhost:3000. + const fetchAuthToken = async () => { + const res = await fetch('/access-token'); + if (!res.ok) { + throw new Error(`Failed to fetch auth token: ${res.status}`); + } + const { access_token } = await res.json(); + return access_token; + }; + const { totalSeconds, seconds, minutes, hours, start, pause, reset } = useStopwatch({ autoStart: false }); const [destNumber, setDestNumber] = useState(''); @@ -72,29 +83,36 @@ export default function DialPad() { }, []) useEffect(() => { - const newPhone = new BandwidthUA({ - accountId: accountId, - // Optional overrides: - // gatewayUrl: process.env.REACT_APP_GATEWAY_URL, - // httpBaseUrl: process.env.REACT_APP_HTTP_BASE_URL, - // eventCallbackUrl: process.env.REACT_APP_EVENT_CALLBACK_URL, - }); - console.log(`version: `, newPhone.version()); + async function initSdk() { + const newPhone = new BandwidthUA({ + accountId: accountId, + gatewayUrl: process.env.REACT_APP_GATEWAY_URL, + httpBaseUrl: process.env.REACT_APP_HTTP_BASE_URL, + eventCallbackUrl: process.env.REACT_APP_EVENT_CALLBACK_URL, + }); + console.log(`version: `, newPhone.version()); - // These are still accepted for backwards compatibility but are no longer - // required — the new SDK connects directly to the WebRTC gateway. - newPhone.setWebSocketKeepAlive(5, false, false, 5, true); + // These are still accepted for backwards compatibility but are no longer + // required — the new SDK connects directly to the WebRTC gateway. + newPhone.setWebSocketKeepAlive(5, false, false, 5, true); - //overriding the SDK logs - newPhone.setBWLogger((...e) => { - console.log(...e); - }); + //overriding the SDK logs + newPhone.setBWLogger((...e) => { + console.log(...e); + }); + + newPhone.checkAvailableDevices(); + newPhone.setAccount(`${sourceNumber}`, 'In-App Calling Sample', ''); - newPhone.checkAvailableDevices(); - newPhone.setAccount(`${sourceNumber}`, 'In-App Calling Sample', ''); - newPhone.setOAuthToken(authToken); - newPhone.init(); - setPhone(newPhone); + const token = await fetchAuthToken(); + newPhone.setOAuthToken(token); + await newPhone.init(); + setPhone(newPhone); + } + + initSdk().catch((err) => { + console.error('SDK init failed:', err); + }); }, []); useEffect(() => { From 55322c0427905b8acbb6e63aeb1aa46c1ed8430e Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Wed, 22 Apr 2026 15:18:57 -0400 Subject: [PATCH 04/13] =?UTF-8?q?Minimize=20sample=20app=20PR:=20cut=20bac?= =?UTF-8?q?kend=20from=20576=E2=86=9259=20lines,=20trim=20env/README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aggressively pruned the migration guide diff: - server/index.ts: removed all call-bridging, endpoint pooling, endpoint creation, BXML routing, debug routes. Kept only the OAuth token endpoint customers need to copy: /access-token (client-credentials exchange) + caching. - Removed server/types.ts (unused stub) - .env.example: cut from 46→12 lines (only required vars for OAuth token endpoint) - README.md: removed detailed setup/callback sections, kept only: "fetch OAuth token from backend" pattern (the actual migration diff) - package.json: removed bandwidth-sdk and http-proxy-middleware (unused) The sample is now a true migration guide: customers see exactly what they need to add to migrate v1→v2 SDK without distraction. Before: 813 net insertions. After: 91 net insertions (-682 lines). Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 42 +--- README.md | 233 +++++--------------- package.json | 6 - server/index.ts | 576 +++--------------------------------------------- server/types.ts | 6 - 5 files changed, 91 insertions(+), 772 deletions(-) delete mode 100644 server/types.ts diff --git a/.env.example b/.env.example index 381b183..05f028d 100644 --- a/.env.example +++ b/.env.example @@ -1,46 +1,12 @@ -# ---------- Client-visible (React) ---------- -# These are baked into the bundle at build time. Anything here is public. - -# Required: Your Bandwidth account ID +# Client-visible (React) — baked into bundle REACT_APP_ACCOUNT_ID= - -# Required: Source phone number (e.g. +15551234567) REACT_APP_ACCOUNT_USERNAME= -# Optional: Override the WebRTC gateway WebSocket URL -# REACT_APP_GATEWAY_URL=wss://gateway.pv.prod.global.aws.bandwidth.com/prod/gateway-service/api/v1/endpoints - -# Optional: Override the Bandwidth REST API base URL as seen from the browser -# (defaults to the same-origin /bwapi path proxied through the Express backend). -# REACT_APP_HTTP_BASE_URL=/bwapi - -# Optional: Event callback URL registered on the endpoint — the gateway posts -# outboundConnectionRequest here so the backend can bridge PSTN legs. Should -# point at /callbacks/bandwidth. -# REACT_APP_EVENT_CALLBACK_URL= - -# ---------- Server-side only (Express backend in ./server) ---------- -# Never prefix with REACT_APP_ — anything prefixed ends up in the bundle. - -# Required: OAuth2 client credentials. The backend uses these at GET /access-token -# to mint short-lived access tokens for the browser, and internally when calling -# the Voice API. BW_ID_CLIENT_ID/SECRET never leave the server. +# Server-side only (never prefixed with REACT_APP_) +# OAuth2 client credentials for the backend's GET /access-token endpoint BW_ID_CLIENT_ID= BW_ID_CLIENT_SECRET= -# Required: account and application used by the backend to place PSTN legs. -ACCOUNT_ID= -APPLICATION_ID= -FROM_NUMBER= - -# Required: publicly reachable base URL that the Bandwidth platform can POST -# callbacks to (e.g. an ngrok / cloudflared tunnel → http://localhost:3000). -CALLBACK_BASE_URL= - -# Optional: override the Bandwidth REST / Identity / Voice hosts (e.g. stage) -# HTTP_BASE_URL=https://api.bandwidth.com/v2 +# Optional # BW_ID_HOSTNAME=https://api.bandwidth.com -# VOICE_URL=https://voice.bandwidth.com/api/v2 - -# Optional: backend port (default 3000) # PORT=3000 diff --git a/README.md b/README.md index 220768a..0f0d8d8 100644 --- a/README.md +++ b/README.md @@ -1,211 +1,92 @@ # In-App Calling Dialpad -# Table of Contents +A minimal sample showing how to migrate from the v1 WebRTC SDK to v2. The key change: instead of baking an auth token into your app, fetch it server-side via OAuth client credentials. -- [Description](#description) -- [Pre-Requisites](#pre-requisites) -- [Initialization](#initialization) -- [Running the Application](#running-the-application) +# Prerequisites -# Description +Your account must have In-App Calling enabled. See [Account Credentials](https://dev.bandwidth.com/docs/account/credentials) for API setup. -A self-contained dial pad app that exercises the Bandwidth WebRTC SDK against the new BRTC platform. Runs two processes from a single `npm start`: - -- **Express backend** (`server/`) — registers the endpoint event callback, handles `outboundConnectionRequest`, places PSTN legs via the Voice API, and returns `` BXML to bridge. Listens on `PORT` (default 3000). -- **React dev server** (`src/`) — serves the UI on port 3001. `src/setupProxy.js` mints short-lived OAuth access tokens at `GET /token` and same-origin-proxies the BW REST API at `/bwapi` so the SDK's endpoint creation doesn't hit a browser CORS preflight. - -Inbound Bandwidth callbacks reach the backend via a public tunnel you provide (`CALLBACK_BASE_URL`, typically ngrok or cloudflared → `http://localhost:3000`). - -# Pre-Requisites - -Your account must have In-App Calling enabled. For API credentials, see [Account Credentials](https://dev.bandwidth.com/docs/account/credentials). - -You need an OAuth2 client ID and secret. The dev server mints short-lived access tokens on demand (see `src/setupProxy.js`), so the browser never sees the client secret and no long-lived token is baked into the bundle. - -You also need a publicly reachable URL forwarding to `http://localhost:3000` (ngrok, cloudflared, or equivalent) and a Bandwidth Voice application whose `CallInitiatedCallbackUrl` points at `/callbacks/bandwidth`. You can update that with the [band CLI](https://github.com/Bandwidth/bw-cli): +# Setup ```sh -band app update --callback-url https:///callbacks/bandwidth +cp .env.example .env ``` -### Environment Setup - -1. Copy the example environment file: +Edit `.env`: ```sh -cp .env.example .env +# React (baked into bundle) +REACT_APP_ACCOUNT_ID= +REACT_APP_ACCOUNT_USERNAME= + +# Express backend +BW_ID_CLIENT_ID= +BW_ID_CLIENT_SECRET= ``` -2. Fill in your values in `.env`: +# Running ```sh -# Client-visible (React) — baked into the bundle -REACT_APP_ACCOUNT_ID= # Your Bandwidth account ID -REACT_APP_ACCOUNT_USERNAME= # Source phone number (e.g. +15551234567) -REACT_APP_EVENT_CALLBACK_URL= # /callbacks/bandwidth - -# Server-side (Express backend in ./server) -BW_ID_CLIENT_ID= # Bandwidth OAuth2 client ID -BW_ID_CLIENT_SECRET= # Bandwidth OAuth2 client secret -ACCOUNT_ID= # Same as REACT_APP_ACCOUNT_ID, for the backend -APPLICATION_ID= # Voice application that owns FROM_NUMBER -FROM_NUMBER= # PSTN number the backend originates from -CALLBACK_BASE_URL= # Public tunnel base URL (e.g. https://xyz.trycloudflare.com) +npm install +npm start +# → Backend on http://localhost:3000 +# → React on http://localhost:3001 ``` -Optional overrides (uncomment in `.env` if needed): +# What Changed -```sh -# REACT_APP_GATEWAY_URL= # Override the WebRTC gateway WebSocket URL -# REACT_APP_HTTP_BASE_URL= # Override REST base URL (defaults to /bwapi proxy) -# HTTP_BASE_URL= # Full BW REST URL incl. /v2 (default https://api.bandwidth.com/v2) -# BW_ID_HOSTNAME= # Override the Identity host (default https://api.bandwidth.com) -# VOICE_URL= # Override the Voice API URL (default https://voice.bandwidth.com/api/v2) -# PORT= # Backend port (default 3000) -``` +**v1 (old):** Auth token passed via environment variable. -# Initialization +**v2 (new):** Backend's `GET /access-token` endpoint mints a short-lived token using client credentials. DialPad fetches it on mount and hands it to `setOAuthToken()`. Client secret never leaves the server. -- **BandwidthUA**: The instance is available from the outset. Initialization is required before making a call. Follow the below code snippet for initialization: +# Minimal Migration Example -```js -import { BandwidthUA } from "@bandwidth/bw-webrtc-sdk"; +See `src/components/DialPad.js` for the key pattern: +```js +// Fetch OAuth token from backend on mount +const fetchAuthToken = async () => { + const res = await fetch('/access-token'); + const { access_token } = await res.json(); + return access_token; +}; + +// Initialize SDK (same v1 API, v2 SDK) const phone = new BandwidthUA({ accountId: accountId, + gatewayUrl: process.env.REACT_APP_GATEWAY_URL, + httpBaseUrl: process.env.REACT_APP_HTTP_BASE_URL, + eventCallbackUrl: process.env.REACT_APP_EVENT_CALLBACK_URL, }); -phone.checkAvailableDevices(); -phone.setAccount(`${sourceNumber}`, "In-App Calling Sample", ""); - -// Fetch a short-lived access token from the dev server's /token endpoint -// (backed by setupProxy.js — client credentials stay server-side). -const { access_token } = await (await fetch("/token")).json(); -phone.setOAuthToken(access_token); - +phone.setAccount(sourceNumber, 'In-App Calling Sample', ''); +const token = await fetchAuthToken(); +phone.setOAuthToken(token); await phone.init(); ``` -> **Note:** In v1.2.0, `setServerConfig()` is no longer required. The SDK connects directly to the Bandwidth WebRTC backend. The only new requirement is passing `accountId` in the constructor. See the [SDK README](https://github.com/Bandwidth/javascript-webrtc-sdk#migration-from-v11x) for migration details. - -# Usage - -### Making a Call - -Making a call using the Bandwidth services involves a series of steps to ensure the call's proper initiation and management. - -```js -const activeCall = await phone.makeCall(`${destNumber}`, extraHeaders); -``` - -Keep the `activeCall` instance in persistent state in order to reuse this instance for call termination, hold & mute. - -### Terminating a Call - -```js -activeCall.terminate(); -``` - -This method is responsible for correctly signaling the termination of the call session. After invoking this method, it's a good practice to handle UI transitions and take any other post-call actions that may be necessary in your application's context. - -### Listeners and Implementation - -Listeners are pivotal in monitoring and responding to real-time events during the call. - -In the provided code, the `BandwidthUA.setListeners` is used. This listener has multiple callback methods. - -**Implementation**: - -To use the listener, you implement it as an anonymous class and provide logic inside each method: +And in `server/index.ts`: ```js -phone.setListeners({ - loginStateChanged: function (isLogin, cause) { - console.log(cause); - switch (cause) { - case "connected": - console.log("phone>>> loginStateChanged: connected"); - break; - case "disconnected": - console.log("phone>>> loginStateChanged: disconnected"); - break; - case "login failed": - console.log("phone>>> loginStateChanged: login failed"); - break; - case "login": - console.log("phone>>> loginStateChanged: login"); - break; - case "logout": - console.log("phone>>> loginStateChanged: logout"); - break; - } - }, - - outgoingCallProgress: function (call, response) { - console.log("phone>>> outgoing call progress"); - }, - - callTerminated: function (call, message, cause) { - console.log(`phone>>> call terminated callback, cause=${cause}`); - }, - - callConfirmed: function (call, message, cause) { - console.log("phone>>> callConfirmed"); - }, - - callShowStreams: function (call, localStream, remoteStream) { - console.log("phone>>> callShowStreams"); - let remoteVideo = document.getElementById("remote-video-container"); - if (remoteVideo != undefined) { - remoteVideo.srcObject = remoteStream; - } - }, - - incomingCall: function (call, invite) { - console.log("phone>>> incomingCall"); - }, - - callHoldStateChanged: function (call, isHold, isRemote) { - console.log( - `phone>>> callHoldStateChanged to ${isHold ? "hold" : "unhold"}` - ); - }, +// Backend endpoint: mint OAuth token (client credentials → access_token) +app.get('/access-token', async (_req, res) => { + const token = await getAuthToken(); + res.json({ access_token: token }); }); -``` - -### Configuring Inbound Calls - -- **Overview:** We have used two major capabilities to make the inbound call - - - Caller to Callee & Callback from Callee to Caller - - Bridging the both calls to connect caller and callee in a single call -- **Sequence Diagram:** Follow sequence diagram to implement the in call using the SDK - ![InboundFLow](bandwidth-inbound-react.drawio.svg) - -- **Notification Handler Service Sample:** - https://github.com/Bandwidth-Samples/in-app-calling-inbound-demo - -# Running the Application - -`npm start` runs both the backend and the React dev server concurrently: - -```sh -npm install -npm start -# → Express backend on http://localhost:3000 -# → React dev server on http://localhost:3001 (CRA defaults to 3001 when 3000 is taken) +// getAuthToken() does the client-credentials exchange with Bandwidth's IDP +async function getAuthToken() { + const response = await fetch(`${BW_ID_HOSTNAME}/api/v1/oauth2/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: 'Basic ' + Buffer.from(`${BW_ID_CLIENT_ID}:${BW_ID_CLIENT_SECRET}`).toString('base64'), + }, + body: new URLSearchParams({ grant_type: 'client_credentials' }), + }); + const data = await response.json(); + return data.access_token; +} ``` -You also need a tunnel forwarding `https://` → `http://localhost:3000`, e.g.: - -```sh -cloudflared tunnel --url http://localhost:3000 -# or: ngrok http 3000 -``` - -Put the resulting URL into `CALLBACK_BASE_URL` and `REACT_APP_EVENT_CALLBACK_URL` (append `/callbacks/bandwidth` for the latter), and keep the Voice application's callback URL in sync via `band app update`. - -# Error Handling - -Errors, especially in networked operations, are inevitable. Ensure you catch, manage, and inform users about these, fostering a seamless experience. +That's it. Copy this pattern into your own app. diff --git a/package.json b/package.json index 9591a0c..89bad2f 100644 --- a/package.json +++ b/package.json @@ -27,21 +27,15 @@ "@mui/icons-material": "^5.14.0", "@mui/material": "^5.14.0", "@mui/styles": "^5.14.0", - "@okta/okta-auth-js": "^7.4.0", - "@okta/okta-react": "^6.7.0", "@testing-library/jest-dom": "^5.14.1", "@testing-library/react": "^13.0.0", "@testing-library/user-event": "^13.2.1", - "axios": "^1.5.0", - "bandwidth-sdk": "^7.4.2", "cors": "^2.8.6", "dotenv": "^16.6.1", "express": "^4.22.1", "firebase": "^10.12.3", - "http-proxy-middleware": "^3.0.5", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-query": "^3.39.2", "react-router-dom": "^6.15.0", "react-scripts": "^5.0.1", "react-timer-hook": "^3.0.0" diff --git a/server/index.ts b/server/index.ts index e11f579..d6c3da5 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,576 +1,60 @@ import 'dotenv/config'; import express, { Request, Response } from 'express'; import cors from 'cors'; -import { createProxyMiddleware } from 'http-proxy-middleware'; -import { CallsApi, Configuration } from 'bandwidth-sdk'; const app = express(); app.use(cors()); - -// Log ALL incoming requests BEFORE body parsing -app.use((req, res, next) => { - console.log(`\n>>> ${req.method} ${req.path} [${req.get('content-type') || 'no content-type'}]`); - next(); -}); - -// Same-origin relay to the Bandwidth REST API. Mounted BEFORE body parsers so -// the raw request body is streamed through untouched. A simple fetch-based -// relay avoids http-proxy-middleware version quirks (works with v2 and v3). -const BW_API_HOST = (() => { - const raw = process.env.HTTP_BASE_URL; - if (!raw) return 'https://api.bandwidth.com'; - try { - const u = new URL(raw); - return `${u.protocol}//${u.host}`; - } catch { - return 'https://api.bandwidth.com'; - } -})(); -app.use('/bwapi', async (req: Request, res: Response) => { - console.log(`[bwapi relay] ${req.method} ${req.url} -> ${BW_API_HOST}/v2${req.url}`); - const readBody = (): Promise => - new Promise((resolve, reject) => { - const chunks: Buffer[] = []; - req.on('data', (c: Buffer) => chunks.push(c)); - req.on('end', () => resolve(chunks.length ? Buffer.concat(chunks) : Buffer.alloc(0))); - req.on('error', reject); - }); - try { - const body = await readBody(); - console.log(`[bwapi relay] body length=${body.length}`); - const headers: Record = {}; - for (const [k, v] of Object.entries(req.headers)) { - if (typeof v === 'string' && k !== 'host' && k !== 'content-length') { - headers[k] = v; - } - } - const upstreamRes = await fetch(`${BW_API_HOST}/v2${req.url}`, { - method: req.method, - headers, - body: body.length ? body : undefined, - }); - console.log(`[bwapi relay] upstream status=${upstreamRes.status}`); - res.status(upstreamRes.status); - upstreamRes.headers.forEach((value, key) => { - if (key.toLowerCase() !== 'transfer-encoding' && key.toLowerCase() !== 'content-encoding') { - res.setHeader(key, value); - } - }); - const buf = Buffer.from(await upstreamRes.arrayBuffer()); - res.send(buf); - } catch (err: any) { - console.error('bwapi relay error:', err.message); - res.status(502).json({ error: err.message }); - } -}); - app.use(express.json()); -app.use(express.urlencoded({ extended: true })); -app.use(express.text({ type: '*/xml' })); - -// Log parsed body -app.use((req, res, next) => { - if (req.body && typeof req.body === 'object' && Object.keys(req.body).length > 0) { - console.log(JSON.stringify(req.body, null, 2)); - } else if (req.body && typeof req.body === 'string') { - console.log(req.body); - } - next(); -}); -const PROD_VOICE_URL = 'https://voice.bandwidth.com/api/v2'; +// Required environment variables +const BW_ID_CLIENT_ID = process.env.BW_ID_CLIENT_ID; +const BW_ID_CLIENT_SECRET = process.env.BW_ID_CLIENT_SECRET; +const BW_ID_HOSTNAME = process.env.BW_ID_HOSTNAME || 'https://api.bandwidth.com'; +const PORT = parseInt(process.env.PORT || '3000', 10); -function getEnvVars() { - const env = process.env; - const hasUserPass = !!env.BW_USERNAME && !!env.BW_PASSWORD; - const hasClientCreds = !!env.BW_ID_CLIENT_ID && !!env.BW_ID_CLIENT_SECRET; - if (!hasUserPass && !hasClientCreds) { - throw new Error('You must set either BW_USERNAME/BW_PASSWORD or BW_ID_CLIENT_ID/BW_ID_CLIENT_SECRET in your environment.'); - } - const required = ['ACCOUNT_ID', 'APPLICATION_ID', 'FROM_NUMBER', 'CALLBACK_BASE_URL']; - for (const v of required) { - if (!env[v]) throw new Error(`Missing required environment variable: ${v}`); - } - return { - HTTP_BASE_URL: (env.HTTP_BASE_URL || 'https://api.bandwidth.com/v2') as string, - VOICE_URL: (env.VOICE_URL || PROD_VOICE_URL) as string, - CALLBACK_BASE_URL: env.CALLBACK_BASE_URL as string, - ACCOUNT_ID: env.ACCOUNT_ID as string, - APPLICATION_ID: env.APPLICATION_ID as string, - BW_USERNAME: env.BW_USERNAME as string | undefined, - BW_PASSWORD: env.BW_PASSWORD as string | undefined, - BW_ID_CLIENT_ID: env.BW_ID_CLIENT_ID as string | undefined, - BW_ID_CLIENT_SECRET: env.BW_ID_CLIENT_SECRET as string | undefined, - BW_ID_HOSTNAME: (env.BW_ID_HOSTNAME || 'https://api.bandwidth.com') as string, - FROM_NUMBER: env.FROM_NUMBER as string, - PORT: parseInt(env.PORT || '3000', 10), - }; +if (!BW_ID_CLIENT_ID || !BW_ID_CLIENT_SECRET) { + throw new Error('Missing required environment variables: BW_ID_CLIENT_ID, BW_ID_CLIENT_SECRET'); } -const { - HTTP_BASE_URL, - VOICE_URL, - CALLBACK_BASE_URL, - ACCOUNT_ID, - APPLICATION_ID, - BW_USERNAME, - BW_PASSWORD, - BW_ID_CLIENT_ID, - BW_ID_CLIENT_SECRET, - BW_ID_HOSTNAME, - FROM_NUMBER, - PORT, -} = getEnvVars(); - -// Endpoint ID -> Available Status -let endpointAvailableMap = new Map(); -// Call ID -> Endpoint ID -let endpointCallIdMap = new Map(); -// Endpoint ID -> Call Status (tracks PSTN leg status for app polling) -let endpointCallStatusMap = new Map(); - -// --- OAuth Token Management --- - -let idToken: string = ''; -let idTokenExpiration: number = 0; +// Token cache +let cachedToken: string = ''; +let tokenExpiresAt: number = 0; async function getAuthToken(): Promise { - if (!idToken || Date.now() >= idTokenExpiration) { - const username = BW_ID_CLIENT_ID || BW_USERNAME!; - const password = BW_ID_CLIENT_SECRET || BW_PASSWORD!; - console.log('Fetching new OAuth access token'); - const response = await fetch(`${BW_ID_HOSTNAME}/api/v1/oauth2/token`, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Authorization: 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64'), - }, - body: new URLSearchParams({ grant_type: 'client_credentials' }), - }); - if (!response.ok) { - throw new Error(`Failed to fetch auth token: ${response.status} ${await response.text()}`); - } - const authData = await response.json(); - idToken = authData.access_token; - idTokenExpiration = Date.now() + (authData.expires_in - 10) * 1000; - console.log(`OAuth token obtained (expires in ${authData.expires_in}s)`); - } - return idToken; -} - -// --- Endpoint / Call Helpers --- - -async function placeCall(endpointId: string, toNumber: string, fromNumber: string): Promise { - // Dialpad-compat path: endpoint may have been created by the SDK (not this server's /token), - // so it won't be in the pool. Auto-register so status/hangup routes still work. - if (!endpointAvailableMap.has(endpointId)) { - console.log(`Auto-registering externally created endpoint ${endpointId}`); - endpointAvailableMap.set(endpointId, false); - } - - const token = await getAuthToken(); - const configuration = new Configuration({ accessToken: token }); - - if (VOICE_URL !== PROD_VOICE_URL) { - console.log(`Using custom voice URL: ${VOICE_URL}`); - configuration.basePath = VOICE_URL; - } - - const callsApi = new CallsApi(configuration); - const body = { - applicationId: APPLICATION_ID, - to: toNumber, - from: fromNumber, - answerUrl: `${CALLBACK_BASE_URL}/calls/answer`, - }; - - const response = await callsApi.createCall(ACCOUNT_ID, body); - const callId = response.data.callId; - console.log(`Placed outbound call ${callId} from endpoint ${endpointId} to ${toNumber}`); - endpointCallIdMap.set(callId, endpointId); - endpointCallStatusMap.set(endpointId, { callId, status: 'ringing' }); - return callId; -} - -function claimFirstAvailableEndpoint(): string { - for (const [endpointId, available] of endpointAvailableMap.entries()) { - if (available) { - claimEndpoint(endpointId); - return endpointId; - } - } - return ''; -} - -function claimEndpoint(endpointId: string) { - if (endpointAvailableMap.has(endpointId)) { - console.log(`Claiming endpoint from the pool ${endpointId}`); - endpointAvailableMap.set(endpointId, false); - } -} - -function releaseEndpoint(endpointId: string) { - if (endpointAvailableMap.has(endpointId)) { - console.log(`Releasing endpoint to the pool ${endpointId}`); - endpointAvailableMap.set(endpointId, true); + if (cachedToken && Date.now() < tokenExpiresAt) { + return cachedToken; } -} -function handleCallDisconnect(callId: string, cause?: string) { - const endpointId = endpointCallIdMap.get(callId); - if (endpointId) { - endpointCallStatusMap.set(endpointId, { callId, status: 'disconnected', cause }); - releaseEndpoint(endpointId); - } - endpointCallIdMap.delete(callId); -} + const response = await fetch(`${BW_ID_HOSTNAME}/api/v1/oauth2/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: 'Basic ' + Buffer.from(`${BW_ID_CLIENT_ID}:${BW_ID_CLIENT_SECRET}`).toString('base64'), + }, + body: new URLSearchParams({ grant_type: 'client_credentials' }), + }); -function processInboundCall(callId: string): string { - const requestingEndpointId = endpointCallIdMap.get(callId); - if (!requestingEndpointId) { - const endpointId = claimFirstAvailableEndpoint(); - if (endpointId === '') { - return ` - - You are on-hold. Please wait for an available endpoint to connect to this call. - -`; - } - return ` - - Connecting - - ${endpointId} - -`; + if (!response.ok) { + throw new Error(`OAuth token request failed: ${response.status}`); } - return ` - - Connecting - - ${requestingEndpointId} - -`; -} -async function deleteEndpoints(): Promise { - const authToken = await getAuthToken(); - const listResponse = await fetch(`${HTTP_BASE_URL}/accounts/${ACCOUNT_ID}/endpoints`, { - headers: { Authorization: 'Bearer ' + authToken }, - }); - if (!listResponse.ok) { - throw new Error(`Failed to list endpoints: ${listResponse.status} ${await listResponse.text()}`); - } - const listData = await listResponse.json(); - const endpoints: { endpointId: string }[] = listData.data ?? []; - console.log(`Deleting ${endpoints.length} endpoint(s)`); - await Promise.all( - endpoints.map(async ({ endpointId }) => { - const delResponse = await fetch(`${HTTP_BASE_URL}/accounts/${ACCOUNT_ID}/endpoints/${endpointId}`, { - method: 'DELETE', - headers: { Authorization: 'Bearer ' + authToken }, - }); - if (!delResponse.ok) { - console.error(`Failed to delete endpoint ${endpointId}: ${delResponse.status}`); - } else { - console.log(`Deleted endpoint ${endpointId}`); - endpointAvailableMap.delete(endpointId); - endpointCallIdMap.forEach((value, key) => { - if (value === endpointId) endpointCallIdMap.delete(key); - }); - } - }) - ); + const data = await response.json(); + cachedToken = data.access_token; + tokenExpiresAt = Date.now() + (data.expires_in - 10) * 1000; + return cachedToken; } -// --- Routes --- - -// GET /access-token - Mint a short-lived OAuth access token for the browser. -// The dialpad calls this on mount and hands the result to BandwidthUA.setOAuthToken. -// BW_CLIENT_ID / BW_CLIENT_SECRET never leave the server. +// GET /access-token - Return a cached OAuth token (minted server-side). +// BW_ID_CLIENT_SECRET never leaves the server. app.get('/access-token', async (_req: Request, res: Response) => { - try { - const accessToken = await getAuthToken(); - res.json({ access_token: accessToken }); - } catch (error: any) { - console.error('Error fetching access token:', error.message); - res.status(500).json({ error: error.message }); - } -}); - -// GET /token - Create a BRTC endpoint and return the JWT token -app.get('/token', async (req: Request, res: Response) => { - try { - const authToken = await getAuthToken(); - - const endpointUrl = `${HTTP_BASE_URL}/accounts/${ACCOUNT_ID}/endpoints`; - console.log(`Creating endpoint: POST ${endpointUrl}`); - - const endpointResponse = await fetch(endpointUrl, { - method: 'POST', - headers: { - Authorization: 'Bearer ' + authToken, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - type: 'WEBRTC', - direction: 'BIDIRECTIONAL', - eventCallbackUrl: `${CALLBACK_BASE_URL}/callbacks/bandwidth`, - eventFallbackUrl: `${CALLBACK_BASE_URL}/callbacks/bandwidth`, - tag: JSON.stringify({ source: 'javascript-sample-app' }), - }), - }); - - if (!endpointResponse.ok) { - const errorText = await endpointResponse.text(); - console.error(`Endpoint creation failed (${endpointResponse.status}): ${errorText}`); - return res.status(endpointResponse.status).json({ error: 'Failed to create endpoint', details: errorText }); - } - - const endpointData = await endpointResponse.json(); - const endpointId: string = endpointData.data.endpointId; - const token: string = endpointData.data.token; - - console.log(`Endpoint created: ${endpointId}`); - endpointAvailableMap.set(endpointId, false); - - res.json({ token, endpointId }); - } catch (error: any) { - console.error('Error creating endpoint:', error.message); - res.status(500).json({ error: error.message }); - } -}); - -// DELETE /api/endpoint/:endpointId - Delete a BRTC endpoint -app.delete('/api/endpoint/:endpointId', async (req: Request, res: Response) => { - const endpointId = req.params.endpointId; - if (!endpointId) { - res.status(400).send('Missing endpointId'); - return; - } - - endpointAvailableMap.delete(endpointId); - endpointCallIdMap.forEach((value, key) => { - if (value === endpointId) endpointCallIdMap.delete(key); - }); - - try { - const authToken = await getAuthToken(); - const endpointResponse = await fetch(`${HTTP_BASE_URL}/accounts/${ACCOUNT_ID}/endpoints/${endpointId}`, { - method: 'DELETE', - headers: { - Authorization: 'Bearer ' + authToken, - 'Content-Type': 'application/json', - }, - }); - if (!endpointResponse.ok) { - const errorText = await endpointResponse.text(); - throw new Error(`Endpoint deletion failed: ${endpointResponse.status} ${errorText}`); - } - res.sendStatus(200); - } catch (error: any) { - console.error('Error deleting endpoint:', error.message); - res.status(500).json({ error: error.message }); - } -}); - -// POST /callbacks/bandwidth - BRTC endpoint events AND incoming PSTN calls -app.post('/callbacks/bandwidth', async (req: Request, res: Response) => { - const event = req.body; - console.log('Callback event:', JSON.stringify(event, null, 2)); - - const endpointId: string = event.endpointId; - const eventType: string = event.event; - const toType: string = event.toType; - let to: string = event.to; - - // --- BRTC endpoint events --- - switch (eventType) { - case 'endpointIneligible': - claimEndpoint(endpointId); - return res.type('application/xml').send(``); - - case 'endpointEligible': - releaseEndpoint(endpointId); - return res.type('application/xml').send(``); - - case 'outboundConnectionRequest': - console.log(`Outbound call request for endpoint ${endpointId} to ${to} (${toType})`); - if (toType === 'PHONE_NUMBER') { - if (!to.startsWith('+')) to = `+${to}`; - try { - await placeCall(endpointId, to, FROM_NUMBER); - } catch (error: any) { - console.error('Error placing outbound call:', error.message); - } - } - return res.type('application/xml').send(``); - } - - // --- Incoming PSTN call (Voice API eventType: "initiate") --- - if (event.eventType === 'initiate' && event.direction === 'inbound') { - console.log(`Incoming PSTN call: ${event.from} -> ${event.to}, callId=${event.callId}`); - const xmlResponse = processInboundCall(event.callId); - if (event.callId) { - const claimedEndpointId = endpointCallIdMap.get(event.callId); - if (claimedEndpointId) { - endpointCallIdMap.set(event.callId, claimedEndpointId); - } - } - return res.type('application/xml').send(xmlResponse); - } - - res.type('application/xml').send(``); -}); - -// POST /callbacks/bandwidth/status - Voice API status events (disconnect, etc.) -app.post('/callbacks/bandwidth/status', (req: Request, res: Response) => { - const event = req.body; - console.log('Voice status event:', JSON.stringify(event, null, 2)); - - if (event.eventType === 'disconnect') { - console.log(`Call disconnected: ${event.callId}, cause: ${event.cause}`); - handleCallDisconnect(event.callId, event.cause); - } - - res.sendStatus(200); -}); - -// POST /calls/answer - BXML callback when an outbound call is answered -app.post('/calls/answer', (req: Request, res: Response) => { - const callId: string = req.body.callId; - const endpointId = endpointCallIdMap.get(callId); - if (endpointId) { - endpointCallStatusMap.set(endpointId, { callId, status: 'answered' }); - } - const xmlResponse = processInboundCall(callId); - console.log(`Call answer callback for callId: ${callId}`); - res.type('application/xml').send(xmlResponse); -}); - -// POST /calls/status - Call status updates (disconnect, redirect) -app.post('/calls/status', async (req: Request, res: Response) => { - const callId: string = req.body.callId; - const eventType: string = req.body.eventType; - - switch (eventType) { - case 'disconnect': - console.log(`Call disconnected with ID: ${callId}`); - handleCallDisconnect(callId, req.body.cause); - res.sendStatus(200); - break; - case 'redirect': - default: - console.log(`Call status update for callId: ${callId} (${eventType})`); - const xmlResponse = processInboundCall(callId); - res.type('application/xml').send(xmlResponse); - break; - } -}); - -// GET /api/endpoint/:endpointId/call-status - Get current PSTN call status for an endpoint -app.get('/api/endpoint/:endpointId/call-status', (req: Request, res: Response) => { - const endpointId = req.params.endpointId; - const status = endpointCallStatusMap.get(endpointId); - if (!status) { - return res.json({ status: 'idle' }); - } - res.json(status); -}); - -// POST /api/endpoint/:endpointId/hangup - Hang up the PSTN leg for an endpoint -app.post('/api/endpoint/:endpointId/hangup', async (req: Request, res: Response) => { - const endpointId = req.params.endpointId; - const callStatus = endpointCallStatusMap.get(endpointId); - if (!callStatus) { - return res.status(404).json({ error: 'No active call for this endpoint' }); - } - try { const token = await getAuthToken(); - const configuration = new Configuration({ accessToken: token }); - if (VOICE_URL !== PROD_VOICE_URL) { - configuration.basePath = VOICE_URL; - } - - const callsApi = new CallsApi(configuration); - await callsApi.updateCall(ACCOUNT_ID, callStatus.callId, { - state: 'completed', - }); - console.log(`Hung up PSTN leg ${callStatus.callId} for endpoint ${endpointId}`); - handleCallDisconnect(callStatus.callId, 'app-hangup'); - res.sendStatus(200); - } catch (error: any) { - console.error(`Error hanging up call for endpoint ${endpointId}:`, error.message); - res.status(500).json({ error: error.message }); - } -}); - -// DELETE /api/endpoints - Delete all endpoints on the account -app.delete('/api/endpoints', async (req: Request, res: Response) => { - try { - await deleteEndpoints(); - res.sendStatus(200); + res.json({ access_token: token }); } catch (error: any) { - console.error('Error deleting all endpoints:', error.message); res.status(500).json({ error: error.message }); } }); -// POST /simulate-incoming-call - Place a test call to a specific endpoint -app.post('/simulate-incoming-call', async (req: Request, res: Response) => { - const { endpointId, toNumber, fromNumber } = req.body; - try { - await placeCall(endpointId, toNumber || FROM_NUMBER, fromNumber || FROM_NUMBER); - res.sendStatus(200); - } catch (error: any) { - console.error('Error placing test call:', error.message); - res.status(500).json({ error: error.message }); - } -}); - -// GET /health -app.get('/health', (req: Request, res: Response) => { - res.json({ status: 'ok' }); -}); - -// GET /debug/endpoints -app.get('/debug/endpoints', (req: Request, res: Response) => { - const available: string[] = []; - const unavailable: string[] = []; - for (const [id, isAvailable] of endpointAvailableMap.entries()) { - (isAvailable ? available : unavailable).push(id); - } - res.json({ - total: endpointAvailableMap.size, - available, - unavailable, - callMappings: Object.fromEntries(endpointCallIdMap), - }); -}); - -// Catch-all -app.all('/{*path}', (req: Request, res: Response) => { - console.log(`\n!!! UNMATCHED ROUTE: ${req.method} ${req.path}`); - res.sendStatus(404); -}); - app.listen(PORT, '0.0.0.0', () => { - console.log(`BRTC token server running on http://localhost:${PORT}`); - console.log(` Account: ${ACCOUNT_ID}`); - console.log(` App ID: ${APPLICATION_ID}`); - console.log(` From: ${FROM_NUMBER}`); - console.log(` Callback: ${CALLBACK_BASE_URL}`); - console.log(); - console.log(` GET /token - Create endpoint and get JWT`); - console.log(` DELETE /api/endpoint/:endpointId - Delete endpoint`); - console.log(` GET /api/endpoint/:endpointId/call-status - Get PSTN call status`); - console.log(` POST /api/endpoint/:endpointId/hangup - Hang up PSTN leg`); - console.log(` POST /callbacks/bandwidth - BRTC events + incoming PSTN calls`); - console.log(` POST /callbacks/bandwidth/status - Voice API status (disconnect)`); - console.log(` POST /calls/answer - Outbound call answer BXML callback`); - console.log(` POST /calls/status - Call status updates`); - console.log(` POST /simulate-incoming-call - Place a test call`); - console.log(` GET /health - Health check`); - console.log(` GET /debug/endpoints - Inspect endpoint pool`); + console.log(`OAuth token server listening on http://localhost:${PORT}`); }); diff --git a/server/types.ts b/server/types.ts deleted file mode 100644 index df94d9a..0000000 --- a/server/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -class Endpoint { - token: string; - endpointId: string; -} - -export {Endpoint} From 76cbba9a3cc2c7d9f8d8bf8220cb51e510043641 Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Wed, 22 Apr 2026 15:48:20 -0400 Subject: [PATCH 05/13] Add missing REACT_APP_*_URL keys to .env.example DialPad.js reads REACT_APP_GATEWAY_URL, REACT_APP_HTTP_BASE_URL, and REACT_APP_EVENT_CALLBACK_URL when constructing BandwidthUA; .env.example needs to list them so customers know to set them. Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.env.example b/.env.example index 05f028d..e07a8c9 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,9 @@ # Client-visible (React) — baked into bundle REACT_APP_ACCOUNT_ID= REACT_APP_ACCOUNT_USERNAME= +REACT_APP_GATEWAY_URL= +REACT_APP_HTTP_BASE_URL= +REACT_APP_EVENT_CALLBACK_URL= # Server-side only (never prefixed with REACT_APP_) # OAuth2 client credentials for the backend's GET /access-token endpoint From 12f83c4df1b44c68890600173d5054cf440c738d Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Wed, 22 Apr 2026 15:50:18 -0400 Subject: [PATCH 06/13] Populate .env.example with real default URLs REACT_APP_GATEWAY_URL and REACT_APP_HTTP_BASE_URL match the production defaults from the brtc SDK. REACT_APP_EVENT_CALLBACK_URL stays blank with a comment because it's customer-specific. Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index e07a8c9..ab483b3 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,9 @@ # Client-visible (React) — baked into bundle REACT_APP_ACCOUNT_ID= REACT_APP_ACCOUNT_USERNAME= -REACT_APP_GATEWAY_URL= -REACT_APP_HTTP_BASE_URL= +REACT_APP_GATEWAY_URL=wss://gateway.pv.prod.global.aws.bandwidth.com/prod/gateway-service/api/v1/endpoints +REACT_APP_HTTP_BASE_URL=https://api.bandwidth.com/v2 +# Your webhook base URL — the SDK will POST call/endpoint events here (e.g. https://your-callback-url.example.com/callbacks/bandwidth) REACT_APP_EVENT_CALLBACK_URL= # Server-side only (never prefixed with REACT_APP_) From bcf7275b4db69a49301e6a661f138c891a6c9647 Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Wed, 22 Apr 2026 16:49:55 -0400 Subject: [PATCH 07/13] Revert to simple JWT token pattern, remove backend OAuth Drop the Express backend OAuth client-credentials flow. Customers now pass their Signum JWT directly via REACT_APP_AUTH_TOKEN env var, matching the original v1 sample pattern. The SDK v2 extracts accountId from JWT claims as a fallback. This simplifies the sample with ~zero diff from master. Removed: - server/ directory (Express backend) - Backend-only deps: express, axios, dotenv, cors, ts-node, typescript, @types/* - Proxy field from package.json - concurrently script and server startup logic Changed: - DialPad.js: synchronous initialization with env var token (no fetchAuthToken) - .env.example: dropped BW_ID_* keys, kept only REACT_APP_* vars - README.md: simplified to reflect env var approach, removed OAuth narrative Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 25 ++++++----- README.md | 92 +++++++++++---------------------------- package.json | 14 +----- server/index.ts | 60 ------------------------- src/components/DialPad.js | 60 +++++++++---------------- 5 files changed, 62 insertions(+), 189 deletions(-) delete mode 100644 server/index.ts diff --git a/.env.example b/.env.example index ab483b3..3f2f0da 100644 --- a/.env.example +++ b/.env.example @@ -1,16 +1,17 @@ -# Client-visible (React) — baked into bundle +# Required: Your Bandwidth account ID REACT_APP_ACCOUNT_ID= + +# Required: Your OAuth / Identity token +REACT_APP_AUTH_TOKEN= + +# Required: Source phone number (e.g. +15551234567) REACT_APP_ACCOUNT_USERNAME= -REACT_APP_GATEWAY_URL=wss://gateway.pv.prod.global.aws.bandwidth.com/prod/gateway-service/api/v1/endpoints -REACT_APP_HTTP_BASE_URL=https://api.bandwidth.com/v2 -# Your webhook base URL — the SDK will POST call/endpoint events here (e.g. https://your-callback-url.example.com/callbacks/bandwidth) -REACT_APP_EVENT_CALLBACK_URL= -# Server-side only (never prefixed with REACT_APP_) -# OAuth2 client credentials for the backend's GET /access-token endpoint -BW_ID_CLIENT_ID= -BW_ID_CLIENT_SECRET= +# Optional: Override the WebRTC gateway WebSocket URL +# REACT_APP_GATEWAY_URL=wss://gateway.pv.prod.global.aws.bandwidth.com/prod/gateway-service/api/v1/endpoints + +# Optional: Override the Bandwidth REST API base URL +# REACT_APP_HTTP_BASE_URL=https://api.bandwidth.com/v2 -# Optional -# BW_ID_HOSTNAME=https://api.bandwidth.com -# PORT=3000 +# Optional: Event callback URL for inbound call notifications +# REACT_APP_EVENT_CALLBACK_URL= diff --git a/README.md b/README.md index 0f0d8d8..fa832e3 100644 --- a/README.md +++ b/README.md @@ -1,92 +1,52 @@ # In-App Calling Dialpad -A minimal sample showing how to migrate from the v1 WebRTC SDK to v2. The key change: instead of baking an auth token into your app, fetch it server-side via OAuth client credentials. +A simple dial pad application used to create calls using the Bandwidth WebRTC SDK. -# Prerequisites +## Table of Contents -Your account must have In-App Calling enabled. See [Account Credentials](https://dev.bandwidth.com/docs/account/credentials) for API setup. +* [Description](#description) +* [Pre-Requisites](#pre-requisites) +* [Setup](#setup) +* [Running the Application](#running-the-application) +* [SDK Documentation](#sdk-documentation) -# Setup +## Description + +This sample app demonstrates how to build a simple dialing interface with the Bandwidth WebRTC SDK. It includes call controls for mute, hold, and hangup functionality. + +## Pre-Requisites + +Your account must have In-App Calling enabled. For more information about API credentials, see the [Account Credentials](https://dev.bandwidth.com/docs/account/credentials) page. + +You'll need a Signum JWT token (OAuth token) to authenticate with the SDK. The SDK now extracts your `accountId` from the JWT claims automatically. + +## Setup ```sh cp .env.example .env ``` -Edit `.env`: +Edit `.env` and populate: ```sh -# React (baked into bundle) REACT_APP_ACCOUNT_ID= REACT_APP_ACCOUNT_USERNAME= - -# Express backend -BW_ID_CLIENT_ID= -BW_ID_CLIENT_SECRET= +REACT_APP_AUTH_TOKEN= ``` -# Running +## Running the Application ```sh npm install npm start -# → Backend on http://localhost:3000 -# → React on http://localhost:3001 ``` -# What Changed - -**v1 (old):** Auth token passed via environment variable. +The app will open at `http://localhost:3000`. -**v2 (new):** Backend's `GET /access-token` endpoint mints a short-lived token using client credentials. DialPad fetches it on mount and hands it to `setOAuthToken()`. Client secret never leaves the server. +## SDK Documentation -# Minimal Migration Example +For detailed SDK usage and API documentation, refer to the [Bandwidth WebRTC SDK documentation](https://dev.bandwidth.com/sdks/webrtc/). -See `src/components/DialPad.js` for the key pattern: - -```js -// Fetch OAuth token from backend on mount -const fetchAuthToken = async () => { - const res = await fetch('/access-token'); - const { access_token } = await res.json(); - return access_token; -}; - -// Initialize SDK (same v1 API, v2 SDK) -const phone = new BandwidthUA({ - accountId: accountId, - gatewayUrl: process.env.REACT_APP_GATEWAY_URL, - httpBaseUrl: process.env.REACT_APP_HTTP_BASE_URL, - eventCallbackUrl: process.env.REACT_APP_EVENT_CALLBACK_URL, -}); - -phone.setAccount(sourceNumber, 'In-App Calling Sample', ''); -const token = await fetchAuthToken(); -phone.setOAuthToken(token); -await phone.init(); -``` - -And in `server/index.ts`: - -```js -// Backend endpoint: mint OAuth token (client credentials → access_token) -app.get('/access-token', async (_req, res) => { - const token = await getAuthToken(); - res.json({ access_token: token }); -}); - -// getAuthToken() does the client-credentials exchange with Bandwidth's IDP -async function getAuthToken() { - const response = await fetch(`${BW_ID_HOSTNAME}/api/v1/oauth2/token`, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Authorization: 'Basic ' + Buffer.from(`${BW_ID_CLIENT_ID}:${BW_ID_CLIENT_SECRET}`).toString('base64'), - }, - body: new URLSearchParams({ grant_type: 'client_credentials' }), - }); - const data = await response.json(); - return data.access_token; -} -``` +## Error Handling -That's it. Copy this pattern into your own app. +Errors are logged to the console. Ensure that your environment variables are correctly set and that your account has In-App Calling enabled. diff --git a/package.json b/package.json index 89bad2f..2b123db 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,6 @@ "version": "1.0.0", "description": "In-App Calling Dialpad Sample App", "private": true, - "proxy": "http://localhost:3000", "repository": { "type": "git", "url": "git+https://github.com/Bandwidth-Samples/in-app-calling-dialpad-node-react.git" @@ -14,11 +13,7 @@ "url": "https://github.com/Bandwidth-Samples/in-app-calling-dialpad-node-react/issues" }, "devDependencies": { - "@babel/plugin-proposal-private-property-in-object": "^7.16.7", - "@types/cors": "^2.8.17", - "@types/express": "^4.17.23", - "concurrently": "^9.0.0", - "tsx": "^4.20.6" + "@babel/plugin-proposal-private-property-in-object": "^7.16.7" }, "dependencies": { "@bandwidth/bw-webrtc-sdk": "file:../javascript-webrtc-sdk", @@ -30,9 +25,6 @@ "@testing-library/jest-dom": "^5.14.1", "@testing-library/react": "^13.0.0", "@testing-library/user-event": "^13.2.1", - "cors": "^2.8.6", - "dotenv": "^16.6.1", - "express": "^4.22.1", "firebase": "^10.12.3", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -41,9 +33,7 @@ "react-timer-hook": "^3.0.0" }, "scripts": { - "start": "concurrently -n server,react -c cyan,magenta \"npm:server\" \"npm:react\"", - "server": "tsx server/index.ts", - "react": "PORT=3001 react-scripts start", + "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test" }, diff --git a/server/index.ts b/server/index.ts deleted file mode 100644 index d6c3da5..0000000 --- a/server/index.ts +++ /dev/null @@ -1,60 +0,0 @@ -import 'dotenv/config'; -import express, { Request, Response } from 'express'; -import cors from 'cors'; - -const app = express(); -app.use(cors()); -app.use(express.json()); - -// Required environment variables -const BW_ID_CLIENT_ID = process.env.BW_ID_CLIENT_ID; -const BW_ID_CLIENT_SECRET = process.env.BW_ID_CLIENT_SECRET; -const BW_ID_HOSTNAME = process.env.BW_ID_HOSTNAME || 'https://api.bandwidth.com'; -const PORT = parseInt(process.env.PORT || '3000', 10); - -if (!BW_ID_CLIENT_ID || !BW_ID_CLIENT_SECRET) { - throw new Error('Missing required environment variables: BW_ID_CLIENT_ID, BW_ID_CLIENT_SECRET'); -} - -// Token cache -let cachedToken: string = ''; -let tokenExpiresAt: number = 0; - -async function getAuthToken(): Promise { - if (cachedToken && Date.now() < tokenExpiresAt) { - return cachedToken; - } - - const response = await fetch(`${BW_ID_HOSTNAME}/api/v1/oauth2/token`, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Authorization: 'Basic ' + Buffer.from(`${BW_ID_CLIENT_ID}:${BW_ID_CLIENT_SECRET}`).toString('base64'), - }, - body: new URLSearchParams({ grant_type: 'client_credentials' }), - }); - - if (!response.ok) { - throw new Error(`OAuth token request failed: ${response.status}`); - } - - const data = await response.json(); - cachedToken = data.access_token; - tokenExpiresAt = Date.now() + (data.expires_in - 10) * 1000; - return cachedToken; -} - -// GET /access-token - Return a cached OAuth token (minted server-side). -// BW_ID_CLIENT_SECRET never leaves the server. -app.get('/access-token', async (_req: Request, res: Response) => { - try { - const token = await getAuthToken(); - res.json({ access_token: token }); - } catch (error: any) { - res.status(500).json({ error: error.message }); - } -}); - -app.listen(PORT, '0.0.0.0', () => { - console.log(`OAuth token server listening on http://localhost:${PORT}`); -}); diff --git a/src/components/DialPad.js b/src/components/DialPad.js index 539f2cd..ad073de 100644 --- a/src/components/DialPad.js +++ b/src/components/DialPad.js @@ -15,21 +15,10 @@ import { Button } from '@mui/material'; export default function DialPad() { const userId = process.env.REACT_APP_ACCOUNT_USERNAME; + const authToken = process.env.REACT_APP_AUTH_TOKEN; const accountId = process.env.REACT_APP_ACCOUNT_ID; const sourceNumber = userId; - // OAuth access tokens come from the Express backend's /access-token route - // (see server/index.ts), which does the client-credentials exchange server-side. - // The `proxy` field in package.json forwards this to http://localhost:3000. - const fetchAuthToken = async () => { - const res = await fetch('/access-token'); - if (!res.ok) { - throw new Error(`Failed to fetch auth token: ${res.status}`); - } - const { access_token } = await res.json(); - return access_token; - }; - const { totalSeconds, seconds, minutes, hours, start, pause, reset } = useStopwatch({ autoStart: false }); const [destNumber, setDestNumber] = useState(''); @@ -83,36 +72,29 @@ export default function DialPad() { }, []) useEffect(() => { - async function initSdk() { - const newPhone = new BandwidthUA({ - accountId: accountId, - gatewayUrl: process.env.REACT_APP_GATEWAY_URL, - httpBaseUrl: process.env.REACT_APP_HTTP_BASE_URL, - eventCallbackUrl: process.env.REACT_APP_EVENT_CALLBACK_URL, - }); - console.log(`version: `, newPhone.version()); - - // These are still accepted for backwards compatibility but are no longer - // required — the new SDK connects directly to the WebRTC gateway. - newPhone.setWebSocketKeepAlive(5, false, false, 5, true); - - //overriding the SDK logs - newPhone.setBWLogger((...e) => { - console.log(...e); - }); + const newPhone = new BandwidthUA({ + accountId: accountId, + // Optional overrides: + // gatewayUrl: process.env.REACT_APP_GATEWAY_URL, + // httpBaseUrl: process.env.REACT_APP_HTTP_BASE_URL, + // eventCallbackUrl: process.env.REACT_APP_EVENT_CALLBACK_URL, + }); + console.log(`version: `, newPhone.version()); - newPhone.checkAvailableDevices(); - newPhone.setAccount(`${sourceNumber}`, 'In-App Calling Sample', ''); + // These are still accepted for backwards compatibility but are no longer + // required — the new SDK connects directly to the WebRTC gateway. + newPhone.setWebSocketKeepAlive(5, false, false, 5, true); - const token = await fetchAuthToken(); - newPhone.setOAuthToken(token); - await newPhone.init(); - setPhone(newPhone); - } - - initSdk().catch((err) => { - console.error('SDK init failed:', err); + //overriding the SDK logs + newPhone.setBWLogger((...e) => { + console.log(...e); }); + + newPhone.checkAvailableDevices(); + newPhone.setAccount(`${sourceNumber}`, 'In-App Calling Sample', ''); + newPhone.setOAuthToken(authToken); + newPhone.init(); + setPhone(newPhone); }, []); useEffect(() => { From b4565e5debddf8a5aa12fc4289a61b8069d52174 Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Wed, 22 Apr 2026 17:02:49 -0400 Subject: [PATCH 08/13] =?UTF-8?q?Drop=20optional=20override=20fields=20?= =?UTF-8?q?=E2=80=94=20always=20use=20SDK=20defaults?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sample now configures only accountId and the OAuth token; gateway, REST, and event-callback URLs come from the SDK's production defaults. README calls this out so customers do the same in their own apps. Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 9 --------- README.md | 6 +++++- src/components/DialPad.js | 6 ------ 3 files changed, 5 insertions(+), 16 deletions(-) diff --git a/.env.example b/.env.example index 3f2f0da..523a5b2 100644 --- a/.env.example +++ b/.env.example @@ -6,12 +6,3 @@ REACT_APP_AUTH_TOKEN= # Required: Source phone number (e.g. +15551234567) REACT_APP_ACCOUNT_USERNAME= - -# Optional: Override the WebRTC gateway WebSocket URL -# REACT_APP_GATEWAY_URL=wss://gateway.pv.prod.global.aws.bandwidth.com/prod/gateway-service/api/v1/endpoints - -# Optional: Override the Bandwidth REST API base URL -# REACT_APP_HTTP_BASE_URL=https://api.bandwidth.com/v2 - -# Optional: Event callback URL for inbound call notifications -# REACT_APP_EVENT_CALLBACK_URL= diff --git a/README.md b/README.md index fa832e3..4113de2 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,11 @@ This sample app demonstrates how to build a simple dialing interface with the Ba Your account must have In-App Calling enabled. For more information about API credentials, see the [Account Credentials](https://dev.bandwidth.com/docs/account/credentials) page. -You'll need a Signum JWT token (OAuth token) to authenticate with the SDK. The SDK now extracts your `accountId` from the JWT claims automatically. +You'll need a Signum JWT token (OAuth token) to authenticate with the SDK. + +## SDK Defaults + +This sample configures the SDK with only `accountId` and the OAuth token. The WebRTC gateway URL, Bandwidth REST base URL, and event callback URL all use the SDK's built-in production defaults — do not override them. Your own integration should do the same unless Bandwidth support has specifically directed you to a different endpoint. ## Setup diff --git a/src/components/DialPad.js b/src/components/DialPad.js index ad073de..22e244d 100644 --- a/src/components/DialPad.js +++ b/src/components/DialPad.js @@ -74,15 +74,9 @@ export default function DialPad() { useEffect(() => { const newPhone = new BandwidthUA({ accountId: accountId, - // Optional overrides: - // gatewayUrl: process.env.REACT_APP_GATEWAY_URL, - // httpBaseUrl: process.env.REACT_APP_HTTP_BASE_URL, - // eventCallbackUrl: process.env.REACT_APP_EVENT_CALLBACK_URL, }); console.log(`version: `, newPhone.version()); - // These are still accepted for backwards compatibility but are no longer - // required — the new SDK connects directly to the WebRTC gateway. newPhone.setWebSocketKeepAlive(5, false, false, 5, true); //overriding the SDK logs From b5475ca51e25e1c4e9af6272fb634e436ccd9543 Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Wed, 22 Apr 2026 17:23:47 -0400 Subject: [PATCH 09/13] Add /bwapi dev proxy to bypass CORS on api.bandwidth.com MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v2 SDK's endpoint-creation POST is browser-blocked by CORS because api.bandwidth.com does not send Access-Control-Allow-Origin. This is dev-only — production apps need a proper backend or will need to wait for the Bandwidth-hosted minting service (TODO'd in the SDK). DialPad.js now honors REACT_APP_HTTP_BASE_URL / GATEWAY_URL / EVENT_CALLBACK_URL when set in .env, falling back to SDK defaults when unset so customer migrations still require zero code changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 3 ++- src/components/DialPad.js | 6 ++++++ src/setupProxy.js | 17 +++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 src/setupProxy.js diff --git a/package.json b/package.json index 2b123db..e9ccce0 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "url": "https://github.com/Bandwidth-Samples/in-app-calling-dialpad-node-react/issues" }, "devDependencies": { - "@babel/plugin-proposal-private-property-in-object": "^7.16.7" + "@babel/plugin-proposal-private-property-in-object": "^7.16.7", + "http-proxy-middleware": "^3.0.5" }, "dependencies": { "@bandwidth/bw-webrtc-sdk": "file:../javascript-webrtc-sdk", diff --git a/src/components/DialPad.js b/src/components/DialPad.js index 22e244d..a91f717 100644 --- a/src/components/DialPad.js +++ b/src/components/DialPad.js @@ -74,6 +74,12 @@ export default function DialPad() { useEffect(() => { const newPhone = new BandwidthUA({ accountId: accountId, + // Overrides below are only for non-production testing. Leave these env + // vars empty (or unset) in real deployments so the SDK uses its + // Bandwidth production defaults. + ...(process.env.REACT_APP_HTTP_BASE_URL && { httpBaseUrl: process.env.REACT_APP_HTTP_BASE_URL }), + ...(process.env.REACT_APP_GATEWAY_URL && { gatewayUrl: process.env.REACT_APP_GATEWAY_URL }), + ...(process.env.REACT_APP_EVENT_CALLBACK_URL && { eventCallbackUrl: process.env.REACT_APP_EVENT_CALLBACK_URL }), }); console.log(`version: `, newPhone.version()); diff --git a/src/setupProxy.js b/src/setupProxy.js new file mode 100644 index 0000000..06c1f0e --- /dev/null +++ b/src/setupProxy.js @@ -0,0 +1,17 @@ +// Dev-only proxy: forwards /bwapi/* to api.bandwidth.com so the browser +// bypasses CORS when the SDK creates an endpoint during local testing. +// Set REACT_APP_HTTP_BASE_URL=/bwapi in .env to route SDK traffic here. +// Production apps should use a proper customer backend. + +const { createProxyMiddleware } = require('http-proxy-middleware'); + +module.exports = function (app) { + app.use( + '/bwapi', + createProxyMiddleware({ + target: 'https://api.bandwidth.com', + changeOrigin: true, + pathRewrite: { '^/bwapi': '/v2' }, + }) + ); +}; From 769a229ccfb7a5021b1bc9f74561f0415d342718 Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Wed, 22 Apr 2026 17:25:55 -0400 Subject: [PATCH 10/13] =?UTF-8?q?Fix=20/bwapi=20proxy=20path=20=E2=80=94?= =?UTF-8?q?=20put=20/v2=20in=20target,=20not=20in=20rewrite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit http-proxy-middleware v3 rewrote /bwapi → /v2/... with the old config but the target was receiving /accounts/... (no /v2 prefix), so api.bandwidth.com 404'd. Moving /v2 into the target URL. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/setupProxy.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/setupProxy.js b/src/setupProxy.js index 06c1f0e..7d3e7ce 100644 --- a/src/setupProxy.js +++ b/src/setupProxy.js @@ -9,9 +9,9 @@ module.exports = function (app) { app.use( '/bwapi', createProxyMiddleware({ - target: 'https://api.bandwidth.com', + target: 'https://api.bandwidth.com/v2', changeOrigin: true, - pathRewrite: { '^/bwapi': '/v2' }, + pathRewrite: { '^/bwapi': '' }, }) ); }; From 4f7a51bfba98b8b145ff78b0a5c8b1d53ae36aa5 Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Mon, 4 May 2026 14:30:19 -0400 Subject: [PATCH 11/13] refactor: remove accountId and proxy; add appId/fromNumber env vars --- .env.example | 12 +++++++++--- README.md | 5 ++--- package.json | 3 +-- src/components/DialPad.js | 11 ++++------- src/setupProxy.js | 17 ----------------- 5 files changed, 16 insertions(+), 32 deletions(-) delete mode 100644 src/setupProxy.js diff --git a/.env.example b/.env.example index 523a5b2..5c22af9 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,14 @@ -# Required: Your Bandwidth account ID -REACT_APP_ACCOUNT_ID= - # Required: Your OAuth / Identity token REACT_APP_AUTH_TOKEN= # Required: Source phone number (e.g. +15551234567) REACT_APP_ACCOUNT_USERNAME= + +# Optional: Voice application ID — skips server-side lookup when provided +# REACT_APP_APP_ID= + +# Optional: From number (E.164) — skips server-side lookup when provided +# REACT_APP_FROM_NUMBER= + +# Optional: Override WebRTC gateway URL (non-production testing only) +# REACT_APP_GATEWAY_URL= diff --git a/README.md b/README.md index 4113de2..57e10f0 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ You'll need a Signum JWT token (OAuth token) to authenticate with the SDK. ## SDK Defaults -This sample configures the SDK with only `accountId` and the OAuth token. The WebRTC gateway URL, Bandwidth REST base URL, and event callback URL all use the SDK's built-in production defaults — do not override them. Your own integration should do the same unless Bandwidth support has specifically directed you to a different endpoint. +This sample connects using your account ID and Signum OAuth token. The SDK authenticates directly with the Bandwidth WebRTC gateway — no REST endpoint creation call is made. The gateway URL uses the SDK's built-in production default; do not override it unless Bandwidth support has specifically directed you to a different endpoint. ## Setup @@ -33,9 +33,8 @@ cp .env.example .env Edit `.env` and populate: ```sh -REACT_APP_ACCOUNT_ID= -REACT_APP_ACCOUNT_USERNAME= REACT_APP_AUTH_TOKEN= +REACT_APP_ACCOUNT_USERNAME= ``` ## Running the Application diff --git a/package.json b/package.json index e9ccce0..2b123db 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,7 @@ "url": "https://github.com/Bandwidth-Samples/in-app-calling-dialpad-node-react/issues" }, "devDependencies": { - "@babel/plugin-proposal-private-property-in-object": "^7.16.7", - "http-proxy-middleware": "^3.0.5" + "@babel/plugin-proposal-private-property-in-object": "^7.16.7" }, "dependencies": { "@bandwidth/bw-webrtc-sdk": "file:../javascript-webrtc-sdk", diff --git a/src/components/DialPad.js b/src/components/DialPad.js index a91f717..1b1942a 100644 --- a/src/components/DialPad.js +++ b/src/components/DialPad.js @@ -16,7 +16,6 @@ import { Button } from '@mui/material'; export default function DialPad() { const userId = process.env.REACT_APP_ACCOUNT_USERNAME; const authToken = process.env.REACT_APP_AUTH_TOKEN; - const accountId = process.env.REACT_APP_ACCOUNT_ID; const sourceNumber = userId; const { totalSeconds, seconds, minutes, hours, start, pause, reset } = useStopwatch({ autoStart: false }); @@ -73,13 +72,11 @@ export default function DialPad() { useEffect(() => { const newPhone = new BandwidthUA({ - accountId: accountId, - // Overrides below are only for non-production testing. Leave these env - // vars empty (or unset) in real deployments so the SDK uses its - // Bandwidth production defaults. - ...(process.env.REACT_APP_HTTP_BASE_URL && { httpBaseUrl: process.env.REACT_APP_HTTP_BASE_URL }), + // Optional: skip server-side lookup when appId/fromNumber are already known. + // gatewayUrl is for non-production testing only — leave unset in real deployments. + ...(process.env.REACT_APP_APP_ID && { appId: process.env.REACT_APP_APP_ID }), + ...(process.env.REACT_APP_FROM_NUMBER && { fromNumber: process.env.REACT_APP_FROM_NUMBER }), ...(process.env.REACT_APP_GATEWAY_URL && { gatewayUrl: process.env.REACT_APP_GATEWAY_URL }), - ...(process.env.REACT_APP_EVENT_CALLBACK_URL && { eventCallbackUrl: process.env.REACT_APP_EVENT_CALLBACK_URL }), }); console.log(`version: `, newPhone.version()); diff --git a/src/setupProxy.js b/src/setupProxy.js deleted file mode 100644 index 7d3e7ce..0000000 --- a/src/setupProxy.js +++ /dev/null @@ -1,17 +0,0 @@ -// Dev-only proxy: forwards /bwapi/* to api.bandwidth.com so the browser -// bypasses CORS when the SDK creates an endpoint during local testing. -// Set REACT_APP_HTTP_BASE_URL=/bwapi in .env to route SDK traffic here. -// Production apps should use a proper customer backend. - -const { createProxyMiddleware } = require('http-proxy-middleware'); - -module.exports = function (app) { - app.use( - '/bwapi', - createProxyMiddleware({ - target: 'https://api.bandwidth.com/v2', - changeOrigin: true, - pathRewrite: { '^/bwapi': '' }, - }) - ); -}; From 8ad5724afca8e15a967b0ec05d4108d367f893c6 Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Mon, 4 May 2026 14:57:18 -0400 Subject: [PATCH 12/13] refactor: use ACCOUNT_USERNAME directly as fromNumber, drop FROM_NUMBER env --- .env.example | 5 +---- README.md | 2 +- src/components/DialPad.js | 7 +++---- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index 5c22af9..eb91fcd 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,11 @@ # Required: Your OAuth / Identity token REACT_APP_AUTH_TOKEN= -# Required: Source phone number (e.g. +15551234567) +# Required: Source phone number (E.164, e.g. +15551234567) — used as the from number for outbound calls REACT_APP_ACCOUNT_USERNAME= # Optional: Voice application ID — skips server-side lookup when provided # REACT_APP_APP_ID= -# Optional: From number (E.164) — skips server-side lookup when provided -# REACT_APP_FROM_NUMBER= - # Optional: Override WebRTC gateway URL (non-production testing only) # REACT_APP_GATEWAY_URL= diff --git a/README.md b/README.md index 57e10f0..6dc63aa 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Edit `.env` and populate: ```sh REACT_APP_AUTH_TOKEN= -REACT_APP_ACCOUNT_USERNAME= +REACT_APP_ACCOUNT_USERNAME= ``` ## Running the Application diff --git a/src/components/DialPad.js b/src/components/DialPad.js index 1b1942a..c630a29 100644 --- a/src/components/DialPad.js +++ b/src/components/DialPad.js @@ -14,9 +14,8 @@ import { useStopwatch } from 'react-timer-hook'; import { Button } from '@mui/material'; export default function DialPad() { - const userId = process.env.REACT_APP_ACCOUNT_USERNAME; const authToken = process.env.REACT_APP_AUTH_TOKEN; - const sourceNumber = userId; + const userId = process.env.REACT_APP_ACCOUNT_USERNAME; const { totalSeconds, seconds, minutes, hours, start, pause, reset } = useStopwatch({ autoStart: false }); @@ -72,10 +71,10 @@ export default function DialPad() { useEffect(() => { const newPhone = new BandwidthUA({ - // Optional: skip server-side lookup when appId/fromNumber are already known. + fromNumber: userId, + // Optional: skip server-side app lookup when appId is already known. // gatewayUrl is for non-production testing only — leave unset in real deployments. ...(process.env.REACT_APP_APP_ID && { appId: process.env.REACT_APP_APP_ID }), - ...(process.env.REACT_APP_FROM_NUMBER && { fromNumber: process.env.REACT_APP_FROM_NUMBER }), ...(process.env.REACT_APP_GATEWAY_URL && { gatewayUrl: process.env.REACT_APP_GATEWAY_URL }), }); console.log(`version: `, newPhone.version()); From a9f5a4b4fc8cb2ee6e0fb1995816fa981ec56744 Mon Sep 17 00:00:00 2001 From: smoghe-bw Date: Mon, 4 May 2026 20:07:12 -0400 Subject: [PATCH 13/13] refactor: clean up DialPad SDK init and call setup --- src/components/DialPad.js | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/components/DialPad.js b/src/components/DialPad.js index c630a29..e4bb6ff 100644 --- a/src/components/DialPad.js +++ b/src/components/DialPad.js @@ -74,20 +74,16 @@ export default function DialPad() { fromNumber: userId, // Optional: skip server-side app lookup when appId is already known. // gatewayUrl is for non-production testing only — leave unset in real deployments. - ...(process.env.REACT_APP_APP_ID && { appId: process.env.REACT_APP_APP_ID }), + ...(process.env.REACT_APP_APP_ID && { applicationId: process.env.REACT_APP_APP_ID }), ...(process.env.REACT_APP_GATEWAY_URL && { gatewayUrl: process.env.REACT_APP_GATEWAY_URL }), }); console.log(`version: `, newPhone.version()); - newPhone.setWebSocketKeepAlive(5, false, false, 5, true); - - //overriding the SDK logs newPhone.setBWLogger((...e) => { console.log(...e); }); newPhone.checkAvailableDevices(); - newPhone.setAccount(`${sourceNumber}`, 'In-App Calling Sample', ''); newPhone.setOAuthToken(authToken); newPhone.init(); setPhone(newPhone); @@ -259,9 +255,8 @@ export default function DialPad() { updateFBStatus("Calling"); setCallStatus('Calling'); setWebRtcStatus('Ringing'); - let extraHeaders = [`User-to-User:eyJhbGciOiJIUzI1NiJ9.WyJoaSJd.-znkjYyCkgz4djmHUPSXl9YrJ6Nix_XvmlwKGFh5ERM;encoding=jwt;aGVsbG8gd29ybGQ;encoding=base64`]; console.log("Dialed number: ", destNumber); - phone.makeCall(`${destNumber}`, extraHeaders).then((value) => { + phone.makeCall(`${destNumber}`).then((value) => { setActiveCall(value); }); setDialedNumber(`+${destNumber}`);