├── .editorconfig ├── .envrc ├── .eslintrc.json ├── .github ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── gh-pages.yml │ ├── nodejs.yml │ └── run-e2e-tests.yml ├── .gitignore ├── .gitlab-ci.yml ├── .npmrc ├── .prettierrc ├── Dockerfile ├── LICENSE ├── README.md ├── demo ├── components │ ├── c-audio-visualiser.mjs │ ├── c-computer-settings.mjs │ ├── c-console-message.mjs │ ├── c-contact.mjs │ ├── c-contacts.mjs │ ├── c-debug-console.mjs │ ├── c-devices-setting.mjs │ ├── c-dialer.mjs │ ├── c-keypad.mjs │ ├── c-mos-values.mjs │ ├── c-publisher.mjs │ ├── c-session.mjs │ ├── c-sessions.mjs │ ├── c-transfer.mjs │ ├── c-voip-account.mjs │ └── c-volume-setting.mjs ├── fonts │ ├── roboto-400.woff2 │ ├── roboto-700.woff2 │ └── roboto-900.woff2 ├── icons │ ├── caret-down.svg │ └── ova_designelement.png ├── index.html ├── index.mjs ├── lib │ ├── calling.mjs │ ├── dom.mjs │ ├── logging.mjs │ └── media.mjs ├── pages │ └── p-demo.mjs ├── sounds │ ├── dtmf-0.mp3 │ ├── dtmf-1.mp3 │ ├── dtmf-2.mp3 │ ├── dtmf-3.mp3 │ ├── dtmf-4.mp3 │ ├── dtmf-5.mp3 │ ├── dtmf-6.mp3 │ ├── dtmf-7.mp3 │ ├── dtmf-8.mp3 │ ├── dtmf-9.mp3 │ ├── dtmf-hash.mp3 │ ├── dtmf-star.mp3 │ └── random.mp3 ├── styles │ └── main.css ├── time.js └── utils │ ├── elementProxies.mjs │ ├── eventTarget.mjs │ └── styleConsoleMessage.mjs ├── docker-compose.override.yml ├── docker-compose.yml ├── package-lock.json ├── package.json ├── puppeteer ├── .dockerignore ├── .eslintrc.json ├── Dockerfile ├── config.js ├── helpers │ ├── constants.js │ └── utils.js ├── package-lock.json ├── package.json └── test │ ├── audioSwitching-e2e.js │ ├── callingOut-e2e.js │ ├── coldTransfer-e2e.js │ ├── connectivity-e2e.js │ ├── example-e2e.js │ ├── hold-e2e.js │ ├── receive-e2e.js │ ├── subscription-e2e.js │ └── warmTransfer-e2e.js ├── rollup.config.js ├── serve.json ├── src ├── audio-context.ts ├── autoplay.ts ├── client.ts ├── enums.ts ├── features.ts ├── health-checker.ts ├── index.ts ├── invitation.ts ├── inviter.ts ├── lib │ ├── freeze.ts │ └── utils.ts ├── logger.ts ├── media.ts ├── session-description-handler.ts ├── session-health.ts ├── session-media.ts ├── session-stats.ts ├── session.ts ├── sound.ts ├── subscription.ts ├── time.ts ├── transport.ts └── types.ts ├── test ├── _helpers.ts ├── _setup-browser-env.js ├── client-connect.ts ├── client-disconnect.ts ├── client.ts └── utils.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | insert_final_newline = false 7 | trim_trailing_whitespace = true 8 | end_of_line = lf 9 | charset = utf-8 10 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | layout node 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:prettier/recommended", 9 | "plugin:@typescript-eslint/eslint-recommended", 10 | "plugin:@typescript-eslint/recommended" 11 | ], 12 | "plugins": ["prettier", "@typescript-eslint"], 13 | "globals": { 14 | "Atomics": "readonly", 15 | "SharedArrayBuffer": "readonly" 16 | }, 17 | "parser": "@typescript-eslint/parser", 18 | "parserOptions": { 19 | "ecmaVersion": 9, 20 | "sourceType": "module" 21 | }, 22 | "rules": { 23 | "curly": ["error", "all"], 24 | "brace-style": ["error", "1tbs"], 25 | "no-console": "off", 26 | "prettier/prettier": "error" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Version 2 | 3 | {version or tag here} 4 | 5 | ### File / Feature 6 | 7 | {file or feature containing the issue} 8 | 9 | ### Expected behaviour 10 | 11 | {what should happen} 12 | 13 | ### Actual behaviour 14 | 15 | {what happens} 16 | 17 | ### Stacktrace / Error message 18 | 19 | {paste here} 20 | 21 | ### Other info 22 | 23 | {anything else that might be related/useful} 24 | 25 | 26 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Issue number 2 | 3 | {if exists provide related issue} 4 | 5 | ### Expected behaviour 6 | 7 | {what should have happened} 8 | 9 | ### Actual behaviour 10 | 11 | {what happens} 12 | 13 | ### Description of fix 14 | 15 | {small description of what fixes the issue} 16 | 17 | ### Other info 18 | 19 | {anything else that might be related/useful} 20 | 21 | -------------------------------------------------------------------------------- /.github/workflows/gh-pages.yml: -------------------------------------------------------------------------------- 1 | name: github pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - feature/hide-privates 8 | 9 | jobs: 10 | build-deploy: 11 | runs-on: ubuntu-18.04 12 | steps: 13 | - uses: actions/checkout@v1 14 | - uses: actions/setup-node@v1 15 | with: 16 | node-version: 12 17 | 18 | - name: build 19 | run: | 20 | npm ci 21 | npm run docs 22 | 23 | - name: deploy 24 | uses: docker://peaceiris/gh-pages:v2.1.0 25 | env: 26 | ACTIONS_DEPLOY_KEY: ${{ secrets.ACTIONS_DEPLOY_KEY }} 27 | PUBLISH_BRANCH: gh-pages 28 | PUBLISH_DIR: ./docs 29 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node.js Package 2 | 3 | on: 4 | push: 5 | paths: 6 | - '*.ts' 7 | - '*.js' 8 | - '*.json' 9 | - '.github/workflows/*' 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v1 17 | - uses: actions/setup-node@v1 18 | with: 19 | node-version: 12 20 | - name: linting and testing 21 | run: | 22 | npm ci 23 | npm run build 24 | npm run typecheck 25 | npm run lint 26 | npm run test 27 | 28 | publish: 29 | needs: build 30 | runs-on: ubuntu-latest 31 | steps: 32 | - uses: actions/checkout@v1 33 | - uses: actions/setup-node@v1 34 | with: 35 | node-version: 12 36 | registry-url: https://registry.npmjs.org/ 37 | - name: publish 38 | if: startsWith(github.ref, 'refs/tags/') 39 | run: | 40 | npm ci 41 | npm publish 42 | env: 43 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 44 | -------------------------------------------------------------------------------- /.github/workflows/run-e2e-tests.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: CI 4 | 5 | # Controls when the action will run. Triggers the workflow on push or pull request 6 | # events but only for the main branch 7 | on: 8 | push: 9 | branches: [main] 10 | pull_request: 11 | branches: [main] 12 | 13 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 14 | jobs: 15 | test: 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Run tests 21 | env: 22 | USER_A: ${{ secrets.USER_A }} 23 | USER_B: ${{ secrets.USER_B }} 24 | PASSWORD_A: ${{ secrets.PASSWORD_A }} 25 | PASSWORD_B: ${{ secrets.PASSWORD_B }} 26 | NUMBER_A: ${{ secrets.NUMBER_A }} 27 | NUMBER_B: ${{ secrets.NUMBER_B }} 28 | WEBSOCKET_URL: ${{ secrets.WEBSOCKET_URL }} 29 | REALM: ${{ secrets.REALM }} 30 | run: | 31 | docker-compose -f docker-compose.yml up --abort-on-container-exit e2e 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | .cache 4 | build 5 | dist 6 | docs 7 | .env 8 | screenshots/** 9 | demo/config.mjs 10 | .dir-locals.el 11 | .nyc_output 12 | coverage 13 | # IDE specific files 14 | .idea/ 15 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | image: node:10 2 | 3 | cache: 4 | key: ${CI_COMMIT_REF_SLUG} 5 | paths: 6 | - node_modules/ 7 | 8 | before_script: 9 | - npm install --no-save 10 | 11 | lint: 12 | script: npm run lint 13 | 14 | typecheck: 15 | script: npm run typecheck 16 | 17 | test: 18 | script: npm run test 19 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | @wearespindle:registry=https://npm.pkg.github.com/ 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:latest 2 | 3 | COPY . /home/webuser/ 4 | 5 | RUN chown -R node:node /home/webuser 6 | 7 | WORKDIR /home/webuser 8 | 9 | USER node 10 | 11 | RUN npm ci 12 | 13 | EXPOSE 1235 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Open VoIP Alliance 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Open VoIP Alliance Webphone Lib 2 | 3 | ![npm](https://img.shields.io/npm/v/webphone-lib?style=flat-square) 4 | 5 | Makes calling easier by providing a layer of abstraction around SIP.js. To figure out why we made this, read [our blog post](https://wearespindle.com/articles/how-to-abstract-the-complications-of-sip-js-away-with-our-library/). 6 | 7 | ## Documentation 8 | 9 | Check out the documentation [here](https://open-voip-alliance.github.io/WebphoneLib/). 10 | 11 | ## Cool stuff 12 | 13 | - Allows you to switch audio devices mid-call. 14 | - Automatically recovers calls on connectivity loss. 15 | - Offers an easy-to-use modern javascript api. 16 | 17 | ## Join us! 18 | 19 | We would love more input for this project. Create an issue, create a pull request for an issue, or if you're not really sure, ask us. We're often hanging around on [discourse](https://discourse.openvoipalliance.org/). We would also love to hear your thoughts and feedback on our project and answer any questions you might have! 20 | 21 | ## Getting started 22 | 23 | ```bash 24 | $ git clone git@github.com:open-voip-alliance/WebphoneLib.git 25 | $ cd WebphoneLib 26 | $ touch demo/config.mjs 27 | ``` 28 | 29 | Add the following to `demo/config.mjs` 30 | 31 | ```javascript 32 | export const authorizationUserId = ; 33 | export const password = ''; 34 | export const realm = ''; 35 | export const websocketUrl = ''; 36 | ``` 37 | 38 | Run the demo-server: 39 | 40 | ```bash 41 | $ npm i && npm run demo 42 | ``` 43 | 44 | And then play around at http://localhost:1235/demo/. 45 | 46 | ## Examples 47 | 48 | ### Connecting and registering 49 | 50 | ```javascript 51 | import { Client } from 'webphone-lib'; 52 | 53 | const account = { 54 | user: 'accountId', 55 | password: 'password', 56 | uri: 'sip:accountId@', 57 | name: 'test' 58 | }; 59 | 60 | const transport = { 61 | wsServers: '', // or replace with your 62 | iceServers: [] // depending on if your provider needs STUN/TURN. 63 | }; 64 | 65 | const media = { 66 | input: { 67 | id: undefined, // default audio device 68 | audioProcessing: true, 69 | volume: 1.0, 70 | muted: false 71 | }, 72 | output: { 73 | id: undefined, // default audio device 74 | volume: 1.0, 75 | muted: false 76 | } 77 | }; 78 | 79 | const client = new Client({ account, transport, media }); 80 | 81 | await client.register(); 82 | ``` 83 | 84 | ### Incoming call 85 | 86 | ```javascript 87 | // incoming call below 88 | client.on('invite', (session) => { 89 | try { 90 | ringer(); 91 | 92 | let { accepted, rejectCause } = await session.accepted(); // wait until the call is picked up 93 | if (!accepted) { 94 | return; 95 | } 96 | 97 | showCallScreen(); 98 | 99 | await session.terminated(); 100 | } catch (e) { 101 | showErrorMessage(e) 102 | } finally { 103 | closeCallScreen(); 104 | } 105 | }); 106 | ``` 107 | 108 | ### Outgoing call 109 | 110 | ```javascript 111 | const session = client.invite('sip:518@'); 112 | 113 | try { 114 | showOutgoingCallInProgress(); 115 | 116 | let { accepted, rejectCause } = await session.accepted(); // wait until the call is picked up 117 | if (!accepted) { 118 | showRejectedScreen(); 119 | return; 120 | } 121 | 122 | showCallScreen(); 123 | 124 | await session.terminated(); 125 | } catch (e) { 126 | } finally { 127 | closeCallScreen(); 128 | } 129 | ``` 130 | 131 | ## Attended transfer of a call 132 | 133 | ```javascript 134 | if (await sessionA.accepted()) { 135 | await sessionA.hold(); 136 | 137 | const sessionB = client.invite('sip:519@'); 138 | if (await sessionB.accepted()) { 139 | // immediately transfer after the other party picked up :p 140 | await client.attendedTransfer(sessionA, sessionB); 141 | 142 | await sessionB.terminated(); 143 | } 144 | } 145 | ``` 146 | 147 | ## Audio device selection 148 | 149 | #### Set a primary input & output device: 150 | 151 | ```javascript 152 | const client = new Client({ 153 | account, 154 | transport, 155 | media: { 156 | input: { 157 | id: undefined, // default input device 158 | audioProcessing: true, 159 | volume: 1.0, 160 | muted: false 161 | }, 162 | output: { 163 | id: undefined, // default output device 164 | volume: 1.0, 165 | muted: false 166 | } 167 | } 168 | }); 169 | ``` 170 | 171 | #### Change the primary I/O devices: 172 | 173 | ```javascript 174 | client.defaultMedia.output.id = '230988012091820398213'; 175 | ``` 176 | 177 | #### Change the media of a session: 178 | 179 | ```javascript 180 | const session = await client.invite('123'); 181 | session.media.input.volume = 50; 182 | session.media.input.audioProcessing = false; 183 | session.media.input.muted = true; 184 | session.media.output.muted = false; 185 | session.media.setInput({ 186 | id: '120398120398123', 187 | audioProcessing: true, 188 | volume: 0.5, 189 | muted: true 190 | }); 191 | ``` 192 | 193 | ## Commands 194 | 195 | | Command | Help | 196 | | ------------------------- | ------------------------------------------------------------------------------- | 197 | | npm run docs | Generate the docs | 198 | | npm run test | Run the tests | 199 | | npm run test -- --verbose | Show output of `console.log` during tests | 200 | | npm run test-watch | Watch the tests as you make changes | 201 | | npm run build | Build the projects | 202 | | npm run prepare | Prepare the project for publish, this is automatically run before `npm publish` | 203 | | npm run lint | Run `tslint` over the source files | 204 | | npm run typecheck | Verifies type constraints are met | 205 | 206 | ## Generate documentation 207 | 208 | [Typedoc](https://typedoc.org/guides/doccomments/) is used to generate the 209 | documentation from the `jsdoc` comments in the source code. See [this 210 | link](https://typedoc.org/guides/doccomments/) for more information on which 211 | `jsdoc` tags are supported. 212 | 213 | ## Run puppeteer tests 214 | 215 | ### Using docker 216 | 217 | Add a .env file with the following: 218 | 219 | ``` 220 | USER_A = 221 | USER_B = 222 | PASSWORD_A = 223 | PASSWORD_B = 224 | NUMBER_A = 225 | NUMBER_B = 226 | WEBSOCKET_URL = 227 | REALM = 228 | ``` 229 | 230 | Then call `docker-compose up` to run the tests. 231 | 232 | Note: Don't forget to call `npm ci` in the puppeteer folder. :) 233 | 234 | ### Without docker 235 | 236 | If you don't want to use docker, you will need to run the demo with the `npm run demo` command (and keep it running) and run the tests with `npm run test:e2e`. For this you will need the .env file with your settings. 237 | -------------------------------------------------------------------------------- /demo/components/c-audio-visualiser.mjs: -------------------------------------------------------------------------------- 1 | import * as sipClient from '../lib/calling.mjs'; 2 | import { NodesProxy } from '../utils/elementProxies.mjs'; 3 | 4 | const WIDTH = 1024; 5 | const HEIGHT = 128; 6 | 7 | window.customElements.define( 8 | 'c-audio-visualiser', 9 | class extends HTMLElement { 10 | constructor() { 11 | super(); 12 | this.nodes = new NodesProxy(this); 13 | } 14 | 15 | handleEvent({ detail: { session } }) { 16 | this.setUpAnalysers(session); 17 | } 18 | 19 | setUpAnalysers(session) { 20 | const localStream = session.localStream.stream; 21 | const remoteStream = session.remoteStream; 22 | 23 | const localAudioCtx = new AudioContext(); 24 | const remoteAudioCtx = new AudioContext(); 25 | 26 | this.localAnalyser = localAudioCtx.createAnalyser(); 27 | this.remoteAnalyser = remoteAudioCtx.createAnalyser(); 28 | 29 | this.localAnalyser.connect(localAudioCtx.destination); 30 | this.localAnalyser.fftSize = 512 * 2; 31 | this.remoteAnalyser.connect(remoteAudioCtx.destination); 32 | this.remoteAnalyser.fftSize = 512 * 2; 33 | 34 | const localSource = localAudioCtx.createMediaStreamSource(localStream); 35 | const remoteSource = remoteAudioCtx.createMediaStreamSource(remoteStream); 36 | localSource.connect(this.localAnalyser); 37 | remoteSource.connect(this.remoteAnalyser); 38 | 39 | this.draw(); 40 | } 41 | 42 | draw() { 43 | this.drawVisualiser(this.localAnalyser, this.nodes.localAudio); 44 | this.drawVisualiser(this.remoteAnalyser, this.nodes.remoteAudio); 45 | requestAnimationFrame(this.draw.bind(this)); 46 | } 47 | 48 | drawVisualiser(analyser, canvas) { 49 | const bufferLength = analyser.frequencyBinCount; 50 | 51 | const dataArray = new Uint8Array(bufferLength); 52 | 53 | analyser.getByteFrequencyData(dataArray); 54 | 55 | let canvasCtx = canvas.getContext('2d'); 56 | canvasCtx.fillStyle = 'rgb(0, 0, 0)'; 57 | canvasCtx.fillRect(0, 0, WIDTH, HEIGHT); 58 | 59 | let barWidth = (WIDTH - bufferLength) / bufferLength; 60 | let barHeight; 61 | let x = 0; 62 | 63 | for (let i = 0; i < bufferLength; i++) { 64 | barHeight = dataArray[i]; 65 | 66 | canvasCtx.fillStyle = '#FE7F9C'; 67 | canvasCtx.fillRect(x, HEIGHT - barHeight / 2, barWidth, barHeight / 2); 68 | 69 | x += barWidth + 1; 70 | } 71 | } 72 | 73 | connectedCallback() { 74 | const template = document.querySelector('[data-component=c-audio-visualiser]'); 75 | this.appendChild(template.content.cloneNode(true)); 76 | 77 | sipClient.callingEvents.addEventListener('sessionAccepted', this); 78 | } 79 | 80 | disconnectedCallback() { 81 | sipClient.callingEvents.removeEventListener('sessionAccepted', this); 82 | } 83 | } 84 | ); 85 | -------------------------------------------------------------------------------- /demo/components/c-computer-settings.mjs: -------------------------------------------------------------------------------- 1 | import './c-volume-setting.mjs'; 2 | import './c-devices-setting.mjs'; 3 | 4 | import { ActionsProxy, NodesProxy } from '../utils/elementProxies.mjs'; 5 | 6 | window.customElements.define( 7 | 'c-computer-settings', 8 | class extends HTMLElement { 9 | constructor() { 10 | super(); 11 | 12 | this.actions = new ActionsProxy(this); 13 | this.nodes = new NodesProxy(this); 14 | } 15 | 16 | connectedCallback() { 17 | const template = document.querySelector('[data-component=c-computer-settings]'); 18 | this.appendChild(template.content.cloneNode(true)); 19 | } 20 | } 21 | ); 22 | -------------------------------------------------------------------------------- /demo/components/c-console-message.mjs: -------------------------------------------------------------------------------- 1 | import { NodesProxy } from '../utils/elementProxies.mjs'; 2 | import styleConsoleMessage from '../utils/styleConsoleMessage.mjs'; 3 | 4 | window.customElements.define( 5 | 'c-console-message', 6 | class extends HTMLElement { 7 | set message(message) { 8 | this._message = message; 9 | } 10 | 11 | set level(level) { 12 | this._level = level; 13 | } 14 | 15 | set module(module) { 16 | this._module = module; 17 | } 18 | 19 | constructor() { 20 | super(); 21 | this.nodes = new NodesProxy(this); 22 | } 23 | 24 | onErrorMessage(errorMessage) { 25 | this.nodes.messageBox.innerText = errorMessage + '\n' + errorMessage.stack; 26 | } 27 | 28 | connectedCallback() { 29 | const styles = styleConsoleMessage(this._level); 30 | const template = document.querySelector('[data-component=c-console-message]'); 31 | this.appendChild(template.content.cloneNode(true)); 32 | this.nodes.moduleBox.innerText = this._module; 33 | this.nodes.moduleBox.style = styles.join(';'); 34 | 35 | if (this._level === 'error') { 36 | this.onErrorMessage(this._message); 37 | } else { 38 | this.nodes.messageBox.innerText = this._message; 39 | } 40 | } 41 | } 42 | ); 43 | -------------------------------------------------------------------------------- /demo/components/c-contact.mjs: -------------------------------------------------------------------------------- 1 | import { ActionsProxy, NodesProxy } from '../utils/elementProxies.mjs'; 2 | import { subscriptionEvents, subscriptions } from '../lib/calling.mjs'; 3 | import { Logger } from '../lib/logging.mjs'; 4 | 5 | const logger = new Logger('c-contact'); 6 | 7 | window.customElements.define( 8 | 'c-contact', 9 | class extends HTMLElement { 10 | set data(data) { 11 | this._data = data; 12 | } 13 | 14 | get data() { 15 | return this._data; 16 | } 17 | 18 | constructor() { 19 | super(); 20 | 21 | this.actions = new ActionsProxy(this); 22 | this.nodes = new NodesProxy(this); 23 | } 24 | 25 | async handleEvent({ type, detail, currentTarget }) { 26 | switch (type) { 27 | case `notify-${this.data.contactUri}`: 28 | this.nodes.contactStatus.textContent = detail.status; 29 | this.populate(); 30 | break; 31 | case `remove-${this.data.contactUri}`: 32 | this.remove(); 33 | break; 34 | default: 35 | break; 36 | } 37 | } 38 | 39 | populate() { 40 | if (this.data) { 41 | const { contactUri } = this.data; 42 | this.nodes.contactUri.textContent = contactUri; 43 | this.nodes.contactStatus.textContent = subscriptions[contactUri]; 44 | } 45 | } 46 | 47 | connectedCallback() { 48 | const template = document.querySelector('[data-component=c-contact]'); 49 | this.appendChild(template.content.cloneNode(true)); 50 | this.populate(); 51 | 52 | subscriptionEvents.addEventListener(`notify-${this.data.contactUri}`, this); 53 | subscriptionEvents.addEventListener(`remove-${this.data.contactUri}`, this); 54 | } 55 | 56 | disconnectedCallback() { 57 | subscriptionEvents.removeEventListener(`notify-${this.data.contactUri}`, this); 58 | subscriptionEvents.removeEventListener(`remove-${this.data.contactUri}`, this); 59 | } 60 | } 61 | ); 62 | -------------------------------------------------------------------------------- /demo/components/c-contacts.mjs: -------------------------------------------------------------------------------- 1 | import { ActionsProxy, NodesProxy } from '../utils/elementProxies.mjs'; 2 | import * as sipClient from '../lib/calling.mjs'; 3 | import { Logger } from '../lib/logging.mjs'; 4 | 5 | const logger = new Logger('c-contacts'); 6 | 7 | // subscribe button with an input field (remembering a few number) 8 | // for the contact to subscribe to 9 | // A subscriptions list, listing the subscriptions you have 10 | // when you click on the subscription be able to unsubscribe 11 | window.customElements.define( 12 | 'c-contacts', 13 | class extends HTMLElement { 14 | constructor() { 15 | super(); 16 | 17 | this.actions = new ActionsProxy(this); 18 | this.nodes = new NodesProxy(this); 19 | } 20 | 21 | async handleEvent(e) { 22 | e.preventDefault(); 23 | 24 | switch (e.type) { 25 | case 'click': 26 | const { 27 | target: { dataset } 28 | } = e; 29 | 30 | if (dataset.action) { 31 | const accountId = this.nodes.input.value; 32 | switch (dataset.action) { 33 | case 'subscribe': 34 | logger.info(`Subscribing to ${accountId}`); 35 | 36 | await sipClient.subscribe(this.nodes.input.value); 37 | 38 | const contactNode = document.createElement('c-contact'); 39 | contactNode.data = { contactUri: this.nodes.input.value }; 40 | this.nodes.contactsList.appendChild(contactNode); 41 | break; 42 | case 'unsubscribe': 43 | logger.info(`Unsubscribing from ${accountId}`); 44 | sipClient.unsubscribe(accountId); 45 | break; 46 | default: 47 | break; 48 | } 49 | } 50 | break; 51 | default: 52 | break; 53 | } 54 | } 55 | 56 | connectedCallback() { 57 | const template = document.querySelector('[data-component=c-contacts]'); 58 | this.appendChild(template.content.cloneNode(true)); 59 | 60 | this.actions.subscribe.addEventListener('click', this); 61 | this.actions.unsubscribe.addEventListener('click', this); 62 | } 63 | 64 | disconnectedCallback() { 65 | this.actions.subscribe.removeEventListener('click', this); 66 | this.actions.unsubscribe.removeEventListener('click', this); 67 | } 68 | } 69 | ); 70 | -------------------------------------------------------------------------------- /demo/components/c-debug-console.mjs: -------------------------------------------------------------------------------- 1 | import { ActionsProxy, NodesProxy } from '../utils/elementProxies.mjs'; 2 | import { loggingEvents } from '../lib/logging.mjs'; 3 | import './c-console-message.mjs'; 4 | 5 | window.customElements.define( 6 | 'c-debug-console', 7 | class extends HTMLElement { 8 | constructor() { 9 | super(); 10 | this.nodes = new NodesProxy(this); 11 | } 12 | 13 | handleEvent(e) { 14 | e.preventDefault(); 15 | const message = document.createElement('c-console-message'); 16 | message.message = e.detail.message; 17 | message.level = e.detail.level; 18 | message.module = e.detail.module; 19 | this.nodes.outputWindow.appendChild(message); 20 | this.updateScroll(); 21 | } 22 | 23 | updateScroll() { 24 | this.nodes.outputWindow.scrollTop = this.nodes.outputWindow.scrollHeight; 25 | } 26 | 27 | connectedCallback() { 28 | const template = document.querySelector('[data-component=c-debug-console]'); 29 | this.appendChild(template.content.cloneNode(true)); 30 | loggingEvents.addEventListener('log_to_console', this); 31 | } 32 | 33 | disconnectedCallback() { 34 | loggingEvents.removeEventListener('log_to_console', this); 35 | } 36 | } 37 | ); 38 | -------------------------------------------------------------------------------- /demo/components/c-devices-setting.mjs: -------------------------------------------------------------------------------- 1 | import { ActionsProxy, NodesProxy } from '../utils/elementProxies.mjs'; 2 | import * as sipClient from '../lib/media.mjs'; 3 | 4 | window.customElements.define( 5 | 'c-devices-setting', 6 | class extends HTMLElement { 7 | constructor() { 8 | super(); 9 | 10 | this.actions = new ActionsProxy(this); 11 | this.nodes = new NodesProxy(this); 12 | } 13 | 14 | handleEvent({ type, currentTarget }) { 15 | switch (type) { 16 | case 'change': 17 | switch (currentTarget) { 18 | case this.nodes.inputSelect: 19 | sipClient.changeInputSelect(currentTarget); 20 | break; 21 | case this.nodes.outputSelect: 22 | sipClient.changeOutputSelect(currentTarget); 23 | break; 24 | default: 25 | break; 26 | } 27 | break; 28 | case 'click': 29 | sipClient.playSound(); 30 | break; 31 | default: 32 | break; 33 | } 34 | } 35 | 36 | connectedCallback() { 37 | const template = document.querySelector('[data-component=c-devices-setting]'); 38 | this.appendChild(template.content.cloneNode(true)); 39 | 40 | [this.nodes.inputSelect, this.nodes.outputSelect].forEach(n => { 41 | n.addEventListener('change', this); 42 | }); 43 | this.nodes.play.addEventListener('click', this); 44 | } 45 | 46 | disconnectedCallback() { 47 | [this.nodes.inputSelect, this.nodes.outputSelect].forEach(n => { 48 | n.removeEventListener('change', this); 49 | }); 50 | this.nodes.play.removeEventListener('click', this); 51 | } 52 | } 53 | ); 54 | -------------------------------------------------------------------------------- /demo/components/c-dialer.mjs: -------------------------------------------------------------------------------- 1 | import './c-keypad.mjs'; 2 | import './c-session.mjs'; 3 | import { ActionsProxy, NodesProxy } from '../utils/elementProxies.mjs'; 4 | import * as sipClient from '../lib/calling.mjs'; 5 | 6 | window.customElements.define( 7 | 'c-dialer', 8 | class extends HTMLElement { 9 | constructor() { 10 | super(); 11 | 12 | this.actions = new ActionsProxy(this); 13 | this.nodes = new NodesProxy(this); 14 | 15 | this._sessions = []; 16 | } 17 | 18 | handleEvent({ currentTarget, target }) { 19 | switch (currentTarget) { 20 | case this.nodes.keypad: 21 | this.nodes.input.value += target.dataset.key; 22 | break; 23 | case this.actions.call: 24 | const { value } = this.nodes.input; 25 | sipClient.invite(value); 26 | break; 27 | default: 28 | break; 29 | } 30 | } 31 | 32 | handleInputKeyDownEvent(e) { 33 | switch (e.key) { 34 | case 'Enter': 35 | e.preventDefault(); 36 | const { value } = this.nodes.input; 37 | sipClient.invite(value); 38 | break; 39 | default: 40 | break; 41 | } 42 | } 43 | 44 | connectedCallback() { 45 | const template = document.querySelector('[data-component=c-dialer]'); 46 | this.appendChild(template.content.cloneNode(true)); 47 | 48 | this.sessions = sipClient.getSessions(); 49 | 50 | this.nodes.keypad.addEventListener('click', this); 51 | this.nodes.input.addEventListener('keydown', this.handleInputKeyDownEvent.bind(this), true); 52 | 53 | this.actions.call.addEventListener('click', this); 54 | 55 | sipClient.callingEvents.addEventListener('sessionsUpdated', this); 56 | } 57 | 58 | disconnectedCallback() { 59 | this.nodes.keypad.removeEventListener('click', this); 60 | this.nodes.input.removeEventListener( 61 | 'keydown', 62 | this.handleInputKeyDownEvent.bind(this), 63 | true 64 | ); 65 | 66 | this.actions.call.removeEventListener('click', this); 67 | 68 | sipClient.callingEvents.removeEventListener('sessionsUpdated', this); 69 | } 70 | } 71 | ); 72 | -------------------------------------------------------------------------------- /demo/components/c-keypad.mjs: -------------------------------------------------------------------------------- 1 | import { playSound } from '../lib/media.mjs'; 2 | 3 | window.customElements.define( 4 | 'c-keypad', 5 | class extends HTMLElement { 6 | handleEvent({ target: { dataset } }) { 7 | if (dataset && dataset.key) { 8 | playSound(); 9 | } 10 | } 11 | 12 | connectedCallback() { 13 | const template = document.querySelector('[data-component=c-keypad]'); 14 | this.appendChild(template.content.cloneNode(true)); 15 | 16 | this.addEventListener('click', this); 17 | this.dialerKeypadButtons = this.querySelector('[data-selector=dialer-keypad-buttons]'); 18 | } 19 | 20 | disconnectedCallback() { 21 | this.removeEventListener('click', this); 22 | } 23 | } 24 | ); 25 | -------------------------------------------------------------------------------- /demo/components/c-mos-values.mjs: -------------------------------------------------------------------------------- 1 | import { ActionsProxy, NodesProxy } from '../utils/elementProxies.mjs'; 2 | import * as sipClient from '../lib/calling.mjs'; 3 | 4 | window.customElements.define( 5 | 'c-mos-values', 6 | class extends HTMLElement { 7 | constructor() { 8 | super(); 9 | 10 | this.nodes = new NodesProxy(this); 11 | } 12 | 13 | handleEvent({ detail }) { 14 | this.nodes.mosValues.removeAttribute('hidden'); 15 | 16 | const { stats } = detail; 17 | 18 | const last = (stats.last || 0).toFixed(2); 19 | const low = (stats.lowest || 0).toFixed(2); 20 | const high = (stats.highest || 0).toFixed(2); 21 | const avg = (stats.average || 0).toFixed(2); 22 | 23 | this.nodes.lowest.innerText = `Low: ${low}`; 24 | this.nodes.highest.innerText = `High: ${high}`; 25 | this.nodes.average.innerText = `Average: ${avg}`; 26 | this.nodes.last.innerText = `Last: ${last}`; 27 | } 28 | 29 | connectedCallback() { 30 | const template = document.querySelector('[data-component=c-mos-values]'); 31 | this.appendChild(template.content.cloneNode(true)); 32 | 33 | sipClient.callingEvents.addEventListener('changeMosValues', this); 34 | } 35 | 36 | disconnectedCallback() { 37 | sipClient.callingEvents.removeEventListener('changeMosValues', this); 38 | } 39 | } 40 | ); 41 | -------------------------------------------------------------------------------- /demo/components/c-publisher.mjs: -------------------------------------------------------------------------------- 1 | import './c-keypad.mjs'; 2 | import './c-session.mjs'; 3 | import { ActionsProxy, NodesProxy } from '../utils/elementProxies.mjs'; 4 | import * as sipClient from '../lib/calling.mjs'; 5 | import { Logger } from '../lib/logging.mjs'; 6 | 7 | const logger = new Logger('c-publisher'); 8 | 9 | function publish(publisher, message, callId) { 10 | logger.info(callId); 11 | 12 | publisher.publish(message); 13 | } 14 | 15 | window.customElements.define( 16 | 'c-publisher', 17 | class extends HTMLElement { 18 | constructor() { 19 | super(); 20 | 21 | this.actions = new ActionsProxy(this); 22 | this.nodes = new NodesProxy(this); 23 | } 24 | 25 | handleEvent({ currentTarget, target }) { 26 | switch (currentTarget) { 27 | case this.actions.publish: 28 | { 29 | const { value: target } = this.nodes.targetInput; 30 | const { value: content } = this.nodes.contentInput; 31 | 32 | logger.info(`Publishing for ${target}`); 33 | 34 | const publisher = this.getOrCreatePublisher(target, { 35 | body: content, 36 | contentType: 'application/dialog-info+xml', 37 | expires: 60 38 | }); 39 | 40 | if (!publisher) { 41 | logger.info('Should register before publishing'); 42 | } 43 | 44 | publish(publisher, content, this.publisher.request.callId); 45 | } 46 | break; 47 | default: 48 | break; 49 | } 50 | } 51 | 52 | getOrCreatePublisher(contact, options) { 53 | if (!this.publisher) { 54 | this.publisher = sipClient.createPublisher(contact, options); 55 | } 56 | 57 | return this.publisher; 58 | } 59 | 60 | connectedCallback() { 61 | const template = document.querySelector('[data-component=c-publisher]'); 62 | this.appendChild(template.content.cloneNode(true)); 63 | 64 | this.actions.publish.addEventListener('click', this); 65 | } 66 | 67 | disconnectedCallback() { 68 | this.actions.publish.removeEventListener('click', this); 69 | } 70 | } 71 | ); 72 | -------------------------------------------------------------------------------- /demo/components/c-session.mjs: -------------------------------------------------------------------------------- 1 | import './c-transfer.mjs'; 2 | 3 | import * as sipClient from '../lib/calling.mjs'; 4 | import { empty } from '../lib/dom.mjs'; 5 | import { Logger } from '../lib/logging.mjs'; 6 | import { ActionsProxy, NodesProxy } from '../utils/elementProxies.mjs'; 7 | 8 | const logger = new Logger('c-session'); 9 | 10 | function handleSessionStatusUpdate({ status }) { 11 | this.status = status; 12 | } 13 | 14 | window.customElements.define( 15 | 'c-session', 16 | class extends HTMLElement { 17 | static get observedAttributes() { 18 | return ['session-id']; 19 | } 20 | 21 | set status(_status) { 22 | this._status = _status; 23 | this.className = `status-${_status}`; 24 | 25 | this.nodes.sessionStatus.textContent = _status; 26 | } 27 | 28 | get status() { 29 | return this._status; 30 | } 31 | 32 | constructor() { 33 | super(); 34 | 35 | this.actions = new ActionsProxy(this); 36 | this.nodes = new NodesProxy(this); 37 | } 38 | 39 | async handleEvent(e) { 40 | const { 41 | target: { dataset } 42 | } = e; 43 | 44 | if (dataset.action) { 45 | logger.info(`Clicked ${dataset.action}`); 46 | 47 | switch (dataset.action) { 48 | case 'accept': 49 | this.session && (await this.session.accept()); 50 | break; 51 | case 'reject': 52 | this.session && (await this.session.reject()); 53 | break; 54 | case 'cancel': 55 | this.session && (await this.session.cancel()); 56 | break; 57 | case 'toggleTransfer': 58 | if (!this.querySelectorAll('c-transfer').length > 0) { 59 | const transfer = document.createElement('c-transfer'); 60 | transfer.setAttribute('session-id', this.session.id); 61 | this.appendChild(transfer); 62 | 63 | this.session.hold(); 64 | } else { 65 | empty(this.nodes.additionalInterface); 66 | } 67 | break; 68 | case 'toggleDTMF': 69 | if (!this.querySelectorAll('c-keypad').length > 0) { 70 | const node = document.createElement('c-keypad'); 71 | node.setAttribute('session-id', this.session.id); 72 | this.nodes.additionalInterface.appendChild(node); 73 | } else { 74 | empty(this.nodes.additionalInterface); 75 | } 76 | break; 77 | case 'hold': 78 | this.setMute(true); 79 | this.session && this.session.hold(); 80 | break; 81 | case 'unhold': 82 | this.setMute(false); 83 | this.session && this.session.unhold(); 84 | break; 85 | case 'toggleMute': 86 | this.toggleMute(); 87 | break; 88 | case 'hangup': 89 | this.session && (await this.session.terminate()); 90 | break; 91 | default: 92 | break; 93 | } 94 | } else if (dataset.key) { 95 | logger.info(`Pressed: ${dataset.key}`); 96 | this.session && this.session.dtmf(dataset.key); 97 | } 98 | } 99 | 100 | toggleMute() { 101 | if (this.session) { 102 | this.session.media.input.muted = !this.session.media.input.muted; 103 | } 104 | } 105 | 106 | setMute(mute) { 107 | this.session.media.input.muted = mute; 108 | } 109 | 110 | connectedCallback() { 111 | const template = document.querySelector('[data-component=c-session]'); 112 | this.appendChild(template.content.cloneNode(true)); 113 | 114 | this.nodes.phoneNumber.innerText = this.session.phoneNumber; 115 | this.nodes.sessionDirection.innerText = this.session.isIncoming 116 | ? 'incoming call' 117 | : 'outgoing call'; 118 | 119 | [ 120 | this.actions.accept, 121 | this.actions.reject, 122 | this.actions.cancel, 123 | this.actions.toggleTransfer, 124 | this.actions.toggleDTMF, 125 | this.actions.hold, 126 | this.actions.unhold, 127 | this.actions.hangup, 128 | this.nodes.additionalInterface 129 | ].forEach(n => { 130 | n.addEventListener('click', this); 131 | }); 132 | 133 | this.session.on('statusUpdate', handleSessionStatusUpdate.bind(this)); 134 | } 135 | 136 | disconnectedCallback() { 137 | [ 138 | this.actions.accept, 139 | this.actions.reject, 140 | this.actions.cancel, 141 | this.actions.toggleTransfer, 142 | this.actions.toggleDTMF, 143 | this.actions.hold, 144 | this.actions.unhold, 145 | this.actions.hangup, 146 | this.nodes.additionalInterface 147 | ].forEach(n => { 148 | n.removeEventListener('click', this); 149 | }); 150 | this.session.removeListener('statusUpdate', handleSessionStatusUpdate.bind(this)); 151 | } 152 | 153 | attributeChangedCallback(name, oldValue, newValue) { 154 | this.session = sipClient.getSession(newValue); 155 | } 156 | } 157 | ); 158 | -------------------------------------------------------------------------------- /demo/components/c-sessions.mjs: -------------------------------------------------------------------------------- 1 | import { ActionsProxy, NodesProxy } from '../utils/elementProxies.mjs'; 2 | import * as sipClient from '../lib/calling.mjs'; 3 | 4 | window.customElements.define( 5 | 'c-sessions', 6 | class extends HTMLElement { 7 | set sessions(sessions) { 8 | this._sessions = sessions; 9 | this.updateDOM(); 10 | } 11 | 12 | constructor() { 13 | super(); 14 | 15 | this.actions = new ActionsProxy(this); 16 | this.nodes = new NodesProxy(this); 17 | } 18 | 19 | handleEvent(e) { 20 | this.sessions = sipClient.getSessions(); 21 | } 22 | 23 | updateDOM() { 24 | if (this.isConnected) { 25 | const idsAlreadyAdded = []; 26 | // remove the ones that shouldn't be there. 27 | for (const node of this.querySelectorAll('c-session')) { 28 | const sessionId = node.getAttribute('session-id'); 29 | if (!sipClient.getSession(sessionId)) { 30 | this.nodes.sessionsList.removeChild(node); 31 | } else { 32 | idsAlreadyAdded.push(sessionId); 33 | } 34 | } 35 | 36 | // add the ones that should be there. 37 | this._sessions.forEach(({ id }) => { 38 | if (idsAlreadyAdded.includes(id)) { 39 | return; 40 | } 41 | 42 | const node = document.createElement('c-session'); 43 | node.setAttribute('session-id', id); 44 | this.nodes.sessionsList.appendChild(node); 45 | }); 46 | } 47 | } 48 | 49 | connectedCallback() { 50 | const template = document.querySelector('[data-component=c-sessions]'); 51 | this.appendChild(template.content.cloneNode(true)); 52 | 53 | this.sessions = sipClient.getSessions(); 54 | sipClient.callingEvents.addEventListener('sessionsUpdated', this); 55 | } 56 | 57 | disconnectedCallback() { 58 | sipClient.callingEvents.removeEventListener('sessionsUpdated', this); 59 | } 60 | } 61 | ); 62 | -------------------------------------------------------------------------------- /demo/components/c-transfer.mjs: -------------------------------------------------------------------------------- 1 | import { ActionsProxy, NodesProxy } from '../utils/elementProxies.mjs'; 2 | import * as sipClient from '../lib/calling.mjs'; 3 | import { Logger } from '../lib/logging.mjs'; 4 | 5 | const logger = new Logger('c-transfer'); 6 | 7 | export async function attendedTransfer(session, number) { 8 | // Holding the first session 9 | session.hold(); 10 | 11 | const toSession = await sipClient.invite(number); 12 | 13 | sipClient.sessionAccepted(toSession).then(() => { 14 | logger.info('Second session got accepted, waiting 3 seconds before transferring.'); 15 | window.setTimeout(() => { 16 | sipClient.attendedTransfer(session, toSession); 17 | }, 3000); // Waiting 3 seconds before transferring. 18 | }); 19 | 20 | sipClient.sessionRejected(toSession).then(({ rejectCause }) => { 21 | logger.info('Second session was rejected '); 22 | }); 23 | } 24 | 25 | window.customElements.define( 26 | 'c-transfer', 27 | class extends HTMLElement { 28 | static get observedAttributes() { 29 | return ['session-id']; 30 | } 31 | 32 | constructor() { 33 | super(); 34 | 35 | this.actions = new ActionsProxy(this); 36 | this.nodes = new NodesProxy(this); 37 | } 38 | 39 | handleEvent(e) { 40 | const { type, currentTarget } = e; 41 | switch (type) { 42 | case 'attendedTransferStatusUpdated': 43 | const { detail } = e; 44 | this.transferToSession = detail.b; 45 | break; 46 | case 'change': 47 | switch (currentTarget) { 48 | case this.nodes.input: 49 | break; 50 | case this.nodes.selectTransferMethod: 51 | this.transferMethod = this.nodes.selectTransferMethod.value; 52 | break; 53 | default: 54 | break; 55 | } 56 | break; 57 | case 'click': 58 | switch (currentTarget) { 59 | case this.actions.transferCall: 60 | this.transferCall(); 61 | break; 62 | default: 63 | break; 64 | } 65 | break; 66 | default: 67 | break; 68 | } 69 | } 70 | 71 | transferCall(value = this.nodes.input.value) { 72 | if (!value) { 73 | return; 74 | } 75 | const number = value; 76 | 77 | switch (this.transferMethod) { 78 | case 'attended': 79 | // Note: will initiate an invite and will do an attended transfer after that invite is accepted. 80 | attendedTransfer(this.session, number); 81 | break; 82 | case 'blind': 83 | sipClient.blindTransfer(this.session, number); 84 | break; 85 | default: 86 | break; 87 | } 88 | } 89 | 90 | connectedCallback() { 91 | const template = document.querySelector('[data-component=c-transfer]'); 92 | this.appendChild(template.content.cloneNode(true)); 93 | 94 | this.nodes.selectTransferMethod.addEventListener('change', this); 95 | this.nodes.input.addEventListener('change', this); 96 | 97 | this.actions.transferCall.addEventListener('click', this); 98 | 99 | sipClient.callingEvents.addEventListener('attendedTransferStatusUpdated', this); 100 | 101 | this.transferMethod = this.nodes.selectTransferMethod.value; 102 | } 103 | 104 | disconnectedCallback() { 105 | this.nodes.selectTransferMethod.removeEventListener('change', this); 106 | this.nodes.input.removeEventListener('change', this); 107 | 108 | this.actions.transferCall.removeEventListener('click', this); 109 | 110 | sipClient.callingEvents.removeEventListener('attendedTransferStatusUpdated', this); 111 | } 112 | 113 | attributeChangedCallback(name, oldValue, newValue) { 114 | this.session = sipClient.getSession(newValue); 115 | } 116 | } 117 | ); 118 | -------------------------------------------------------------------------------- /demo/components/c-voip-account.mjs: -------------------------------------------------------------------------------- 1 | import * as CONF from '../config.mjs'; 2 | import * as sipClient from '../lib/calling.mjs'; 3 | import { setOndevicesChanged, setInputsAndOutputs } from '../lib/media.mjs'; 4 | import { ActionsProxy, NodesProxy } from '../utils/elementProxies.mjs'; 5 | 6 | window.customElements.define( 7 | 'c-voip-account', 8 | class extends HTMLElement { 9 | constructor() { 10 | super(); 11 | 12 | this.actions = new ActionsProxy(this); 13 | this.nodes = new NodesProxy(this); 14 | } 15 | 16 | handleEvent(e) { 17 | e.preventDefault(); 18 | 19 | switch (e.type) { 20 | case 'click': 21 | { 22 | const { 23 | target: { dataset } 24 | } = e; 25 | 26 | if (dataset.action) { 27 | switch (dataset.action) { 28 | case 'register': 29 | { 30 | const userId = this.nodes.userIdInput.value; 31 | const password = this.nodes.passwordInput.value; 32 | const websocketUrl = this.nodes.websocketUrlInput.value; 33 | const realm = this.nodes.realmInput.value; 34 | sipClient.setAccount(userId, password, realm); 35 | sipClient.setTransport(websocketUrl); 36 | sipClient.setClient(); 37 | setOndevicesChanged(); 38 | setInputsAndOutputs(); 39 | sipClient.registerAccount(); 40 | console.log('register'); 41 | } 42 | break; 43 | case 'unregister': 44 | sipClient.unregisterAccount(); 45 | console.log('unregister'); 46 | break; 47 | case 'reconfigure': 48 | sipClient.reconfigure(); 49 | console.log('reconfigure'); 50 | break; 51 | } 52 | } 53 | } 54 | break; 55 | 56 | case 'clientStatusUpdate': 57 | { 58 | const { 59 | detail: { status } 60 | } = e; 61 | this.nodes.clientStatus.textContent = status; 62 | } 63 | break; 64 | 65 | case 'change': 66 | localStorage.setItem('dndEnabled', this.actions.dndToggle.checked); 67 | break; 68 | 69 | default: 70 | console.log(e); 71 | } 72 | } 73 | 74 | connectedCallback() { 75 | const template = document.querySelector('[data-component=c-voip-account]'); 76 | this.appendChild(template.content.cloneNode(true)); 77 | 78 | [this.actions.register, this.actions.unregister, this.actions.reconfigure].forEach(n => { 79 | n.addEventListener('click', this); 80 | }); 81 | 82 | this.actions.dndToggle.addEventListener('change', this); 83 | 84 | if (localStorage.getItem('dndEnabled') === 'true') { 85 | this.actions.dndToggle.setAttribute('checked', ''); 86 | } 87 | 88 | this.nodes.passwordInput.value = CONF.password; 89 | this.nodes.userIdInput.value = CONF.authorizationUserId; 90 | this.nodes.realmInput.value = CONF.realm; 91 | this.nodes.websocketUrlInput.value = CONF.websocketUrl; 92 | 93 | sipClient.callingEvents.addEventListener('clientStatusUpdate', this); 94 | } 95 | 96 | disconnectedCallback() { 97 | [this.actions.register, this.actions.unregister, this.actions.reconfigure].forEach(n => { 98 | n.removeEventListener('click', this); 99 | }); 100 | 101 | this.actions.dndToggle.removeEventListener('change', this); 102 | 103 | sipClient.callingEvents.removeEventListener('clientStatusUpdate', this); 104 | } 105 | } 106 | ); 107 | -------------------------------------------------------------------------------- /demo/components/c-volume-setting.mjs: -------------------------------------------------------------------------------- 1 | import { ActionsProxy, NodesProxy } from '../utils/elementProxies.mjs'; 2 | import * as sipClient from '../lib/media.mjs'; 3 | 4 | window.customElements.define( 5 | 'c-volume-setting', 6 | class extends HTMLElement { 7 | constructor() { 8 | super(); 9 | 10 | this.nodes = new NodesProxy(this); 11 | } 12 | 13 | handleVolumeChange({ currentTarget }) { 14 | const { value } = currentTarget; 15 | switch (currentTarget) { 16 | case this.nodes.inVol: 17 | sipClient.changeInputVolume(value); 18 | break; 19 | case this.nodes.outVol: 20 | sipClient.changeOutputVolume(value); 21 | break; 22 | default: 23 | break; 24 | } 25 | } 26 | 27 | handleMuteChange({ currentTarget }) { 28 | const { checked } = currentTarget; 29 | switch (currentTarget) { 30 | case this.nodes.inMute: 31 | sipClient.changeInputMuted(checked); 32 | break; 33 | case this.nodes.outMute: 34 | sipClient.changeOutputMuted(checked); 35 | break; 36 | default: 37 | break; 38 | } 39 | } 40 | 41 | connectedCallback() { 42 | const template = document.querySelector('[data-component=c-volume-setting]'); 43 | this.appendChild(template.content.cloneNode(true)); 44 | 45 | [this.nodes.inVol, this.nodes.outVol].forEach(n => { 46 | n.addEventListener('change', this.handleVolumeChange.bind(this)); 47 | }); 48 | [this.nodes.inMute, this.nodes.outMute].forEach(n => { 49 | n.addEventListener('change', this.handleMuteChange.bind(this)); 50 | }); 51 | } 52 | 53 | disconnectedCallback() { 54 | [this.nodes.inVol, this.nodes.inMute, this.nodes.outVol, this.nodes.outMute].forEach(n => { 55 | n.removeEventListener('change', this); 56 | }); 57 | } 58 | } 59 | ); 60 | -------------------------------------------------------------------------------- /demo/fonts/roboto-400.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-voip-alliance/WebphoneLib/dc245d302691e1173c67bc6466364d636ddb1c4f/demo/fonts/roboto-400.woff2 -------------------------------------------------------------------------------- /demo/fonts/roboto-700.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-voip-alliance/WebphoneLib/dc245d302691e1173c67bc6466364d636ddb1c4f/demo/fonts/roboto-700.woff2 -------------------------------------------------------------------------------- /demo/fonts/roboto-900.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-voip-alliance/WebphoneLib/dc245d302691e1173c67bc6466364d636ddb1c4f/demo/fonts/roboto-900.woff2 -------------------------------------------------------------------------------- /demo/icons/caret-down.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /demo/icons/ova_designelement.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-voip-alliance/WebphoneLib/dc245d302691e1173c67bc6466364d636ddb1c4f/demo/icons/ova_designelement.png -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | SipLib test 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 |
13 | 14 | 15 | 57 | 58 | 66 | 67 | 73 | 74 | 85 | 86 | 94 | 95 | 106 | 107 | 133 | 134 | 156 | 157 | 173 | 174 | 193 | 194 | 209 | 210 | 224 | 225 | 231 | 232 | 271 | 272 | 283 | 284 | 293 | 294 | 315 | 316 | -------------------------------------------------------------------------------- /demo/index.mjs: -------------------------------------------------------------------------------- 1 | import './pages/p-demo.mjs'; 2 | -------------------------------------------------------------------------------- /demo/lib/calling.mjs: -------------------------------------------------------------------------------- 1 | import { Client, log } from '../../dist/index.mjs'; 2 | import eventTarget from '../utils/eventTarget.mjs'; 3 | import { Logger } from './logging.mjs'; 4 | import { media } from './media.mjs'; 5 | 6 | const logger = new Logger('calling'); 7 | log.level = 'info'; 8 | log.connector = ({ level, context, message }) => { 9 | const print = { 10 | debug: console.debug, 11 | verbose: console.debug, 12 | info: console.info, 13 | warn: console.warn, 14 | error: console.error 15 | }[level]; 16 | 17 | print(`${level} [${context}] ${message}`); 18 | }; 19 | 20 | let account; 21 | let transport; 22 | 23 | export let client; 24 | 25 | function onBeforeInvite(invitation) { 26 | if (localStorage.getItem('dndEnabled') === 'true') { 27 | // Send the 486 'Busy here' status instead of the default 480 'Unavailable'. 28 | invitation.reject({ statusCode: 486 }); 29 | // Prevents onInvite to progress any further. 30 | return true; 31 | } 32 | 33 | // Nothing to see here, move along. 34 | return false; 35 | } 36 | 37 | export function setTransport(websocketUrl) { 38 | transport = { 39 | wsServers: websocketUrl, 40 | iceServers: [], 41 | delegate: { 42 | onBeforeInvite 43 | } 44 | }; 45 | } 46 | 47 | export function setAccount(user, password, realm) { 48 | const uri = `sip:${user}@${realm}`; 49 | account = { 50 | user, 51 | password, 52 | uri, 53 | name: 'test' 54 | }; 55 | } 56 | 57 | export function setClient() { 58 | client = new Client({ 59 | account, 60 | transport, 61 | media, 62 | userAgentString: 'WebphoneLib Demo' 63 | }); 64 | } 65 | 66 | export const callingEvents = eventTarget(); 67 | 68 | function onClientStatusUpdate(status) { 69 | logger.info(`Account status changed to ${status}`); 70 | callingEvents.dispatchEvent(new CustomEvent('clientStatusUpdate', { detail: { status } })); 71 | } 72 | 73 | export const subscriptions = {}; 74 | function onSubscriptionStatusUpdate(uri, status) { 75 | logger.debug(`onSubscriptionStatusUpdate: ${uri} - ${status}`); 76 | 77 | // Removing whitespaces from uri. 78 | const cleanedUri = uri.replace(/\s/g, ''); 79 | subscriptions[cleanedUri] = status; 80 | subscriptionEvents.dispatchEvent(new CustomEvent(`notify-${cleanedUri}`, { detail: { status } })); 81 | } 82 | 83 | function onSessionsUpdated() { 84 | callingEvents.dispatchEvent(new CustomEvent('sessionsUpdated')); 85 | } 86 | 87 | function onInvite() { 88 | callingEvents.dispatchEvent(new CustomEvent('sessionsUpdated')); 89 | } 90 | 91 | export const subscriptionEvents = eventTarget(); 92 | 93 | export function registerAccount() { 94 | logger.info('Trying to register account.'); 95 | client.on('statusUpdate', onClientStatusUpdate); 96 | client.on('subscriptionNotify', onSubscriptionStatusUpdate); 97 | client.on('sessionAdded', onSessionsUpdated); 98 | client.on('sessionRemoved', onSessionsUpdated); 99 | client.on('invite', onInvite); 100 | client 101 | .connect() 102 | .then(async () => { 103 | console.log('connected!'); 104 | }) 105 | .catch(e => { 106 | logger.error(e); 107 | console.error(e); 108 | }); 109 | } 110 | 111 | export function unregisterAccount() { 112 | logger.info('Trying to unregister account.'); 113 | 114 | try { 115 | client.disconnect(); 116 | } catch (e) { 117 | logger.error(e); 118 | console.log(e); 119 | } 120 | } 121 | 122 | export function reconfigure() { 123 | logger.info('Trying to reconfigure account.'); 124 | try { 125 | client.reconfigure({ account, transport }); 126 | } catch (e) { 127 | logger.error(e); 128 | console.log(e); 129 | } 130 | } 131 | 132 | export async function subscribe(uri) { 133 | try { 134 | return client.subscribe(uri); 135 | } catch (e) { 136 | logger.error(e); 137 | } 138 | 139 | subscriptions[uri] = 'unknown'; 140 | } 141 | 142 | export function unsubscribe(uri) { 143 | subscriptionEvents.dispatchEvent(new CustomEvent(`remove-${uri}`)); 144 | delete subscriptions[uri]; 145 | return client.unsubscribe(uri); 146 | } 147 | 148 | // Placeholder until web-calling lib has implemented separate rejected promise 149 | export function sessionAccepted(session) { 150 | return new Promise((resolve, reject) => { 151 | session 152 | .accepted() 153 | .then(({ accepted }) => { 154 | if (accepted) { 155 | callingEvents.dispatchEvent(new CustomEvent('sessionAccepted', { detail: { session } })); 156 | resolve(); 157 | } 158 | }) 159 | .catch(() => reject()); 160 | }); 161 | } 162 | 163 | // Placeholder until web-calling lib has implemented separate rejected promise 164 | export function sessionRejected(session) { 165 | return new Promise((resolve, reject) => { 166 | session 167 | .accepted() 168 | .then(({ accepted, rejectCause }) => { 169 | if (!accepted) { 170 | resolve({ rejectCause }); 171 | } 172 | }) 173 | .catch(() => reject()); 174 | }); 175 | } 176 | 177 | export async function invite(phoneNumber) { 178 | try { 179 | const session = await client.invite(`sip:${phoneNumber}@voipgrid.nl`).catch(logger.error); 180 | 181 | if (!session) { 182 | return; 183 | } 184 | 185 | logger.info('placed an outgoing call'); 186 | 187 | sessionAccepted(session).then(() => { 188 | logger.info('outgoing session is accepted', session.id); 189 | 190 | callingEvents.dispatchEvent(new CustomEvent('sessionUpdate', { detail: session })); 191 | }); 192 | 193 | sessionRejected(session).then(({ rejectCause }) => { 194 | logger.info('outgoing session was rejected', session.id, 'because', rejectCause); 195 | callingEvents.dispatchEvent(new CustomEvent('sessionUpdate', { detail: session })); 196 | }); 197 | 198 | session.terminated().finally(() => { 199 | logger.info('outgoing call was terminated', session.id); 200 | callingEvents.dispatchEvent(new CustomEvent('sessionUpdate', { detail: session })); 201 | }); 202 | 203 | session.on('callQualityUpdate', (sessionId, stats) => { 204 | const { mos } = stats; 205 | callingEvents.dispatchEvent(new CustomEvent('changeMosValues', { detail: mos })); 206 | }); 207 | 208 | return session; 209 | } catch (e) { 210 | logger.error(e); 211 | } 212 | } 213 | 214 | export async function attendedTransfer(session, toSession) { 215 | return client.attendedTransfer(session, toSession); 216 | } 217 | 218 | export async function blindTransfer(session, phoneNumber) { 219 | return session.blindTransfer(`sip:${phoneNumber}@voipgrid.nl`); 220 | } 221 | 222 | export function getSessions() { 223 | if (client && client.isConnected()) { 224 | return client.getSessions(); 225 | } 226 | return []; 227 | } 228 | 229 | export function getSession(id) { 230 | if (client && !client.isConnected()) { 231 | return; 232 | } 233 | 234 | return client.getSession(id); 235 | } 236 | 237 | export function createPublisher(contact, options) { 238 | if (client && !client.isConnected()) { 239 | return; 240 | } 241 | 242 | return client.createPublisher(contact, options); 243 | } 244 | -------------------------------------------------------------------------------- /demo/lib/dom.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * empties a DOM node. 3 | * @param node - the node that needs to be emptied. 4 | */ 5 | export function empty(node) { 6 | while (node.firstChild) { 7 | node.removeChild(node.firstChild); 8 | } 9 | } 10 | 11 | export function getDocumentElement(elementName) { 12 | return document.querySelector(`[data-selector=${elementName}]`); 13 | } 14 | 15 | export function getFormValues(form) { 16 | return Array.from(form).reduce((prev, { name, value }) => { 17 | if (name) { 18 | return Object.assign(prev, { 19 | [name]: value 20 | }); 21 | } else { 22 | return prev; 23 | } 24 | }, {}); 25 | } 26 | -------------------------------------------------------------------------------- /demo/lib/logging.mjs: -------------------------------------------------------------------------------- 1 | import eventTarget from '../utils/eventTarget.mjs'; 2 | 3 | export const loggingEvents = eventTarget(); 4 | 5 | const LEVELS = { 6 | error: 4, 7 | warn: 3, 8 | info: 2, 9 | verbose: 1, 10 | debug: 0 11 | }; 12 | 13 | export let verbosity = 'info'; 14 | 15 | export class Logger { 16 | constructor(module) { 17 | this.module = module; 18 | 19 | // Define aliases for each log level on Logger. 20 | // eg. `Logger.info(...) = Logger.log('info', ...)` 21 | Object.keys(LEVELS).forEach(level => { 22 | this[level] = function() { 23 | this.log.call(this, level, ...arguments); 24 | }.bind(this); 25 | }); 26 | } 27 | 28 | log(level, message) { 29 | const verbosityIdx = LEVELS[verbosity] || 0; 30 | const levelIdx = LEVELS[level] || 0; 31 | const module = this.module; 32 | if (levelIdx >= verbosityIdx) { 33 | loggingEvents.dispatchEvent( 34 | new CustomEvent('log_to_console', { detail: { level, message, module } }) 35 | ); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /demo/lib/media.mjs: -------------------------------------------------------------------------------- 1 | import { Media, Sound } from '../../dist/index.mjs'; 2 | import { client, getSessions } from './calling.mjs'; 3 | import { getDocumentElement } from './dom.mjs'; 4 | import { Logger } from './logging.mjs'; 5 | 6 | const logger = new Logger('media'); 7 | 8 | export const media = { 9 | input: { 10 | id: undefined, 11 | audioProcessing: true, 12 | volume: 1.0, 13 | muted: false 14 | }, 15 | output: { 16 | id: undefined, 17 | volume: 1.0, 18 | muted: false 19 | } 20 | }; 21 | 22 | const sound = new Sound('/demo/sounds/dtmf-0.mp3', { volume: 1.0, overlap: true }); 23 | 24 | Media.requestPermission(); 25 | 26 | function getSelectedOption(select) { 27 | try { 28 | return select.options[select.selectedIndex]; 29 | } catch (e) { 30 | return undefined; 31 | } 32 | } 33 | 34 | function makeOptions(select, devices) { 35 | let selected = getSelectedOption(select); 36 | 37 | // Remove all options. 38 | select.options.length = 0; 39 | 40 | // Build a list of new options. 41 | devices 42 | .map(({ id, name }) => { 43 | const option = document.createElement('option'); 44 | option.value = id; 45 | option.text = name; 46 | return option; 47 | }) 48 | .forEach(opt => { 49 | if (selected && opt.value === selected.value) { 50 | opt.selected = true; 51 | selected = undefined; 52 | } 53 | select.add(opt); 54 | }); 55 | 56 | if (selected) { 57 | logger.error(`Selected device went away: ${selected.text}`); 58 | } 59 | } 60 | 61 | function updateDevicesLists(mediaDevices, list) { 62 | while (list.firstChild) list.removeChild(list.firstChild); 63 | 64 | mediaDevices 65 | .map(({ name }) => { 66 | const device = document.createElement('li'); 67 | device.textContent = name; 68 | return device; 69 | }) 70 | .forEach(listItem => { 71 | list.appendChild(listItem); 72 | }); 73 | } 74 | 75 | Media.on('permissionGranted', () => { 76 | console.log('Permission granted'); 77 | }); 78 | Media.on('permissionRevoked', () => console.log('Permission revoked')); 79 | 80 | export function setOndevicesChanged() { 81 | Media.on('devicesChanged', () => { 82 | logger.info('Devices changed'); 83 | setInputsAndOutputs(); 84 | 85 | updateDevicesLists(Media.inputs, getDocumentElement('inputDevicesList')); 86 | updateDevicesLists(Media.outputs, getDocumentElement('outputDevicesList')); 87 | }); 88 | } 89 | Media.init(); 90 | 91 | export function setInputsAndOutputs() { 92 | // call on functions for the missed devicesChanged event 93 | makeOptions(getDocumentElement('inputSelect'), Media.inputs); 94 | makeOptions(getDocumentElement('outputSelect'), Media.outputs); 95 | 96 | // for the first time set default media. 97 | changeInputSelect(getDocumentElement('inputSelect')); 98 | changeOutputSelect(getDocumentElement('outputSelect')); 99 | } 100 | 101 | export function changeInputSelect(_inputSelect) { 102 | const selected = getSelectedOption(_inputSelect); 103 | if (selected) { 104 | getSessions().forEach(session => { 105 | session.media.input.id = selected.value; 106 | }); 107 | 108 | client.defaultMedia.input.id = selected.value; 109 | } 110 | logger.info('Input select changed to: ' + selected.text); 111 | } 112 | 113 | export function changeOutputSelect(_outputSelect) { 114 | const selected = getSelectedOption(_outputSelect); 115 | if (selected) { 116 | getSessions().forEach(session => { 117 | session.media.output.id = selected.value; 118 | }); 119 | 120 | client.defaultMedia.output.id = selected.value; 121 | } 122 | logger.info('Output select changed to: ' + selected.text); 123 | } 124 | 125 | export function playSound() { 126 | sound.sinkId = getSelectedOption(document.querySelector('[data-selector="outputSelect"]')).value; 127 | sound.play(); 128 | } 129 | 130 | window.Media = Media; 131 | 132 | export function changeInputVolume(value) { 133 | const vol = value / 10; 134 | getSessions().forEach(session => { 135 | session.media.input.volume = vol; 136 | }); 137 | 138 | client.defaultMedia.input.volume = vol; 139 | 140 | logger.info('Input volume changed to: ' + value); 141 | } 142 | 143 | export function changeInputMuted(checked) { 144 | getSessions().forEach(session => { 145 | session.media.media.input.muted = checked; 146 | }); 147 | 148 | client.defaultMedia.input.muted = checked; 149 | 150 | logger.info('Input muted changed to: ' + checked); 151 | } 152 | 153 | export function changeOutputVolume(value) { 154 | const vol = value / 10; 155 | getSessions().forEach(session => { 156 | session.media.output.volume = vol; 157 | }); 158 | 159 | client.defaultMedia.output.volume = vol; 160 | 161 | logger.info('Output volume changed to: ' + value); 162 | } 163 | 164 | export function changeOutputMuted(checked) { 165 | getSessions().forEach(session => { 166 | session.media.output.muted = checked; 167 | }); 168 | 169 | client.defaultMedia.output.muted = checked; 170 | 171 | logger.info('Output muted changed to: ' + checked); 172 | } 173 | -------------------------------------------------------------------------------- /demo/pages/p-demo.mjs: -------------------------------------------------------------------------------- 1 | import '../components/c-voip-account.mjs'; 2 | import '../components/c-contacts.mjs'; 3 | import '../components/c-contact.mjs'; 4 | import '../components/c-debug-console.mjs'; 5 | import '../components/c-computer-settings.mjs'; 6 | import '../components/c-dialer.mjs'; 7 | import '../components/c-publisher.mjs'; 8 | import '../components/c-sessions.mjs'; 9 | import '../components/c-mos-values.mjs'; 10 | import '../components/c-audio-visualiser.mjs'; 11 | 12 | window.customElements.define( 13 | 'p-demo', 14 | class extends HTMLElement { 15 | connectedCallback() { 16 | const template = document.querySelector('[data-component=p-demo]'); 17 | this.appendChild(template.content.cloneNode(true)); 18 | } 19 | } 20 | ); 21 | -------------------------------------------------------------------------------- /demo/sounds/dtmf-0.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-voip-alliance/WebphoneLib/dc245d302691e1173c67bc6466364d636ddb1c4f/demo/sounds/dtmf-0.mp3 -------------------------------------------------------------------------------- /demo/sounds/dtmf-1.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-voip-alliance/WebphoneLib/dc245d302691e1173c67bc6466364d636ddb1c4f/demo/sounds/dtmf-1.mp3 -------------------------------------------------------------------------------- /demo/sounds/dtmf-2.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-voip-alliance/WebphoneLib/dc245d302691e1173c67bc6466364d636ddb1c4f/demo/sounds/dtmf-2.mp3 -------------------------------------------------------------------------------- /demo/sounds/dtmf-3.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-voip-alliance/WebphoneLib/dc245d302691e1173c67bc6466364d636ddb1c4f/demo/sounds/dtmf-3.mp3 -------------------------------------------------------------------------------- /demo/sounds/dtmf-4.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-voip-alliance/WebphoneLib/dc245d302691e1173c67bc6466364d636ddb1c4f/demo/sounds/dtmf-4.mp3 -------------------------------------------------------------------------------- /demo/sounds/dtmf-5.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-voip-alliance/WebphoneLib/dc245d302691e1173c67bc6466364d636ddb1c4f/demo/sounds/dtmf-5.mp3 -------------------------------------------------------------------------------- /demo/sounds/dtmf-6.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-voip-alliance/WebphoneLib/dc245d302691e1173c67bc6466364d636ddb1c4f/demo/sounds/dtmf-6.mp3 -------------------------------------------------------------------------------- /demo/sounds/dtmf-7.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-voip-alliance/WebphoneLib/dc245d302691e1173c67bc6466364d636ddb1c4f/demo/sounds/dtmf-7.mp3 -------------------------------------------------------------------------------- /demo/sounds/dtmf-8.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-voip-alliance/WebphoneLib/dc245d302691e1173c67bc6466364d636ddb1c4f/demo/sounds/dtmf-8.mp3 -------------------------------------------------------------------------------- /demo/sounds/dtmf-9.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-voip-alliance/WebphoneLib/dc245d302691e1173c67bc6466364d636ddb1c4f/demo/sounds/dtmf-9.mp3 -------------------------------------------------------------------------------- /demo/sounds/dtmf-hash.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-voip-alliance/WebphoneLib/dc245d302691e1173c67bc6466364d636ddb1c4f/demo/sounds/dtmf-hash.mp3 -------------------------------------------------------------------------------- /demo/sounds/dtmf-star.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-voip-alliance/WebphoneLib/dc245d302691e1173c67bc6466364d636ddb1c4f/demo/sounds/dtmf-star.mp3 -------------------------------------------------------------------------------- /demo/sounds/random.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-voip-alliance/WebphoneLib/dc245d302691e1173c67bc6466364d636ddb1c4f/demo/sounds/random.mp3 -------------------------------------------------------------------------------- /demo/styles/main.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Roboto'; 3 | font-style: normal; 4 | font-weight: 400; 5 | font-display: swap; 6 | src: local('Roboto'), local('Roboto-Regular'), url('../fonts/roboto-400.woff2') format('woff2'); 7 | } 8 | 9 | @font-face { 10 | font-family: 'Roboto'; 11 | font-style: normal; 12 | font-weight: 700; 13 | font-display: swap; 14 | src: local('Roboto Bold'), local('Roboto-Bold'), url('../fonts/roboto-700.woff2') format('woff2'); 15 | } 16 | 17 | @font-face { 18 | font-family: 'Roboto'; 19 | font-style: normal; 20 | font-weight: 900; 21 | font-display: swap; 22 | src: local('Roboto Black'), local('Roboto-Black'), 23 | url('../fonts/roboto-900.woff2') format('woff2'); 24 | } 25 | 26 | body { 27 | background-color: #e6e8eb; 28 | font-family: 'Roboto', sans-serif; 29 | margin: 0.5rem; 30 | } 31 | 32 | section { 33 | background-color: #f5f7fa; 34 | border-radius: 0.3rem; 35 | padding: 1.5rem 2rem; 36 | margin: 0.5rem; 37 | } 38 | 39 | select { 40 | -webkit-appearance: none; 41 | -moz-appearance: none; 42 | appearance: none; 43 | min-width: 15rem; 44 | max-width: 20rem; 45 | font-size: 1rem; 46 | font-family: Roboto, sans-serif; 47 | color: #555; 48 | background: #fff url(../icons/caret-down.svg) right 0.5rem center no-repeat; 49 | background-size: 15px 9px; 50 | border: 1px solid #e6e8eb; 51 | border-radius: 0.2rem; 52 | padding: 0.5rem 2rem 0.5rem 0.5rem; 53 | } 54 | 55 | input[type='text'], 56 | input[type='password'] { 57 | font-size: 1rem; 58 | font-family: Roboto, sans-serif; 59 | color: #555; 60 | background-color: #fff; 61 | border: 1px solid #e6e8eb; 62 | width: 50%; 63 | padding: 0.75rem; 64 | margin-bottom: 1rem; 65 | box-sizing: border-box; 66 | } 67 | 68 | c-dialer input[type='text'] { 69 | width: 100%; 70 | } 71 | 72 | h2 { 73 | margin: 0; 74 | margin-bottom: 1rem; 75 | border-bottom: 1px solid #e6e8eb; 76 | padding-bottom: 0.6rem; 77 | } 78 | 79 | button { 80 | display: -webkit-inline-box; 81 | display: inline-flex; 82 | -webkit-box-align: center; 83 | align-items: center; 84 | -webkit-box-pack: center; 85 | justify-content: center; 86 | font-size: 1rem; 87 | font-weight: 700; 88 | text-decoration: none; 89 | font-family: Roboto, sans-serif; 90 | padding: 0.75rem 1rem; 91 | background: none; 92 | border: none; 93 | border-radius: 0.3rem; 94 | transition: 0.3s; 95 | } 96 | 97 | button:hover { 98 | cursor: pointer; 99 | } 100 | 101 | button.base-button { 102 | color: #555; 103 | background: #fff; 104 | border-bottom: 2px solid #e6e8eb; 105 | border: solid #e6e8eb; 106 | border-width: 1px 1px 2px; 107 | } 108 | 109 | button.base-button:hover { 110 | background: #555; 111 | color: #fff; 112 | } 113 | 114 | button.session-button { 115 | color: #555; 116 | background: #fff; 117 | border-bottom: 2px solid #e6e8eb; 118 | border: solid #e6e8eb; 119 | border-width: 1px 1px 2px; 120 | } 121 | 122 | button.session-button:focus { 123 | background: #ffd0a3; 124 | color: #d45400; 125 | } 126 | 127 | .top-content { 128 | display: flex; 129 | } 130 | 131 | .left-part, 132 | .right-part { 133 | flex: auto; 134 | } 135 | 136 | .debug-console { 137 | height: 200px; 138 | overflow: scroll; 139 | margin: 0; 140 | } 141 | 142 | .status-pill { 143 | padding: 0.5rem 1rem; 144 | height: 27px; 145 | display: flex; 146 | align-items: center; 147 | font-size: 1rem; 148 | border-radius: 2rem; 149 | width: 10rem; 150 | margin-top: 1rem; 151 | } 152 | 153 | .status-pill.disconnected { 154 | background-color: #e0e0e0; 155 | color: #666; 156 | } 157 | 158 | .checkbox-container { 159 | display: block; 160 | position: relative; 161 | margin-bottom: 1.5rem; 162 | } 163 | 164 | .checkbox-container > input[type='checkbox'] { 165 | vertical-align: top; 166 | } 167 | 168 | .checkbox-container > input[type='checkbox'] + span { 169 | margin-left: 0.2rem; 170 | margin-bottom: 0; 171 | display: inline-block; 172 | font-weight: 300; 173 | } 174 | 175 | .session { 176 | background-color: #ffffff; 177 | padding: 0.5rem; 178 | } 179 | 180 | .session .session-information-header { 181 | font-weight: 700; 182 | padding-bottom: 0.2rem; 183 | } 184 | 185 | .session .session-button.accept { 186 | color: #046614; 187 | background-color: #acf5a6; 188 | } 189 | 190 | .session .session-button.reject, 191 | .session .session-button.hangup { 192 | color: #8f0a06; 193 | background-color: #ffadad; 194 | } 195 | 196 | .session .session-status { 197 | border-radius: 0.5em; 198 | background-color: #e0e0e0; 199 | font-weight: 500; 200 | padding: 0.5em; 201 | margin: 0.2rem 0; 202 | display: inline-block; 203 | } 204 | 205 | c-volume-setting label, 206 | c-devices-setting label, 207 | c-debug-console label { 208 | display: block; 209 | position: relative; 210 | font-weight: 700; 211 | margin-bottom: 0.5rem; 212 | } 213 | 214 | c-mos-values section span { 215 | background-color: #555; 216 | border-radius: 0.5rem; 217 | padding: 0.5em; 218 | margin: 0.2rem 0; 219 | color: #ffffff; 220 | display: inline-block; 221 | } 222 | 223 | .volume-container { 224 | display: flex; 225 | margin-bottom: 0.5rem; 226 | max-width: 15rem; 227 | } 228 | 229 | .volume-container label { 230 | margin: 0; 231 | flex: 1; 232 | } 233 | 234 | .volume-container input { 235 | flex: 1; 236 | } 237 | 238 | .devices-container { 239 | display: flex; 240 | } 241 | 242 | .devices-container .content { 243 | flex: 1; 244 | } 245 | 246 | c-volume-setting { 247 | display: flex; 248 | } 249 | 250 | c-volume-setting > div { 251 | flex: 1; 252 | } 253 | 254 | c-volume-setting [type='range'] { 255 | width: 100%; 256 | } 257 | 258 | c-devices-setting select { 259 | max-width: 17rem; 260 | } 261 | 262 | c-devices-setting .device-select-container { 263 | display: -webkit-box; 264 | display: flex; 265 | } 266 | 267 | c-devices-setting button { 268 | font-size: 0.9rem; 269 | padding: 0.5rem 0.8rem; 270 | margin-left: 1rem; 271 | } 272 | 273 | c-devices-setting .content { 274 | margin-bottom: 1.5rem; 275 | } 276 | 277 | .devices-list { 278 | display: flex; 279 | } 280 | 281 | .devices-list div { 282 | flex: 1; 283 | } 284 | 285 | .devices-list ul { 286 | list-style: none; 287 | padding: 0; 288 | } 289 | 290 | .devices-list ul li { 291 | padding-bottom: 0.3rem; 292 | } 293 | 294 | .devices-list ul li::before { 295 | content: '-'; 296 | padding-right: 0.5rem; 297 | } 298 | 299 | c-console-message div { 300 | margin-top: 0.5rem; 301 | } 302 | 303 | c-console-message span.source-label { 304 | border-radius: 0.5em; 305 | color: white; 306 | font-weight: bold; 307 | padding: 2px 0.5em; 308 | } 309 | 310 | c-console-message span.message-box { 311 | margin-top: 0.5rem; 312 | } 313 | 314 | .dtmf-keypad { 315 | display: grid; 316 | grid-template-columns: auto auto auto; 317 | grid-gap: 1rem; 318 | margin: 1rem; 319 | max-width: 12rem; 320 | } 321 | 322 | .dtmf-keypad > button { 323 | font-size: 1.25rem; 324 | font-weight: 900; 325 | width: 3rem; 326 | height: 3rem; 327 | padding: 0.5rem 1rem; 328 | -webkit-user-select: none; 329 | -moz-user-select: none; 330 | -ms-user-select: none; 331 | user-select: none; 332 | } 333 | 334 | c-dialer .dialer-body { 335 | display: -webkit-box; 336 | display: flex; 337 | -webkit-box-align: center; 338 | align-items: center; 339 | -webkit-box-pack: center; 340 | justify-content: center; 341 | -webkit-box-orient: vertical; 342 | -webkit-box-direction: normal; 343 | flex-direction: column; 344 | } 345 | 346 | c-dialer .dialer-buttons { 347 | width: 11rem; 348 | display: -webkit-box; 349 | display: flex; 350 | } 351 | 352 | c-dialer .dialer-call-button { 353 | -webkit-box-flex: 2; 354 | flex: 2; 355 | background-color: #acf5a6; 356 | color: #046614; 357 | } 358 | 359 | c-voip-account .dnd-toggle { 360 | display: flex; 361 | align-items: center; 362 | margin-top: 1rem; 363 | } 364 | 365 | c-voip-account .dnd-toggle > span { 366 | margin-right: 0.5rem; 367 | } 368 | 369 | /* The switch - the box around the slider */ 370 | .switch { 371 | position: relative; 372 | display: inline-block; 373 | width: 60px; 374 | height: 34px; 375 | } 376 | 377 | /* Hide default HTML checkbox */ 378 | .switch input { 379 | opacity: 0; 380 | width: 0; 381 | height: 0; 382 | } 383 | 384 | /* The slider */ 385 | .slider { 386 | position: absolute; 387 | cursor: pointer; 388 | top: 0; 389 | left: 0; 390 | right: 0; 391 | bottom: 0; 392 | background-color: #ccc; 393 | -webkit-transition: 0.4s; 394 | transition: 0.4s; 395 | } 396 | 397 | .slider:before { 398 | position: absolute; 399 | content: ''; 400 | height: 26px; 401 | width: 26px; 402 | left: 4px; 403 | bottom: 4px; 404 | background-color: white; 405 | -webkit-transition: 0.4s; 406 | transition: 0.4s; 407 | } 408 | 409 | input:checked + .slider { 410 | background-color: #d45400; 411 | } 412 | 413 | input:focus + .slider { 414 | box-shadow: 0 0 1px #d45400; 415 | } 416 | 417 | input:checked + .slider:before { 418 | -webkit-transform: translateX(26px); 419 | -ms-transform: translateX(26px); 420 | transform: translateX(26px); 421 | } 422 | 423 | /* Rounded sliders */ 424 | .slider.round { 425 | border-radius: 34px; 426 | } 427 | 428 | .slider.round:before { 429 | border-radius: 50%; 430 | } 431 | -------------------------------------------------------------------------------- /demo/time.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Sleep for a number of milliseconds. This will not block the thread, but 3 | * instead it returns a `Promise` which will resolve after `ms` 4 | * milliseconds. 5 | * 6 | * @param {Number} ms - Number of milliseconds to sleep. 7 | * @returns {Promise} - which resolves after `ms` milliseconds. 8 | */ 9 | export function sleep(ms) { 10 | return new Promise(resolve => setTimeout(resolve, ms)); 11 | } 12 | -------------------------------------------------------------------------------- /demo/utils/elementProxies.mjs: -------------------------------------------------------------------------------- 1 | function createProxyConstructor(type) { 2 | return function(context) { 3 | return new Proxy(new Map(), { 4 | get(obj, property) { 5 | if (!obj.has(property)) { 6 | obj.set(property, context.querySelector(`[data-${type}=${property}]`)); 7 | } 8 | return obj.get(property); 9 | }, 10 | set(obj, property, value) { 11 | if (typeof value === 'string') { 12 | obj.set(property, context.querySelector(`[data-${type}=${property}]`)); 13 | } else if (value.nodeType) { 14 | obj.set(property, value); 15 | } 16 | }, 17 | deleteProperty(obj, property) { 18 | return obj.delete(property); 19 | } 20 | }); 21 | }; 22 | } 23 | 24 | export const NodesProxy = createProxyConstructor('selector'); 25 | export const ActionsProxy = createProxyConstructor('action'); 26 | -------------------------------------------------------------------------------- /demo/utils/eventTarget.mjs: -------------------------------------------------------------------------------- 1 | /* 2 | This module is to be able to create a cross browser way to use custom events 3 | For Chrom(ium) and Firefox executing `new EventTarget();` does not throw. 4 | In Safari it does so we create an (arbitrarily chosen type of) element to use as an eventTarget 5 | */ 6 | export default function eventTarget() { 7 | try { 8 | return new EventTarget(); 9 | } catch (err) { 10 | return document.createElement('i'); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /demo/utils/styleConsoleMessage.mjs: -------------------------------------------------------------------------------- 1 | export default function styleConsoleMessage(level) { 2 | const colors = { 3 | error: `#8f0a06`, // Red 4 | warn: `#ff7b24`, // Orange 5 | info: `#0051d4`, // Blue 6 | debug: `#666666`, // Gray 7 | verbose: `#046614` // Green 8 | }; 9 | 10 | // from Workbox 11 | const styles = [`background: ${colors[level]}`]; 12 | return styles; 13 | } 14 | -------------------------------------------------------------------------------- /docker-compose.override.yml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | services: 3 | e2e: 4 | volumes: 5 | - ./puppeteer:/home/pptruser 6 | - /tmp/.X11-unix:/tmp/.X11-unix 7 | - /home/pptruser/node_modules 8 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | services: 3 | web: 4 | build: . 5 | container_name: demo-page 6 | ports: 7 | - '1235:1235' 8 | command: npm run demo 9 | e2e: 10 | build: ./puppeteer 11 | container_name: puppeteer 12 | network_mode: 'host' 13 | depends_on: 14 | - web 15 | environment: 16 | - DISPLAY=$unix$DISPLAY 17 | - DEMO_URL=http://localhost:1235/demo 18 | - USER_A 19 | - USER_B 20 | - USER_C 21 | - PASSWORD_A 22 | - PASSWORD_B 23 | - PASSWORD_C 24 | - NUMBER_A 25 | - NUMBER_B 26 | - NUMBER_C 27 | - WEBSOCKET_URL 28 | - REALM 29 | command: sh -c "sleep 10 && npm run test:e2e" 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webphone-lib", 3 | "version": "0.2.23", 4 | "description": "Webphone Lib", 5 | "author": "Open VoIP Alliance", 6 | "license": "MIT", 7 | "main": "dist/index.mjs", 8 | "types": "dist/index.d.ts", 9 | "scripts": { 10 | "clean": "rm -fR dist/* build/*", 11 | "build": "npm run clean && rollup -c", 12 | "build-watch": "npm run clean && rollup -cw", 13 | "serve": "serve -l 1235", 14 | "demo": "run-p build-watch serve", 15 | "typecheck": "tsc", 16 | "lint": "eslint \"src/**/*.ts\"", 17 | "prepare": "npm run lint && npm run build", 18 | "test": "ava", 19 | "test-coverage": "nyc ava", 20 | "test-watch": "ava --watch", 21 | "docs": "typedoc --out docs src", 22 | "run-e2e-tests-in-docker": "docker-compose up --exit-code-from" 23 | }, 24 | "husky": { 25 | "hooks": { 26 | "pre-commit": "pretty-quick --staged" 27 | } 28 | }, 29 | "ava": { 30 | "require": [ 31 | "esm", 32 | "ts-node/register", 33 | "./test/_setup-browser-env.js" 34 | ], 35 | "extensions": [ 36 | "ts" 37 | ], 38 | "babel": false, 39 | "compileEnhancements": false 40 | }, 41 | "nyc": { 42 | "require": [ 43 | "ts-node/register" 44 | ], 45 | "extends": "@istanbuljs/nyc-config-typescript", 46 | "all": true, 47 | "check-coverage": true, 48 | "include": [ 49 | "src/**/*.ts" 50 | ] 51 | }, 52 | "repository": { 53 | "type": "git", 54 | "url": "git@github.com:open-voip-alliance/WebphoneLib.git" 55 | }, 56 | "files": [ 57 | "src", 58 | "dist", 59 | "test", 60 | "README.md" 61 | ], 62 | "dependencies": { 63 | "p-retry": "^4.1.0", 64 | "p-timeout": "^3.2.0", 65 | "sip.js": "0.15.6" 66 | }, 67 | "devDependencies": { 68 | "@istanbuljs/nyc-config-typescript": "^0.1.3", 69 | "@types/events": "^3.0.0", 70 | "@types/sinon": "^7.5.0", 71 | "@typescript-eslint/eslint-plugin": "^3.1.0", 72 | "@typescript-eslint/parser": "^2.7.0", 73 | "ava": "^2.4.0", 74 | "ava-fast-check": "^1.1.2", 75 | "base": "^0.13.2", 76 | "browser-env": "^3.2.6", 77 | "dotenv": "^8.2.0", 78 | "eslint": "^6.5.1", 79 | "eslint-config-prettier": "^6.4.0", 80 | "eslint-plugin-prettier": "^3.1.1", 81 | "fast-check": "^1.17.0", 82 | "husky": "^3.0.8", 83 | "npm-run-all": "^4.1.5", 84 | "nyc": "^14.1.1", 85 | "prettier": "^1.18.2", 86 | "pretty-quick": "^1.11.1", 87 | "rollup": "^1.23.1", 88 | "rollup-plugin-commonjs": "^10.1.0", 89 | "rollup-plugin-json": "^4.0.0", 90 | "rollup-plugin-node-builtins": "^2.1.2", 91 | "rollup-plugin-node-resolve": "^5.2.0", 92 | "rollup-plugin-typescript2": "^0.24.3", 93 | "serve": "^11.3.2", 94 | "sinon": "^7.5.0", 95 | "ts-node": "^8.4.1", 96 | "typedoc": "^0.15.0", 97 | "typescript": "^3.6.3" 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /puppeteer/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /puppeteer/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true, 4 | "es6": true 5 | }, 6 | "extends": ["eslint:recommended", "plugin:prettier/recommended"], 7 | "plugins": ["prettier"], 8 | "parser": "espree", 9 | "parserOptions": { 10 | "ecmaVersion": 9, 11 | "sourceType": "module" 12 | }, 13 | "rules": { 14 | "curly": ["error", "all"], 15 | "brace-style": ["error", "1tbs"], 16 | "indent": ["warn", 2], 17 | "no-console": "off", 18 | "prettier/prettier": "error" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /puppeteer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM cypress/browsers:node14.7.0-chrome84 2 | # Secretly using cypress browser base bec we're too lazy to make it ourselves 3 | 4 | RUN groupadd -r pptruser && useradd -r -g pptruser -G audio,video pptruser \ 5 | && mkdir -p /home/pptruser/Downloads \ 6 | && chown -R pptruser:pptruser /home/pptruser 7 | 8 | COPY . /home/pptruser 9 | COPY package.json /home/pptruser/package.json 10 | COPY package-lock.json /home/pptruser/package-lock.json 11 | 12 | # Run everything after as non-privileged user. 13 | USER pptruser 14 | 15 | WORKDIR /home/pptruser 16 | 17 | RUN npm ci 18 | -------------------------------------------------------------------------------- /puppeteer/config.js: -------------------------------------------------------------------------------- 1 | const dotenv = require('dotenv'); 2 | 3 | dotenv.config(); 4 | 5 | module.exports = { 6 | USER_A: process.env.USER_A, 7 | USER_B: process.env.USER_B, 8 | USER_C: process.env.USER_C, 9 | PASSWORD_A: process.env.PASSWORD_A, 10 | PASSWORD_B: process.env.PASSWORD_B, 11 | PASSWORD_C: process.env.PASSWORD_C, 12 | NUMBER_A: process.env.NUMBER_A, 13 | NUMBER_B: process.env.NUMBER_B, 14 | NUMBER_C: process.env.NUMBER_C, 15 | WEBSOCKET_URL: process.env.WEBSOCKET_URL, 16 | REALM: process.env.REALM 17 | }; 18 | -------------------------------------------------------------------------------- /puppeteer/helpers/constants.js: -------------------------------------------------------------------------------- 1 | const dotenv = require('dotenv'); 2 | 3 | dotenv.config(); 4 | 5 | module.exports = { 6 | NON_EXISTING_NUMBER: '989', 7 | DEMO_URL: process.env.DEMO_URL, 8 | LAUNCH_OPTIONS: { 9 | args: [ 10 | '--no-sandbox', 11 | '--disable-setuid-sandbox', 12 | '--use-fake-device-for-media-stream', 13 | '--use-fake-ui-for-media-stream', 14 | '--start-maximized', 15 | '--unsafely-treat-insecure-origin-as-secure=http://web:1235' 16 | ], 17 | dumpio: false, 18 | ignoreHTTPSErrors: true, 19 | headless: true, 20 | devtools: false, 21 | slowMo: 10, 22 | timeout: 0, 23 | defaultViewport: null 24 | }, 25 | USER_ID_INPUT: 'c-voip-account [data-selector="userIdInput"]', 26 | USER_PASSWORD_INPUT: 'c-voip-account [data-selector="passwordInput"]', 27 | WEBSOCKET_URL_INPUT: 'c-voip-account [data-selector="websocketUrlInput"]', 28 | REALM_INPUT: 'c-voip-account [data-selector="realmInput"]', 29 | DIALER_INPUT: 'c-dialer [data-selector="input"]', 30 | DIALER_CALL_BUTTON: 'c-dialer [data-action="call"]', 31 | REGISTER_BUTTON: 'c-voip-account [data-action="register"]', 32 | SESSION_ACCEPT_BUTTON: 'c-session [data-action="accept"]', 33 | SESSION_UNHOLD_BUTTON: 'c-session [data-action="unhold"]', 34 | SESSION_REJECT_BUTTON: 'c-session [data-action="reject"]', 35 | SESSION_CANCEL_BUTTON: 'c-session [data-action="cancel"]', 36 | SESSION_HANGUP_BUTTON: 'c-session [data-action="hangup"]', 37 | SESSION_TRANSFER_BUTTON: 'c-session [data-action="toggleTransfer"]', 38 | SESSION_TRANSFER_METHOD_DROPDOWN: 'c-transfer [data-selector="selectTransferMethod"]', 39 | SESSION_COLD_TRANSFER_SELECT: 'blind', 40 | SESSION_WARM_TRANSFER_SELECT: 'attended', 41 | SESSION_TRANSFER_INPUT: 'c-transfer [data-selector="input"]', 42 | SESSION_COMPLETE_TRANSFER_BUTTON: 'c-transfer [data-action="transferCall"]', 43 | SESSION_STATUS: 'c-session [data-selector="sessionStatus"]', 44 | SESSIONS_LIST: '[data-selector="sessionsList"]', 45 | SESSIONS: 'c-session', 46 | CLIENT_STATUS: '[data-selector="clientStatus"]' 47 | }; 48 | -------------------------------------------------------------------------------- /puppeteer/helpers/utils.js: -------------------------------------------------------------------------------- 1 | const { errors } = require('puppeteer'); 2 | const { 3 | USER_ID_INPUT, 4 | USER_PASSWORD_INPUT, 5 | REALM_INPUT, 6 | WEBSOCKET_URL_INPUT, 7 | REGISTER_BUTTON, 8 | DIALER_INPUT, 9 | DIALER_CALL_BUTTON 10 | } = require('../helpers/constants'); 11 | const { REALM, WEBSOCKET_URL } = require('../config'); 12 | 13 | async function clearText(page, selector) { 14 | try { 15 | await page.waitForSelector(selector); 16 | await page.click(selector, { clickCount: 3 }); 17 | await page.press('Backspace'); 18 | } catch (error) {} 19 | } 20 | 21 | async function typeText(page, selector, text) { 22 | try { 23 | await page.waitForSelector(selector); 24 | await page.type(selector, text); 25 | } catch (error) { 26 | throw new Error(`Could not type into selector: ${selector}`); 27 | } 28 | } 29 | 30 | async function click(page, selector) { 31 | try { 32 | await page.waitForSelector(selector); 33 | await page.click(selector); 34 | } catch (error) { 35 | throw new Error(`Could not click on selector: ${selector}`); 36 | } 37 | } 38 | 39 | module.exports = { 40 | click, 41 | async getText(page, selector) { 42 | try { 43 | await page.waitForSelector(selector); 44 | return page.$$eval(selector, element => element.innerHTML); 45 | } catch (error) { 46 | throw new Error(`Could not get text from selector: ${selector}`); 47 | } 48 | }, 49 | typeText, 50 | async waitForText(page, selector, text) { 51 | let node; 52 | try { 53 | node = await page.waitForSelector(selector); 54 | } catch (err) { 55 | if (err instanceof errors.TimeoutError) { 56 | throw new Error(`Timeout waiting for selector: "${selector}"`); 57 | } 58 | throw err; 59 | } 60 | 61 | let isFound; 62 | try { 63 | isFound = await page.waitForFunction( 64 | (node, text) => { 65 | if (node && node.innerText.includes(text)) { 66 | return true; 67 | } 68 | return false; 69 | }, 70 | {}, 71 | node, 72 | text 73 | ); 74 | } catch (err) { 75 | if (err instanceof errors.TimeoutError) { 76 | throw new Error(`Timeout while retrying to find "${text}" in selector "${selector}"`); 77 | } 78 | throw err; 79 | } 80 | 81 | return isFound.jsonValue(); 82 | }, 83 | clearText, 84 | async registerUser(page, userAuthId, userPw) { 85 | await clearText(page, USER_ID_INPUT); 86 | await typeText(page, USER_ID_INPUT, userAuthId); 87 | 88 | await clearText(page, USER_PASSWORD_INPUT); 89 | await typeText(page, USER_PASSWORD_INPUT, userPw); 90 | 91 | await clearText(page, WEBSOCKET_URL_INPUT); 92 | await typeText(page, WEBSOCKET_URL_INPUT, WEBSOCKET_URL); 93 | 94 | await clearText(page, REALM_INPUT); 95 | await typeText(page, REALM_INPUT, REALM); 96 | 97 | await click(page, REGISTER_BUTTON); 98 | }, 99 | async callNumber(page, number) { 100 | await clearText(page, DIALER_INPUT); 101 | await typeText(page, DIALER_INPUT, number); 102 | await click(page, DIALER_CALL_BUTTON); 103 | } 104 | }; 105 | -------------------------------------------------------------------------------- /puppeteer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webphone-lib-e2e", 3 | "version": "0.0.1", 4 | "description": "Webphone Lib End to End", 5 | "author": "Open VoIP Alliance", 6 | "license": "MIT", 7 | "main": "dist/index.mjs", 8 | "scripts": { 9 | "test:e2e": "mocha --timeout=20000 ./test/*.js" 10 | }, 11 | "devDependencies": { 12 | "chai": "^4.3.6", 13 | "dotenv": "^16.0.2", 14 | "mocha": "^10.0.0", 15 | "puppeteer": "^18.0.4" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /puppeteer/test/audioSwitching-e2e.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-voip-alliance/WebphoneLib/dc245d302691e1173c67bc6466364d636ddb1c4f/puppeteer/test/audioSwitching-e2e.js -------------------------------------------------------------------------------- /puppeteer/test/callingOut-e2e.js: -------------------------------------------------------------------------------- 1 | const puppeteer = require('puppeteer'); 2 | const { expect } = require('chai'); 3 | const { describe, beforeEach, afterEach, it } = require('mocha'); 4 | 5 | const { callNumber, click, waitForText, registerUser } = require('../helpers/utils'); 6 | const { USER_A, USER_B, PASSWORD_A, PASSWORD_B, NUMBER_A, NUMBER_B } = require('../config'); 7 | const { 8 | NON_EXISTING_NUMBER, 9 | DEMO_URL, 10 | SESSIONS, 11 | SESSION_ACCEPT_BUTTON, 12 | SESSION_REJECT_BUTTON, 13 | SESSION_CANCEL_BUTTON, 14 | SESSION_HANGUP_BUTTON, 15 | SESSION_STATUS, 16 | CLIENT_STATUS, 17 | LAUNCH_OPTIONS 18 | } = require('../helpers/constants'); 19 | 20 | describe('Calling out', () => { 21 | let browser; 22 | let page; 23 | let page2; 24 | 25 | beforeEach(async function() { 26 | browser = await puppeteer.launch(LAUNCH_OPTIONS); 27 | 28 | page = await browser.newPage(); 29 | page.on('pageerror', function(err) { 30 | console.log(`Page error: ${err.toString()}`); 31 | }); 32 | 33 | page2 = await browser.newPage(); 34 | }); 35 | 36 | afterEach(async function() { 37 | await browser.close(); 38 | }); 39 | 40 | it('calling out & having the other party answer & let the other party end the call (terminate)', async function() { 41 | page.bringToFront(); 42 | await page.goto(DEMO_URL); 43 | 44 | const url = await page.url(); 45 | expect(url).to.include('/demo/'); 46 | 47 | await registerUser(page, USER_A, PASSWORD_A); 48 | expect(await waitForText(page, CLIENT_STATUS, 'connected')).to.be.true; 49 | 50 | page2.bringToFront(); 51 | await page2.goto(DEMO_URL); 52 | 53 | await registerUser(page2, USER_B, PASSWORD_B); 54 | expect(await waitForText(page2, CLIENT_STATUS, 'connected')).to.be.true; 55 | 56 | await callNumber(page2, NUMBER_A); 57 | 58 | page.bringToFront(); 59 | await click(page, SESSION_ACCEPT_BUTTON); 60 | expect(await waitForText(page, SESSION_STATUS, 'active')).to.be.true; 61 | 62 | await click(page, SESSION_HANGUP_BUTTON); 63 | }); 64 | 65 | it('calling out & having the other party answer & end the call yourself (terminate)', async function() { 66 | page.bringToFront(); 67 | await page.goto(DEMO_URL); 68 | 69 | const url = await page.url(); 70 | expect(url).to.include('/demo/'); 71 | 72 | // register on the first page 73 | await registerUser(page, USER_A, PASSWORD_A); 74 | expect(await waitForText(page, CLIENT_STATUS, 'connected')).to.be.true; 75 | 76 | // register on the second page 77 | page2.bringToFront(); 78 | await page2.goto(DEMO_URL); 79 | 80 | await registerUser(page2, USER_B, PASSWORD_B); 81 | expect(await waitForText(page, CLIENT_STATUS, 'connected')).to.be.true; 82 | 83 | // setup a call from page2 to the other one 84 | await callNumber(page2, NUMBER_A); 85 | 86 | // accept the call on the first page 87 | page.bringToFront(); 88 | await click(page, SESSION_ACCEPT_BUTTON); 89 | expect(await waitForText(page, SESSION_STATUS, 'active')).to.be.true; 90 | 91 | // end the call from the second page 92 | page2.bringToFront(); 93 | await click(page2, SESSION_HANGUP_BUTTON); 94 | }); 95 | 96 | it('calling out & ending the call before it is answered (cancel)', async function() { 97 | page.bringToFront(); 98 | await page.goto(DEMO_URL); 99 | 100 | const url = await page.url(); 101 | expect(url).to.include('/demo/'); 102 | 103 | // Register on the first page 104 | await registerUser(page, USER_A, PASSWORD_A); 105 | expect(await waitForText(page, CLIENT_STATUS, 'connected')).to.be.true; 106 | 107 | // Register on the second page 108 | page2.bringToFront(); 109 | await page2.goto(DEMO_URL); 110 | 111 | await registerUser(page2, USER_B, PASSWORD_B); 112 | expect(await waitForText(page, CLIENT_STATUS, 'connected')).to.be.true; 113 | 114 | // setup a call from the second page 115 | await callNumber(page2, NUMBER_A); 116 | 117 | // and end the call when we can 118 | await click(page2, SESSION_CANCEL_BUTTON); 119 | await page2.waitForTimeout(100); 120 | }); 121 | 122 | it('calling out while other party rejects the call', async function() { 123 | page.bringToFront(); 124 | await page.goto(DEMO_URL); 125 | 126 | const url = await page.url(); 127 | expect(url).to.include('/demo/'); 128 | 129 | // Register on the first page 130 | await registerUser(page, USER_A, PASSWORD_A); 131 | expect(await waitForText(page, CLIENT_STATUS, 'connected')).to.be.true; 132 | 133 | // Register on the second page 134 | page2.bringToFront(); 135 | await page2.goto(DEMO_URL); 136 | 137 | await registerUser(page2, USER_B, PASSWORD_B); 138 | expect(await waitForText(page, CLIENT_STATUS, 'connected')).to.be.true; 139 | 140 | // setup a call from the second page 141 | await callNumber(page2, NUMBER_A); 142 | 143 | // Reject the call from the first page 144 | page.bringToFront(); 145 | await click(page, SESSION_REJECT_BUTTON); 146 | }); 147 | 148 | it('calling out while other party is not available', async function() { 149 | page.bringToFront(); 150 | await page.goto(DEMO_URL); 151 | 152 | const url = await page.url(); 153 | expect(url).to.include('/demo/'); 154 | 155 | // Register on the first page 156 | await registerUser(page, USER_A, PASSWORD_A); 157 | expect(await waitForText(page, CLIENT_STATUS, 'connected')).to.be.true; 158 | 159 | // setup a call to the non-logged in account 160 | await callNumber(page, NUMBER_B); 161 | 162 | await page.waitForTimeout(500); 163 | expect(await page.$$(SESSIONS)).to.have.length(0); 164 | }); 165 | 166 | it('calling out while other party does not exist', async function() { 167 | page.bringToFront(); 168 | await page.goto(DEMO_URL); 169 | 170 | const url = await page.url(); 171 | expect(url).to.include('/demo/'); 172 | 173 | // Register on the first page 174 | await registerUser(page, USER_A, PASSWORD_A); 175 | expect(await waitForText(page, CLIENT_STATUS, 'connected')).to.be.true; 176 | 177 | // Setup a call to an internal number we know does not exist 178 | await callNumber(page, NON_EXISTING_NUMBER); 179 | 180 | await page.waitForTimeout(5000); 181 | expect(await page.$$(SESSIONS)).to.have.length(0); 182 | }); 183 | }); 184 | -------------------------------------------------------------------------------- /puppeteer/test/coldTransfer-e2e.js: -------------------------------------------------------------------------------- 1 | const puppeteer = require('puppeteer'); 2 | const { expect } = require('chai'); 3 | const { describe, beforeEach, afterEach, it } = require('mocha'); 4 | 5 | const { 6 | callNumber, 7 | click, 8 | typeText, 9 | clearText, 10 | waitForText, 11 | registerUser 12 | } = require('../helpers/utils'); 13 | const { 14 | USER_A, 15 | USER_B, 16 | USER_C, 17 | PASSWORD_A, 18 | PASSWORD_B, 19 | PASSWORD_C, 20 | NUMBER_B, 21 | NUMBER_C 22 | } = require('../config'); 23 | const { 24 | NON_EXISTING_NUMBER, 25 | DEMO_URL, 26 | SESSIONS, 27 | SESSION_ACCEPT_BUTTON, 28 | SESSION_REJECT_BUTTON, 29 | SESSION_HANGUP_BUTTON, 30 | SESSION_STATUS, 31 | SESSION_TRANSFER_BUTTON, 32 | SESSION_TRANSFER_METHOD_DROPDOWN, 33 | SESSION_COLD_TRANSFER_SELECT, 34 | SESSION_TRANSFER_INPUT, 35 | SESSION_COMPLETE_TRANSFER_BUTTON, 36 | CLIENT_STATUS, 37 | LAUNCH_OPTIONS 38 | } = require('../helpers/constants'); 39 | 40 | describe('Cold Transfer', () => { 41 | let browser; 42 | let page; 43 | let page2; 44 | let page3; 45 | 46 | beforeEach(async function() { 47 | browser = await puppeteer.launch(LAUNCH_OPTIONS); 48 | 49 | page = await browser.newPage(); 50 | page.on('pageerror', function(err) { 51 | console.log(`Page error: ${err.toString()}`); 52 | }); 53 | 54 | page2 = await browser.newPage(); 55 | page3 = await browser.newPage(); 56 | }); 57 | 58 | afterEach(async function() { 59 | await browser.close(); 60 | }); 61 | 62 | it('Have user A call user B and transfer user B to user C via a cold transfer in User A', async function() { 63 | page2.bringToFront(); 64 | await page2.goto(DEMO_URL); 65 | 66 | await registerUser(page2, USER_B, PASSWORD_B); 67 | expect(await waitForText(page2, CLIENT_STATUS, 'connected')).to.be.true; 68 | 69 | page3.bringToFront(); 70 | await page3.goto(DEMO_URL); 71 | 72 | await registerUser(page3, USER_C, PASSWORD_C); 73 | expect(await waitForText(page3, CLIENT_STATUS, 'connected')).to.be.true; 74 | 75 | page.bringToFront(); 76 | await page.goto(DEMO_URL); 77 | 78 | const url = await page.url(); 79 | expect(url).to.include('/demo/'); 80 | 81 | await registerUser(page, USER_A, PASSWORD_A); 82 | expect(await waitForText(page, CLIENT_STATUS, 'connected')).to.be.true; 83 | 84 | await callNumber(page, NUMBER_B); 85 | 86 | page2.bringToFront(); 87 | await click(page2, SESSION_ACCEPT_BUTTON); 88 | expect(await waitForText(page2, SESSION_STATUS, 'active')).to.be.true; 89 | 90 | page.bringToFront(); 91 | await click(page, SESSION_TRANSFER_BUTTON); 92 | 93 | await page.select(SESSION_TRANSFER_METHOD_DROPDOWN, SESSION_COLD_TRANSFER_SELECT); 94 | await typeText(page, SESSION_TRANSFER_INPUT, NUMBER_C); 95 | await click(page, SESSION_COMPLETE_TRANSFER_BUTTON); 96 | await page.waitForTimeout(200); 97 | expect(await page.$$(SESSIONS)).to.be.empty; 98 | 99 | page3.bringToFront(); 100 | await click(page3, SESSION_ACCEPT_BUTTON); 101 | expect(await waitForText(page3, SESSION_STATUS, 'active')).to.be.true; 102 | 103 | page2.bringToFront(); 104 | await click(page2, SESSION_ACCEPT_BUTTON); 105 | expect(await waitForText(page2, SESSION_STATUS, 'active')).to.be.true; 106 | await click(page2, SESSION_HANGUP_BUTTON); 107 | }); 108 | 109 | it('Have user A call user B and transfer user C to user B but have user C hang up and let user A accept ringback', async function() { 110 | page2.bringToFront(); 111 | await page2.goto(DEMO_URL); 112 | 113 | await registerUser(page2, USER_B, PASSWORD_B); 114 | expect(await waitForText(page2, CLIENT_STATUS, 'connected')).to.be.true; 115 | 116 | page3.bringToFront(); 117 | await page3.goto(DEMO_URL); 118 | 119 | await registerUser(page3, USER_C, PASSWORD_C); 120 | expect(await waitForText(page3, CLIENT_STATUS, 'connected')).to.be.true; 121 | 122 | page.bringToFront(); 123 | await page.goto(DEMO_URL); 124 | 125 | const url = await page.url(); 126 | expect(url).to.include('/demo/'); 127 | 128 | await registerUser(page, USER_A, PASSWORD_A); 129 | expect(await waitForText(page, CLIENT_STATUS, 'connected')).to.be.true; 130 | 131 | await callNumber(page, NUMBER_B); 132 | 133 | page2.bringToFront(); 134 | await click(page2, SESSION_ACCEPT_BUTTON); 135 | expect(await waitForText(page2, SESSION_STATUS, 'active')).to.be.true; 136 | 137 | page.bringToFront(); 138 | await click(page, SESSION_TRANSFER_BUTTON); 139 | 140 | await page.select(SESSION_TRANSFER_METHOD_DROPDOWN, SESSION_COLD_TRANSFER_SELECT); 141 | await typeText(page, SESSION_TRANSFER_INPUT, NUMBER_C); 142 | await click(page, SESSION_COMPLETE_TRANSFER_BUTTON); 143 | 144 | expect(await page.$$(SESSIONS)).to.be.empty; 145 | 146 | page3.bringToFront(); 147 | // Rejecting the incoming transfer call 148 | await click(page3, SESSION_REJECT_BUTTON); 149 | expect(await page.$$(SESSIONS)).to.be.empty; 150 | 151 | // Go back to user A to accept the ringback 152 | page.bringToFront(); 153 | await click(page, SESSION_ACCEPT_BUTTON); 154 | expect(await waitForText(page, SESSION_STATUS, 'active')).to.be.true; 155 | await click(page, SESSION_HANGUP_BUTTON); 156 | }); 157 | 158 | it('Have user A call user B and transfer a non existing number to user B', async function() { 159 | page2.bringToFront(); 160 | await page2.goto(DEMO_URL); 161 | 162 | await registerUser(page2, USER_B, PASSWORD_B); 163 | expect(await waitForText(page2, CLIENT_STATUS, 'connected')).to.be.true; 164 | 165 | page.bringToFront(); 166 | await page.goto(DEMO_URL); 167 | 168 | const url = await page.url(); 169 | expect(url).to.include('/demo/'); 170 | 171 | await registerUser(page, USER_A, PASSWORD_A); 172 | expect(await waitForText(page, CLIENT_STATUS, 'connected')).to.be.true; 173 | 174 | await callNumber(page, NUMBER_B); 175 | 176 | page2.bringToFront(); 177 | await click(page2, SESSION_ACCEPT_BUTTON); 178 | expect(await waitForText(page2, SESSION_STATUS, 'active')).to.be.true; 179 | 180 | page.bringToFront(); 181 | await click(page, SESSION_TRANSFER_BUTTON); 182 | 183 | await page.select(SESSION_TRANSFER_METHOD_DROPDOWN, SESSION_COLD_TRANSFER_SELECT); 184 | await typeText(page, SESSION_TRANSFER_INPUT, NON_EXISTING_NUMBER); 185 | await click(page, SESSION_COMPLETE_TRANSFER_BUTTON); 186 | await page.waitForTimeout(200); 187 | expect(await page.$$(SESSIONS)).to.be.empty; 188 | 189 | page2.bringToFront(); 190 | expect(await waitForText(page2, SESSION_STATUS, 'active')).to.be.true; 191 | 192 | //Accept the ringback 193 | page.bringToFront(); 194 | await click(page, SESSION_ACCEPT_BUTTON); 195 | expect(await waitForText(page, SESSION_STATUS, 'active')).to.be.true; 196 | await click(page, SESSION_HANGUP_BUTTON); 197 | }); 198 | }); 199 | -------------------------------------------------------------------------------- /puppeteer/test/connectivity-e2e.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-voip-alliance/WebphoneLib/dc245d302691e1173c67bc6466364d636ddb1c4f/puppeteer/test/connectivity-e2e.js -------------------------------------------------------------------------------- /puppeteer/test/example-e2e.js: -------------------------------------------------------------------------------- 1 | const puppeteer = require('puppeteer'); 2 | const expect = require('chai').expect; 3 | const { describe, beforeEach, afterEach, it } = require('mocha'); 4 | 5 | const { click } = require('../helpers/utils'); 6 | const { 7 | REGISTER_BUTTON, 8 | DEMO_URL, 9 | LAUNCH_OPTIONS, 10 | CLIENT_STATUS 11 | } = require('../helpers/constants'); 12 | 13 | describe.skip('examples', () => { 14 | let browser; 15 | let page; 16 | 17 | beforeEach(async function() { 18 | browser = await puppeteer.launch(LAUNCH_OPTIONS); 19 | page = await browser.newPage(); 20 | }); 21 | 22 | afterEach(async function() { 23 | await browser.close(); 24 | }); 25 | 26 | it('Should launch a browser', async function() { 27 | // Assert if the page is visible 28 | await page.goto(DEMO_URL); 29 | const url = await page.url(); 30 | expect(url).to.include('/demo/'); 31 | 32 | // Click on Register 33 | await click(page, REGISTER_BUTTON); 34 | 35 | await page.waitForTimeout(2000); 36 | }); 37 | 38 | it('Should be possible to launch two browser pages', async function() { 39 | // Launch a second browser 40 | let browser2; 41 | let page2; 42 | browser2 = await puppeteer.launch(LAUNCH_OPTIONS); 43 | page2 = await browser2.newPage(); 44 | await page2.setDefaultTimeout(1000); 45 | await page2.goto(DEMO_URL); 46 | 47 | // Assert if both pages are visible 48 | await browser2.close(); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /puppeteer/test/hold-e2e.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-voip-alliance/WebphoneLib/dc245d302691e1173c67bc6466364d636ddb1c4f/puppeteer/test/hold-e2e.js -------------------------------------------------------------------------------- /puppeteer/test/receive-e2e.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-voip-alliance/WebphoneLib/dc245d302691e1173c67bc6466364d636ddb1c4f/puppeteer/test/receive-e2e.js -------------------------------------------------------------------------------- /puppeteer/test/subscription-e2e.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-voip-alliance/WebphoneLib/dc245d302691e1173c67bc6466364d636ddb1c4f/puppeteer/test/subscription-e2e.js -------------------------------------------------------------------------------- /puppeteer/test/warmTransfer-e2e.js: -------------------------------------------------------------------------------- 1 | const puppeteer = require('puppeteer'); 2 | const { expect } = require('chai'); 3 | const { describe, beforeEach, afterEach, it } = require('mocha'); 4 | 5 | const { callNumber, click, typeText, waitForText, registerUser } = require('../helpers/utils'); 6 | const { 7 | USER_A, 8 | USER_B, 9 | USER_C, 10 | PASSWORD_A, 11 | PASSWORD_B, 12 | PASSWORD_C, 13 | NUMBER_B, 14 | NUMBER_C 15 | } = require('../config'); 16 | const { 17 | NON_EXISTING_NUMBER, 18 | DEMO_URL, 19 | SESSIONS, 20 | SESSION_ACCEPT_BUTTON, 21 | SESSION_UNHOLD_BUTTON, 22 | SESSION_REJECT_BUTTON, 23 | SESSION_HANGUP_BUTTON, 24 | SESSION_STATUS, 25 | SESSION_TRANSFER_BUTTON, 26 | SESSION_TRANSFER_METHOD_DROPDOWN, 27 | SESSION_WARM_TRANSFER_SELECT, 28 | SESSION_TRANSFER_INPUT, 29 | SESSION_COMPLETE_TRANSFER_BUTTON, 30 | CLIENT_STATUS, 31 | LAUNCH_OPTIONS 32 | } = require('../helpers/constants'); 33 | const { assert } = require('sinon'); 34 | 35 | describe('Warm Transfer', () => { 36 | let browser; 37 | let page; 38 | let page2; 39 | let page3; 40 | 41 | beforeEach(async function() { 42 | browser = await puppeteer.launch(LAUNCH_OPTIONS); 43 | 44 | page = await browser.newPage(); 45 | page.on('pageerror', function(err) { 46 | console.log(`Page error: ${err.toString()}`); 47 | }); 48 | 49 | page2 = await browser.newPage(); 50 | page3 = await browser.newPage(); 51 | }); 52 | 53 | afterEach(async function() { 54 | await browser.close(); 55 | }); 56 | 57 | it('Have user A call user B and transfer user C to user B via a warm transfer in User A', async function() { 58 | page2.bringToFront(); 59 | await page2.goto(DEMO_URL); 60 | 61 | await registerUser(page2, USER_B, PASSWORD_B); 62 | expect(await waitForText(page2, CLIENT_STATUS, 'connected')).to.be.true; 63 | 64 | page3.bringToFront(); 65 | await page3.goto(DEMO_URL); 66 | 67 | await registerUser(page3, USER_C, PASSWORD_C); 68 | expect(await waitForText(page3, CLIENT_STATUS, 'connected')).to.be.true; 69 | 70 | page.bringToFront(); 71 | await page.goto(DEMO_URL); 72 | 73 | const url = await page.url(); 74 | expect(url).to.include('/demo/'); 75 | 76 | await registerUser(page, USER_A, PASSWORD_A); 77 | expect(await waitForText(page, CLIENT_STATUS, 'connected')).to.be.true; 78 | 79 | await callNumber(page, NUMBER_B); 80 | 81 | page2.bringToFront(); 82 | await click(page2, SESSION_ACCEPT_BUTTON); 83 | expect(await waitForText(page2, SESSION_STATUS, 'active')).to.be.true; 84 | 85 | page.bringToFront(); 86 | await click(page, SESSION_TRANSFER_BUTTON); 87 | 88 | await page.select(SESSION_TRANSFER_METHOD_DROPDOWN, SESSION_WARM_TRANSFER_SELECT); 89 | // Reminder: the demo page transfers the call after 3 seconds. 90 | await typeText(page, SESSION_TRANSFER_INPUT, NUMBER_C); 91 | await click(page, SESSION_COMPLETE_TRANSFER_BUTTON); 92 | expect(await page.$$(SESSIONS)).to.have.length(2); 93 | 94 | page3.bringToFront(); 95 | await click(page3, SESSION_ACCEPT_BUTTON); 96 | // After this accept it will be the 3 seconds 97 | 98 | expect(await waitForText(page3, SESSION_STATUS, 'active')).to.be.true; 99 | expect(await page3.$$(SESSIONS)).to.have.length(1); 100 | 101 | page.bringToFront(); 102 | await page.waitForTimeout(3000); 103 | expect(await page.$$(SESSIONS)).to.have.length(0); 104 | 105 | page2.bringToFront(); 106 | expect(await waitForText(page2, SESSION_STATUS, 'active')).to.be.true; 107 | expect(await page2.$$(SESSIONS)).to.have.length(1); 108 | 109 | await click(page2, SESSION_HANGUP_BUTTON); 110 | }); 111 | 112 | it('Have user A call user B and transfer user C to user B but have user C hang up and have user A activate call to B again', async function() { 113 | page2.bringToFront(); 114 | await page2.goto(DEMO_URL); 115 | 116 | await registerUser(page2, USER_B, PASSWORD_B); 117 | expect(await waitForText(page2, CLIENT_STATUS, 'connected')).to.be.true; 118 | 119 | page3.bringToFront(); 120 | await page3.goto(DEMO_URL); 121 | 122 | await registerUser(page3, USER_C, PASSWORD_C); 123 | expect(await waitForText(page3, CLIENT_STATUS, 'connected')).to.be.true; 124 | 125 | page.bringToFront(); 126 | await page.goto(DEMO_URL); 127 | 128 | const url = await page.url(); 129 | expect(url).to.include('/demo/'); 130 | 131 | await registerUser(page, USER_A, PASSWORD_A); 132 | expect(await waitForText(page, CLIENT_STATUS, 'connected')).to.be.true; 133 | 134 | await callNumber(page, NUMBER_B); 135 | 136 | page2.bringToFront(); 137 | await click(page2, SESSION_ACCEPT_BUTTON); 138 | expect(await page2.$$(SESSIONS)).to.have.length(1); 139 | expect(await waitForText(page2, SESSION_STATUS, 'active')).to.be.true; 140 | 141 | page.bringToFront(); 142 | expect(await page.$$(SESSIONS)).to.have.length(1); 143 | await click(page, SESSION_TRANSFER_BUTTON); 144 | 145 | await page.select(SESSION_TRANSFER_METHOD_DROPDOWN, SESSION_WARM_TRANSFER_SELECT); 146 | await typeText(page, SESSION_TRANSFER_INPUT, NUMBER_C); 147 | await click(page, SESSION_COMPLETE_TRANSFER_BUTTON); 148 | expect(await page.$$(SESSIONS)).to.have.length(2); 149 | 150 | page3.bringToFront(); 151 | // Rejecting the incoming transfer call 152 | await click(page3, SESSION_REJECT_BUTTON); 153 | expect(await page3.$$(SESSIONS)).to.be.empty; 154 | 155 | // Go back to user A to "continue" the session with user B. 156 | page.bringToFront(); 157 | expect(await page.$$(SESSIONS)).to.have.length(1); 158 | expect(await waitForText(page, SESSION_STATUS, 'on_hold')).to.be.true; 159 | await click(page, SESSION_UNHOLD_BUTTON); 160 | expect(await waitForText(page, SESSION_STATUS, 'active')).to.be.true; 161 | await click(page, SESSION_HANGUP_BUTTON); 162 | }); 163 | 164 | it('Have user A call user B and transfer B to a non existing number', async function() { 165 | page2.bringToFront(); 166 | await page2.goto(DEMO_URL); 167 | 168 | await registerUser(page2, USER_B, PASSWORD_B); 169 | expect(await waitForText(page2, CLIENT_STATUS, 'connected')).to.be.true; 170 | 171 | page.bringToFront(); 172 | await page.goto(DEMO_URL); 173 | 174 | const url = await page.url(); 175 | expect(url).to.include('/demo/'); 176 | 177 | await registerUser(page, USER_A, PASSWORD_A); 178 | expect(await waitForText(page, CLIENT_STATUS, 'connected')).to.be.true; 179 | 180 | await callNumber(page, NUMBER_B); 181 | 182 | page2.bringToFront(); 183 | await click(page2, SESSION_ACCEPT_BUTTON); 184 | expect(await page2.$$(SESSIONS)).to.have.length(1); 185 | expect(await waitForText(page2, SESSION_STATUS, 'active')).to.be.true; 186 | 187 | page.bringToFront(); 188 | expect(await page.$$(SESSIONS)).to.have.length(1); 189 | await click(page, SESSION_TRANSFER_BUTTON); 190 | 191 | await page.select(SESSION_TRANSFER_METHOD_DROPDOWN, SESSION_WARM_TRANSFER_SELECT); 192 | await typeText(page, SESSION_TRANSFER_INPUT, NON_EXISTING_NUMBER); 193 | await click(page, SESSION_COMPLETE_TRANSFER_BUTTON); 194 | expect(await page.$$(SESSIONS)).to.have.length(2); 195 | 196 | // session will get rejected so lets wait a bit for that 197 | await page.waitForTimeout(5000); 198 | 199 | expect(await page.$$(SESSIONS)).to.have.length(1); 200 | expect(await waitForText(page, SESSION_STATUS, 'on_hold')).to.be.true; 201 | await click(page, SESSION_UNHOLD_BUTTON); 202 | expect(await waitForText(page, SESSION_STATUS, 'active')).to.be.true; 203 | await click(page, SESSION_HANGUP_BUTTON); 204 | }); 205 | }); 206 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import builtins from 'rollup-plugin-node-builtins'; 2 | import resolve from 'rollup-plugin-node-resolve'; 3 | import commonjs from 'rollup-plugin-commonjs'; 4 | import json from 'rollup-plugin-json'; 5 | import typescript from 'rollup-plugin-typescript2'; 6 | 7 | function onwarn(warning) { 8 | console.log(warning.toString()); 9 | } 10 | 11 | export default { 12 | onwarn, 13 | input: 'src/index.ts', 14 | output: { 15 | file: 'dist/index.mjs', 16 | format: 'esm', 17 | sourcemap: true 18 | }, 19 | plugins: [resolve({ preferBuiltins: true }), commonjs(), builtins(), json(), typescript()] 20 | }; 21 | -------------------------------------------------------------------------------- /serve.json: -------------------------------------------------------------------------------- 1 | { 2 | "public": ".", 3 | "trailingSlash": true, 4 | "headers": [ 5 | { 6 | "source": "**/*", 7 | "headers": [ 8 | { 9 | "key": "Cache-Control", 10 | "value": "no-cache" 11 | } 12 | ] 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /src/audio-context.ts: -------------------------------------------------------------------------------- 1 | function createAudioContext(): AudioContext { 2 | const cls = (window as any).AudioContext || (window as any).webkitAudioContext; 3 | if (cls) { 4 | return new cls(); 5 | } 6 | } 7 | 8 | export const audioContext = createAudioContext(); 9 | -------------------------------------------------------------------------------- /src/autoplay.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | 3 | // As short as possible mp3 file. 4 | // source: https://gist.github.com/westonruter/253174 5 | // prettier-ignore 6 | const audioTestSample = 'data:audio/mpeg;base64,/+MYxAAAAANIAUAAAASEEB/jwOFM/0MM/90b/+RhST//w4NFwOjf///PZu////9lns5GFDv//l9GlUIEEIAAAgIg8Ir/JGq3/+MYxDsLIj5QMYcoAP0dv9HIjUcH//yYSg+CIbkGP//8w0bLVjUP///3Z0x5QCAv/yLjwtGKTEFNRTMuOTeqqqqqqqqqqqqq/+MYxEkNmdJkUYc4AKqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq'; 7 | 8 | interface IAutoplay { 9 | listen(): void; 10 | stop(): void; 11 | on(event: 'allowed', listener: () => void): this; 12 | } 13 | 14 | class AutoplaySingleton extends EventEmitter implements IAutoplay { 15 | public readonly allowed: Promise; 16 | 17 | private timer: number; 18 | 19 | constructor() { 20 | super(); 21 | this.allowed = new Promise(resolve => { 22 | this.once('allowed', resolve); 23 | }); 24 | } 25 | 26 | public listen(): void { 27 | this.timer = window.setTimeout(() => this.update(), 0); 28 | } 29 | 30 | public stop(): void { 31 | if (this.timer) { 32 | window.clearTimeout(this.timer); 33 | delete this.timer; 34 | } 35 | } 36 | 37 | private async update() { 38 | if (await this.test()) { 39 | this.emit('allowed'); 40 | delete this.timer; 41 | } else { 42 | this.timer = window.setTimeout(() => this.update(), 1000); 43 | } 44 | } 45 | 46 | private async test(): Promise { 47 | const audio = new Audio(); 48 | audio.src = audioTestSample; 49 | const playPromise = audio.play(); 50 | if (playPromise === undefined) { 51 | return false; 52 | } 53 | 54 | try { 55 | await playPromise; 56 | return true; 57 | } catch (e) { 58 | return false; 59 | } 60 | } 61 | } 62 | 63 | export const Autoplay = new AutoplaySingleton(); 64 | -------------------------------------------------------------------------------- /src/enums.ts: -------------------------------------------------------------------------------- 1 | export enum ClientStatus { 2 | CONNECTING = 'connecting', 3 | CONNECTED = 'connected', 4 | DYING = 'dying', // (once you have a call and connectivity drops, your call is 'dying' for 1 minute) 5 | RECOVERING = 'recovering', 6 | DISCONNECTING = 'disconnecting', 7 | DISCONNECTED = 'disconnected' 8 | } 9 | 10 | export enum SessionStatus { 11 | TRYING = 'trying', 12 | RINGING = 'ringing', 13 | ACTIVE = 'active', 14 | ON_HOLD = 'on_hold', 15 | TERMINATED = 'terminated' 16 | } 17 | 18 | export enum SubscriptionStatus { 19 | AVAILABLE = 'available', 20 | TRYING = 'trying', 21 | PROCEEDING = 'proceeding', 22 | EARLY = 'early', 23 | RINGING = 'ringing', 24 | CONFIRMED = 'confirmed', 25 | BUSY = 'busy', 26 | TERMINATED = 'terminated' 27 | } 28 | 29 | export enum ReconnectionMode { 30 | ONCE, 31 | BURST 32 | } 33 | -------------------------------------------------------------------------------- /src/features.ts: -------------------------------------------------------------------------------- 1 | const mediaDevices = 'mediaDevices' in window.navigator; 2 | 3 | export const webaudio = { 4 | mediaDevices, 5 | setSinkId: 'Audio' in window && 'setSinkId' in new (window as any).Audio(), 6 | getUserMedia: mediaDevices && 'getUserMedia' in window.navigator.mediaDevices, 7 | audioContext: 'AudioContext' in window || 'webkitAudioContext' in window 8 | }; 9 | 10 | const peerConnection = 'RTCPeerConnection' in window; 11 | 12 | export const webrtc = { 13 | peerConnection, 14 | connectionstatechange: peerConnection && 'onconnectionstatechange' in RTCPeerConnection.prototype 15 | }; 16 | 17 | const browserUa: string = navigator.userAgent.toLowerCase(); 18 | export const isSafari = browserUa.indexOf('safari') !== -1 && browserUa.indexOf('chrome') < 0; 19 | export const isFirefox = browserUa.indexOf('firefox') !== -1 && browserUa.indexOf('chrome') < 0; 20 | export const isChrome = browserUa.indexOf('chrome') !== -1 && !isSafari && !isFirefox; 21 | 22 | export const isLocalhost = ['127.0.0.1', 'localhost'].includes(window.location.hostname); 23 | 24 | const required = [ 25 | webrtc.peerConnection, 26 | webaudio.mediaDevices, 27 | webaudio.getUserMedia, 28 | webaudio.audioContext 29 | ]; 30 | 31 | export function checkRequired(): boolean { 32 | return required.every(x => x); 33 | } 34 | -------------------------------------------------------------------------------- /src/health-checker.ts: -------------------------------------------------------------------------------- 1 | import pTimeout from 'p-timeout'; 2 | import { C, Core } from 'sip.js'; 3 | import { UserAgent } from 'sip.js/lib/api/user-agent'; 4 | 5 | export class HealthChecker { 6 | private optionsTimeout: NodeJS.Timeout; 7 | private logger: Core.Logger; 8 | 9 | constructor(private userAgent: UserAgent) { 10 | this.logger = userAgent.userAgentCore.loggerFactory.getLogger('socket-health-checker'); 11 | } 12 | 13 | public stop(): void { 14 | clearTimeout(this.optionsTimeout); 15 | } 16 | 17 | /** 18 | * Start a periodic OPTIONS message to be sent to the sip server, if it 19 | * does not respond, our connection is probably broken. 20 | */ 21 | public start(): any { 22 | return pTimeout( 23 | new Promise(resolve => { 24 | clearTimeout(this.optionsTimeout); 25 | this.userAgent.userAgentCore.request(this.createOptionsMessage(), { 26 | onAccept: () => { 27 | resolve(); 28 | this.optionsTimeout = setTimeout(() => { 29 | this.start(); 30 | }, 22000); 31 | } 32 | }); 33 | }), 34 | 2000, // if there is no response after 2 seconds, emit disconnected. 35 | () => { 36 | this.logger.error('No response after OPTIONS message to sip server.'); 37 | clearTimeout(this.optionsTimeout); 38 | this.userAgent.transport.emit('disconnected'); 39 | } 40 | ); 41 | } 42 | 43 | private createOptionsMessage() { 44 | const settings = { 45 | params: { 46 | toUri: this.userAgent.configuration.uri, 47 | cseq: 1, 48 | fromUri: this.userAgent.userAgentCore.configuration.aor 49 | }, 50 | registrar: undefined 51 | }; 52 | 53 | /* If no 'registrarServer' is set use the 'uri' value without user portion. */ 54 | if (!settings.registrar) { 55 | let registrarServer: any = {}; 56 | if (typeof this.userAgent.configuration.uri === 'object') { 57 | registrarServer = this.userAgent.configuration.uri.clone(); 58 | registrarServer.user = undefined; 59 | } else { 60 | registrarServer = this.userAgent.configuration.uri; 61 | } 62 | settings.registrar = registrarServer; 63 | } 64 | 65 | return this.userAgent.userAgentCore.makeOutgoingRequestMessage( 66 | C.OPTIONS, 67 | settings.registrar, 68 | settings.params.fromUri, 69 | settings.params.toUri ? settings.params.toUri : settings.registrar, 70 | settings.params 71 | ); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { Autoplay } from './autoplay'; 2 | export { Client } from './client'; 3 | export { log } from './logger'; 4 | export { Media } from './media'; 5 | export { Sound } from './sound'; 6 | export { IClientOptions } from './types'; 7 | 8 | import * as Features from './features'; 9 | export { Features }; 10 | -------------------------------------------------------------------------------- /src/invitation.ts: -------------------------------------------------------------------------------- 1 | import { Invitation as SIPInvitation } from 'sip.js/lib/api/invitation'; 2 | import { InvitationRejectOptions } from 'sip.js/lib/api/invitation-reject-options'; 3 | 4 | import { SessionStatus } from './enums'; 5 | import { ISessionAccept, SessionImpl } from './session'; 6 | 7 | export class Invitation extends SessionImpl { 8 | protected session: SIPInvitation; 9 | private acceptedRef: any; 10 | 11 | constructor(options) { 12 | super(options); 13 | 14 | this.acceptedPromise = new Promise(resolve => { 15 | this.acceptedRef = resolve; 16 | }); 17 | 18 | this.cancelled = options.cancelled; 19 | 20 | this.status = SessionStatus.RINGING; 21 | this.emit('statusUpdate', { id: this.id, status: this.status }); 22 | } 23 | 24 | public accept(): Promise { 25 | return (this.session as SIPInvitation).accept().then(() => { 26 | this.status = SessionStatus.ACTIVE; 27 | this.emit('statusUpdate', { id: this.id, status: this.status }); 28 | this.acceptedRef({ accepted: true }); 29 | 30 | this.session.delegate = { 31 | onInvite: () => { 32 | this._remoteIdentity = this.extractRemoteIdentity(); 33 | this.emit('remoteIdentityUpdate', this, this.remoteIdentity); 34 | } 35 | }; 36 | }); 37 | } 38 | 39 | public accepted(): Promise { 40 | return this.acceptedPromise; 41 | } 42 | 43 | public reject(rejectOptions?: InvitationRejectOptions): Promise { 44 | return this.session.reject(rejectOptions).then(() => this.acceptedRef({ accepted: false })); 45 | } 46 | 47 | public async tried(): Promise { 48 | throw new Error('Not applicable for incoming calls.'); 49 | } 50 | 51 | public async cancel(): Promise { 52 | throw new Error('Cannot cancel an incoming call.'); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/inviter.ts: -------------------------------------------------------------------------------- 1 | import { Core } from 'sip.js'; 2 | import { Inviter as SIPInviter } from 'sip.js/lib/api/inviter'; 3 | 4 | import { SessionStatus } from './enums'; 5 | import { ISessionAccept, SessionImpl } from './session'; 6 | 7 | export class Inviter extends SessionImpl { 8 | protected session: SIPInviter; 9 | private progressedPromise: Promise; 10 | private triedPromise: Promise; 11 | 12 | constructor(options) { 13 | super(options); 14 | 15 | this.triedPromise = new Promise(tryingResolve => { 16 | this.progressedPromise = new Promise(progressResolve => { 17 | this.acceptedPromise = new Promise((acceptedResolve, acceptedReject) => { 18 | this.inviteOptions = this.makeInviteOptions({ 19 | onAccept: acceptedResolve, 20 | onReject: acceptedResolve, 21 | onRejectThrow: acceptedReject, 22 | onProgress: progressResolve, 23 | onTrying: tryingResolve 24 | }); 25 | }); 26 | }); 27 | }); 28 | } 29 | 30 | public progressed(): Promise { 31 | return this.progressedPromise; 32 | } 33 | 34 | public tried(): Promise { 35 | return this.triedPromise; 36 | } 37 | 38 | public accepted(): Promise { 39 | return this.acceptedPromise; 40 | } 41 | 42 | public invite(): Promise { 43 | return this.session.invite(this.inviteOptions).then((request: Core.OutgoingInviteRequest) => { 44 | this.status = SessionStatus.RINGING; 45 | this.emit('statusUpdate', { id: this.id, status: this.status }); 46 | return request; 47 | }); 48 | } 49 | 50 | public async accept(): Promise { 51 | throw new Error('Cannot accept an outgoing call.'); 52 | } 53 | 54 | public async reject(): Promise { 55 | throw new Error('Cannot reject an outgoing call.'); 56 | } 57 | 58 | public cancel(): Promise { 59 | return this.session.cancel(); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/lib/freeze.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @hidden 3 | */ 4 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 5 | function getPropertyDescriptor(obj: any, name: string) { 6 | if (obj) { 7 | return ( 8 | Object.getOwnPropertyDescriptor(obj, name) || 9 | getPropertyDescriptor(Object.getPrototypeOf(obj), name) 10 | ); 11 | } 12 | } 13 | 14 | /** 15 | * Create immutable proxies for all `properties` on `obj` proxying to `impl`. 16 | * @hidden 17 | */ 18 | // eslint-disable-next-line @typescript-eslint/ban-types 19 | export function createFrozenProxy(obj: object, impl: T, properties: string[]): T { 20 | const missingDescriptors = properties.filter( 21 | name => getPropertyDescriptor(impl, name) === undefined 22 | ); 23 | 24 | if (missingDescriptors.length > 0) { 25 | throw new Error( 26 | `Implementation is not complete, missing properties: ${missingDescriptors.join(', ')}` 27 | ); 28 | } 29 | 30 | return Object.freeze( 31 | properties.reduce((proxy, name) => { 32 | const desc = getPropertyDescriptor(impl, name); 33 | 34 | if ('value' in desc) { 35 | if (typeof desc.value === 'function') { 36 | proxy[name] = desc.value.bind(impl); 37 | } else { 38 | proxy[name] = desc.value; 39 | } 40 | return proxy; 41 | } else { 42 | return Object.defineProperty(proxy, name, { 43 | get: desc.get.bind(impl) 44 | }); 45 | } 46 | }, obj) 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { audioContext } from '../audio-context'; 2 | 3 | /** 4 | * Generic class type T. For example: `Type` 5 | */ 6 | export type Type = new (...args: any[]) => T; 7 | 8 | export function eqSet(a: Set, b: Set): boolean { 9 | return a.size === b.size && [...a].every(b.has.bind(b)); 10 | } 11 | 12 | // https://stackoverflow.com/a/13969691/248948 13 | export function isPrivateIP(ip: string): boolean { 14 | const parts = ip.split('.'); 15 | return ( 16 | parts[0] === '10' || 17 | (parts[0] === '172' && (parseInt(parts[1], 10) >= 16 && parseInt(parts[1], 10) <= 31)) || 18 | (parts[0] === '192' && parts[1] === '168') 19 | ); 20 | } 21 | 22 | export async function fetchStream(url: string): Promise<() => Promise> { 23 | const response = await fetch(url); 24 | const data = await response.arrayBuffer(); 25 | const buffer = await audioContext.decodeAudioData(data); 26 | return async () => { 27 | const soundSource = audioContext.createBufferSource(); 28 | soundSource.buffer = buffer; 29 | soundSource.start(0, 0); 30 | return soundSource; 31 | // const destination = audioContext.createMediaStreamDestination(); 32 | // soundSource.connect(destination); 33 | // return destination.stream; 34 | }; 35 | } 36 | 37 | /** 38 | * Calculate a jitter from interval. 39 | * @param {number} interval - The interval in ms to calculate jitter for. 40 | * @param {number} percentage - The jitter range in percentage. 41 | * @returns {number} The calculated jitter in ms. 42 | */ 43 | export function jitter(interval: number, percentage: number): number { 44 | const min = Math.max(0, Math.ceil(interval * ((100 - percentage) / 100))); 45 | const max = Math.floor(interval * ((100 + percentage) / 100)); 46 | return Math.floor(min + Math.random() * (max - min)); 47 | } 48 | 49 | /** 50 | * This doubles the retry interval in each run and adds jitter. 51 | * @param {any} retry - The reference retry object. 52 | * @returns {any & { interval: number } } The updated retry object. 53 | */ 54 | export function increaseTimeout(retry: any): any & { interval: number } { 55 | // Make sure that interval doesn't go past the limit. 56 | if (retry.interval * 2 < retry.limit) { 57 | retry.interval = retry.interval * 2; 58 | } else { 59 | retry.interval = retry.limit; 60 | } 61 | 62 | retry.timeout = retry.interval + jitter(retry.interval, 30); 63 | return retry; 64 | } 65 | 66 | /** 67 | * Clamp a value between `min` and `max`, both inclusive. 68 | * @param {number} value - Value. 69 | * @param {number} min - Minimum value, inclusive. 70 | * @param {number} max - Maximum value, inclusive. 71 | * @returns {number} Clamped value. 72 | */ 73 | export function clamp(value: number, min: number, max: number): number { 74 | if (value < min) { 75 | return min; 76 | } else if (value > max) { 77 | return max; 78 | } else { 79 | return value; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | interface ILoggerConnector { 2 | level: string; 3 | message: string; 4 | context: any; 5 | } 6 | 7 | type LoggerConnector = (connector: ILoggerConnector) => void; 8 | 9 | class Logger { 10 | private static readonly levels: { [index: string]: number } = { 11 | error: 4, 12 | warn: 3, 13 | info: 2, 14 | verbose: 1, 15 | debug: 0 16 | }; 17 | 18 | private static getLevelIdx(level: string) { 19 | const idx = Logger.levels[level]; 20 | return idx === undefined ? 0 : idx; 21 | } 22 | 23 | public level = 'info'; 24 | public connector?: LoggerConnector; 25 | 26 | constructor(level: string, connector?: LoggerConnector) { 27 | this.level = level; 28 | 29 | if (connector) { 30 | this.connector = connector; 31 | } 32 | } 33 | 34 | public error(message, context) { 35 | this.log('error', message, context); 36 | } 37 | 38 | public warn(message, context) { 39 | this.log('warn', message, context); 40 | } 41 | 42 | public info(message, context) { 43 | this.log('info', message, context); 44 | } 45 | 46 | public debug(message, context) { 47 | this.log('debug', message, context); 48 | } 49 | 50 | public verbose(message, context) { 51 | this.log('verbose', message, context); 52 | } 53 | 54 | public log(level, message, context) { 55 | const levelIdx = Logger.getLevelIdx(level); 56 | const thresholdIdx = Logger.getLevelIdx(this.level); 57 | if (this.connector && levelIdx >= thresholdIdx) { 58 | this.connector({ level, message, context }); 59 | } 60 | } 61 | } 62 | 63 | export const log = new Logger('info'); 64 | -------------------------------------------------------------------------------- /src/media.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | 3 | import * as Features from './features'; 4 | import { eqSet } from './lib/utils'; 5 | import { log } from './logger'; 6 | import * as Time from './time'; 7 | import { IMediaInput } from './types'; 8 | 9 | export interface IAudioDevice { 10 | /** 11 | * Unique identifier for the presented device that is persisted across 12 | * sessions. It is reset when the user clears cookies. See 13 | * `MediaDeviceInfo.deviceId`. 14 | */ 15 | id: string; 16 | name: string; 17 | kind: 'audioinput' | 'audiooutput'; 18 | } 19 | 20 | interface IMediaDevices { 21 | readonly devices: IAudioDevice[]; 22 | readonly inputs: IAudioDevice[]; 23 | readonly outputs: IAudioDevice[]; 24 | on( 25 | event: 'devicesChanged' | 'permissionGranted' | 'permissionRevoked', 26 | listener: () => void 27 | ): this; 28 | } 29 | 30 | const UPDATE_INTERVAL = 1 * Time.second; 31 | 32 | /** 33 | * Offers an abstraction over Media permissions and device enumeration for use 34 | * with WebRTC. 35 | */ 36 | class MediaSingleton extends EventEmitter implements IMediaDevices { 37 | private allDevices: IAudioDevice[] = []; 38 | private requestPermissionPromise: Promise; 39 | private timer: number = undefined; 40 | private hadPermission = false; 41 | 42 | public init() { 43 | this.update(); 44 | 45 | navigator.mediaDevices.ondevicechange = () => { 46 | this.update(); 47 | }; 48 | } 49 | 50 | get devices() { 51 | return this.allDevices; 52 | } 53 | 54 | get inputs() { 55 | return this.allDevices.filter(d => d.kind === 'audioinput'); 56 | } 57 | 58 | get outputs() { 59 | return this.allDevices.filter(d => d.kind === 'audiooutput'); 60 | } 61 | 62 | /** 63 | * Check if we (still) have permission to getUserMedia and enumerateDevices. 64 | * This only checks the permission and does not ask the user for anything. Use 65 | * `requestPermission` to ask the user to approve the request. 66 | */ 67 | public async checkPermission(): Promise { 68 | const devices = await navigator.mediaDevices.enumerateDevices(); 69 | 70 | if (devices.length && devices[0].label) { 71 | return true; 72 | } 73 | 74 | return false; 75 | } 76 | 77 | public async requestPermission(): Promise { 78 | if (this.requestPermissionPromise) { 79 | return this.requestPermissionPromise; 80 | } 81 | 82 | try { 83 | const { state } = await navigator.permissions.query({ name: 'microphone' }); 84 | if (state === 'granted') { 85 | return; 86 | } 87 | } catch (err) { 88 | console.error('permissions query mic not supported in firefox, doing fallback', err); 89 | } 90 | 91 | // eslint-disable-next-line no-async-promise-executor 92 | this.requestPermissionPromise = new Promise(async (resolve, reject) => { 93 | try { 94 | const stream = await navigator.mediaDevices.getUserMedia({ 95 | audio: true, 96 | video: false 97 | }); 98 | 99 | if (!this.hadPermission) { 100 | if (this.timer) { 101 | window.clearTimeout(this.timer); 102 | } 103 | 104 | await this.update(); 105 | } 106 | 107 | // Close the stream and delete the promise. 108 | this.closeStream(stream); 109 | resolve(); 110 | } catch (err) { 111 | reject(err); 112 | } finally { 113 | delete this.requestPermissionPromise; 114 | } 115 | }); 116 | 117 | return this.requestPermissionPromise; 118 | } 119 | 120 | public openInputStream(input: IMediaInput): Promise { 121 | log.debug(`Requesting input stream with: audioProcessing=${input.audioProcessing}`, 'media'); 122 | const constraints = getInputConstraints(input); 123 | const promise = navigator.mediaDevices.getUserMedia(constraints); 124 | promise.then(stream => { 125 | stream.getTracks().forEach(track => { 126 | log.debug( 127 | `Media stream track has settings: ${JSON.stringify(track.getSettings())}`, 128 | 'media' 129 | ); 130 | }); 131 | }); 132 | return promise; 133 | } 134 | 135 | public closeStream(stream: MediaStream): void { 136 | stream.getTracks().forEach(track => track.stop()); 137 | } 138 | 139 | private async enumerateDevices(): Promise { 140 | const devices = await navigator.mediaDevices.enumerateDevices(); 141 | 142 | if (devices.length && devices[0].label) { 143 | return devices; 144 | } 145 | 146 | return undefined; 147 | } 148 | 149 | private async update() { 150 | const devices = await this.enumerateDevices(); 151 | const havePermission = devices !== undefined; 152 | 153 | if (havePermission) { 154 | if (!this.hadPermission) { 155 | this.emit('permissionGranted'); 156 | } 157 | 158 | this.updateDevices(devices); 159 | } else { 160 | if (this.hadPermission) { 161 | this.emit('permissionRevoked'); 162 | this.allDevices = []; 163 | this.emit('devicesChanged'); 164 | } 165 | } 166 | 167 | this.hadPermission = havePermission; 168 | } 169 | 170 | private updateDevices(enumeratedDevices: MediaDeviceInfo[]) { 171 | // Map the found devices to our own format, and filter out videoinput's. 172 | const allDevices = enumeratedDevices 173 | .map( 174 | (d: MediaDeviceInfo): IAudioDevice => { 175 | if (!d.label) { 176 | // This should not happen, but safe guard that devices without a name 177 | // cannot enter our device state. 178 | return undefined; 179 | } 180 | if (d.kind === 'audioinput') { 181 | return { id: d.deviceId, name: d.label, kind: 'audioinput' }; 182 | } else if (d.kind === 'audiooutput') { 183 | return { id: d.deviceId, name: d.label, kind: 'audiooutput' }; 184 | } else { 185 | return undefined; 186 | } 187 | } 188 | ) 189 | .filter(d => d !== undefined); 190 | 191 | const newIds = new Set(allDevices.map(d => d.id)); 192 | const oldIds = new Set(this.allDevices.map(d => d.id)); 193 | 194 | if (!eqSet(newIds, oldIds)) { 195 | this.allDevices = allDevices; 196 | this.emit('devicesChanged'); 197 | } 198 | } 199 | } 200 | 201 | export const Media = new MediaSingleton(); 202 | 203 | function getInputConstraints(input: IMediaInput): MediaStreamConstraints { 204 | const presets = input.audioProcessing 205 | ? { 206 | echoCancellation: true, 207 | noiseSuppression: true, 208 | autoGainControl: true, 209 | googAudioMirroring: true, 210 | googAutoGainControl: true, 211 | googAutoGainControl2: true, 212 | googEchoCancellation: true, 213 | googHighpassFilter: true, 214 | googNoiseSuppression: true, 215 | googTypingNoiseDetection: true 216 | } 217 | : { 218 | echoCancellation: false, 219 | noiseSuppression: false, 220 | autoGainControl: false, 221 | googAudioMirroring: false, 222 | googAutoGainControl: false, 223 | googAutoGainControl2: false, 224 | googEchoCancellation: false, 225 | googHighpassFilter: false, 226 | googNoiseSuppression: false, 227 | googTypingNoiseDetection: false 228 | }; 229 | 230 | const constraints: MediaStreamConstraints = { audio: presets, video: false }; 231 | if (input.id) { 232 | (constraints.audio as MediaTrackConstraints).deviceId = input.id; 233 | } 234 | 235 | log.debug(`Using input constraints: ${JSON.stringify(constraints)}`, 'media'); 236 | 237 | return constraints; 238 | } 239 | -------------------------------------------------------------------------------- /src/session-description-handler.ts: -------------------------------------------------------------------------------- 1 | import { Web } from 'sip.js'; 2 | import { SessionDescriptionHandler } from 'sip.js/lib/Web'; 3 | 4 | import { audioContext } from './audio-context'; 5 | import { isPrivateIP } from './lib/utils'; 6 | import { log } from './logger'; 7 | 8 | export function stripPrivateIps( 9 | description: RTCSessionDescriptionInit 10 | ): Promise { 11 | const lines = description.sdp.split(/\r\n/); 12 | const filtered = lines.filter(line => { 13 | const m = /a=candidate:\d+ \d+ (?:udp|tcp) \d+ (\d+\.\d+\.\d+\.\d+)/i.exec(line); 14 | return !m || !isPrivateIP(m[1]); 15 | }); 16 | description.sdp = filtered.join('\r\n'); 17 | return Promise.resolve(description); 18 | } 19 | 20 | export function sessionDescriptionHandlerFactory(session, options): SessionDescriptionHandler { 21 | const sdh = Web.SessionDescriptionHandler.defaultFactory(session, options); 22 | 23 | session.__streams = { 24 | localStream: audioContext.createMediaStreamDestination(), 25 | remoteStream: new MediaStream() 26 | }; 27 | 28 | (sdh as any).getMediaStream = async () => { 29 | await session.__media.setInput(); 30 | return session.__streams.localStream.stream; 31 | }; 32 | 33 | (sdh as any).on('addTrack', async (track, stream) => { 34 | const pc = session.sessionDescriptionHandler.peerConnection; 35 | // eslint-disable-next-line prefer-rest-params 36 | log.debug('addTrack' + arguments, 'sessionDescriptionHandlerFactory'); 37 | 38 | let remoteStream = new MediaStream(); 39 | if (pc.getReceivers) { 40 | pc.getReceivers().forEach(receiver => { 41 | const rtrack = receiver.track; 42 | if (rtrack) { 43 | remoteStream.addTrack(rtrack); 44 | } 45 | }); 46 | } else { 47 | remoteStream = pc.getRemoteStreams()[0]; 48 | } 49 | 50 | session.__streams.remoteStream = remoteStream; 51 | try { 52 | await session.__media.setOutput(); 53 | } catch (e) { 54 | log.error(e, 'sessionDescriptionHandlerFactory'); 55 | session.__media.emit('mediaFailure'); 56 | } 57 | }); 58 | 59 | log.debug('Returning patched SDH for session' + session, 'sessionDescriptionHandlerFactory'); 60 | return sdh; 61 | } 62 | -------------------------------------------------------------------------------- /src/session-health.ts: -------------------------------------------------------------------------------- 1 | import { Session as UserAgentSession } from 'sip.js/lib/api/session'; 2 | import * as Features from './features'; 3 | 4 | export function checkAudioConnected( 5 | session: UserAgentSession, 6 | { 7 | checkInterval, 8 | noAudioTimeout 9 | }: { 10 | checkInterval: number; 11 | noAudioTimeout: number; 12 | } 13 | ): Promise { 14 | let checkTimer: number; 15 | 16 | return new Promise((resolve, reject) => { 17 | session.once('SessionDescriptionHandler-created', () => { 18 | // We patched the sdh with peerConnection. 19 | const pc = (session.sessionDescriptionHandler as any).peerConnection; 20 | 21 | // onconnectionstatechange is only supported on Chromium. For all other 22 | // browsers we look at the outbound-rtp stats to detect potentially broken 23 | // audio. 24 | if (Features.webrtc.connectionstatechange) { 25 | pc.addEventListener('connectionstatechange', () => { 26 | switch (pc.connectionState) { 27 | case 'connected': 28 | resolve(); 29 | break; 30 | 31 | case 'failed': 32 | reject(); 33 | break; 34 | } 35 | }); 36 | } else { 37 | let noAudioTimeoutLeft = noAudioTimeout; 38 | const checkStats = () => { 39 | pc.getStats().then((stats: RTCStatsReport) => { 40 | const buckets = Array.from(stats.values()); 41 | const outbound = buckets.find(obj => obj.type === 'outbound-rtp'); 42 | if (outbound && outbound.packetsSent > 0) { 43 | resolve(); 44 | } else { 45 | noAudioTimeoutLeft -= checkInterval; 46 | if (noAudioTimeoutLeft <= 0) { 47 | reject(); 48 | } else { 49 | checkTimer = window.setTimeout(checkStats, checkInterval); 50 | } 51 | } 52 | }); 53 | }; 54 | 55 | checkTimer = window.setTimeout(checkStats, checkInterval); 56 | 57 | session.once('terminated', () => { 58 | if (checkTimer) { 59 | window.clearTimeout(checkTimer); 60 | } 61 | }); 62 | } 63 | }); 64 | }); 65 | } 66 | -------------------------------------------------------------------------------- /src/session-media.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | 3 | import { Session } from 'sip.js/lib/api/session'; 4 | import { IncomingInviteRequest } from 'sip.js/lib/core'; 5 | 6 | import { audioContext } from './audio-context'; 7 | import * as Features from './features'; 8 | import { clamp } from './lib/utils'; 9 | import { log } from './logger'; 10 | import { Media } from './media'; 11 | import { SessionImpl } from './session'; 12 | import { IMedia, IMediaInput, IMediaOutput } from './types'; 13 | 14 | interface IRTCPeerConnectionLegacy extends RTCPeerConnection { 15 | getRemoteStreams: () => MediaStream[]; 16 | getLocalStreams: () => MediaStream[]; 17 | } 18 | 19 | export type InternalSession = Session & { 20 | _sessionDescriptionHandler: { 21 | peerConnection: IRTCPeerConnectionLegacy; 22 | }; 23 | 24 | __streams: { 25 | localStream: MediaStreamAudioDestinationNode; 26 | remoteStream: MediaStream; 27 | }; 28 | 29 | __media: SessionMedia; 30 | 31 | on( 32 | event: 'reinvite', 33 | listener: (session: InternalSession, request: IncomingInviteRequest) => void 34 | ): InternalSession; 35 | }; 36 | 37 | interface ISessionMedia extends IMedia { 38 | on(event: 'setupFailed', listener: () => void): this; 39 | } 40 | 41 | export class SessionMedia extends EventEmitter implements ISessionMedia { 42 | public readonly input: IMediaInput; 43 | public readonly output: IMediaOutput; 44 | 45 | private session: SessionImpl; 46 | 47 | private media: IMedia; 48 | private audioOutput: HTMLAudioElement; 49 | private inputStream: MediaStream; 50 | private inputNode: GainNode; 51 | 52 | public constructor(session: SessionImpl, media: IMedia) { 53 | super(); 54 | 55 | this.session = (session as any).session; 56 | 57 | // This link is for the custom SessionDescriptionHandler. 58 | (this.session as any).__media = this; 59 | 60 | // Make a copy of media. 61 | this.media = { 62 | input: Object.assign({}, media.input), 63 | output: Object.assign({}, media.output) 64 | }; 65 | 66 | session.on('terminated', () => { 67 | this.stopInput(); 68 | this.stopOutput(); 69 | }); 70 | 71 | // eslint-disable-next-line @typescript-eslint/no-this-alias 72 | const self = this; 73 | 74 | this.input = { 75 | get id() { 76 | return self.media.input.id; 77 | }, 78 | set id(value) { 79 | self.setInputDevice(value); 80 | }, 81 | get audioProcessing() { 82 | return self.media.input.audioProcessing; 83 | }, 84 | set audioProcessing(value) { 85 | self.setInputAudioProcessing(value); 86 | }, 87 | get volume() { 88 | return self.media.input.volume; 89 | }, 90 | set volume(value) { 91 | self.setInputVolume(value); 92 | }, 93 | get muted() { 94 | return self.media.input.muted; 95 | }, 96 | set muted(value) { 97 | self.setInputMuted(value); 98 | } 99 | }; 100 | 101 | this.output = { 102 | get id() { 103 | return self.media.output.id; 104 | }, 105 | set id(value) { 106 | self.setOutputDevice(value); 107 | }, 108 | get volume() { 109 | return self.media.output.volume; 110 | }, 111 | set volume(value) { 112 | self.setOutputVolume(value); 113 | }, 114 | get muted() { 115 | return self.media.output.muted; 116 | }, 117 | set muted(value) { 118 | self.setOutputMuted(value); 119 | } 120 | }; 121 | } 122 | 123 | public async setInput(newInput?: IMediaInput): Promise { 124 | if (newInput === undefined) { 125 | newInput = this.media.input; 126 | } 127 | 128 | // First open a new stream, then close the old one. 129 | const newInputStream = await Media.openInputStream(newInput); 130 | // Close the old inputStream and disconnect from WebRTC. 131 | this.stopInput(); 132 | 133 | this.inputStream = newInputStream; 134 | const sourceNode = audioContext.createMediaStreamSource(newInputStream); 135 | const gainNode = audioContext.createGain(); 136 | gainNode.gain.value = newInput.volume; 137 | sourceNode.connect(gainNode); 138 | 139 | // If muted; don't connect the node to the local stream. 140 | if (!newInput.muted) { 141 | gainNode.connect((this.session as any).__streams.localStream); 142 | } 143 | this.inputNode = gainNode; 144 | this.media.input = newInput; 145 | } 146 | 147 | public async setOutput(newOutput?: IMediaOutput): Promise { 148 | if (newOutput === undefined) { 149 | newOutput = this.media.output; 150 | } 151 | 152 | // Create the new audio output. 153 | const audio = new Audio(); 154 | audio.volume = clamp(newOutput.volume, 0.0, 1.0); 155 | audio.muted = newOutput.muted; 156 | 157 | // Attach it to the correct output device. 158 | await audioContext.resume(); 159 | if (newOutput.id) { 160 | if (Features.webaudio.setSinkId) { 161 | await (audio as any).setSinkId(newOutput.id); 162 | } else { 163 | log.warn('cannot set output device: setSinkId is not supported', 'session-media'); 164 | } 165 | } 166 | 167 | // Close the old audio output. 168 | this.stopOutput(); 169 | 170 | this.audioOutput = audio; 171 | this.media.output = newOutput; 172 | audio.srcObject = (this.session as any).__streams.remoteStream; 173 | 174 | // This can fail if autoplay is not yet allowed. 175 | await audio.play(); 176 | } 177 | 178 | private setInputDevice(id: string | undefined) { 179 | this.setInput(Object.assign({}, this.media.input, { id })); 180 | } 181 | 182 | private setInputAudioProcessing(audioProcessing: boolean) { 183 | log.debug(`setting audioProcessing to: ${audioProcessing}`, 'media'); 184 | this.setInput(Object.assign({}, this.media.input, { audioProcessing })); 185 | } 186 | 187 | private setInputVolume(newVolume: number) { 188 | if (this.inputNode) { 189 | this.inputNode.gain.value = newVolume; 190 | } 191 | this.media.input.volume = newVolume; 192 | } 193 | 194 | private setInputMuted(newMuted: boolean) { 195 | if (this.inputNode) { 196 | if (newMuted) { 197 | try { 198 | this.inputNode.disconnect((this.session as any).__streams.localStream); 199 | } catch (e) { 200 | if (e && e.name === 'InvalidAccessError') { 201 | log.debug('cannot disconnect input audio node as the input is already muted', 'media'); 202 | } else { 203 | throw e; 204 | } 205 | } 206 | } else { 207 | this.inputNode.connect((this.session as any).__streams.localStream); 208 | } 209 | } 210 | 211 | this.media.input.muted = newMuted; 212 | } 213 | 214 | private setOutputDevice(id: string | undefined) { 215 | this.setOutput(Object.assign({}, this.media.output, { id })); 216 | } 217 | 218 | private setOutputVolume(newVolume: number) { 219 | if (this.audioOutput) { 220 | this.audioOutput.volume = newVolume; 221 | } 222 | this.media.output.volume = newVolume; 223 | } 224 | 225 | private setOutputMuted(newMuted: boolean) { 226 | if (this.audioOutput) { 227 | this.audioOutput.muted = newMuted; 228 | } 229 | 230 | this.media.output.muted = newMuted; 231 | } 232 | 233 | private stopInput() { 234 | if (this.inputStream) { 235 | Media.closeStream(this.inputStream); 236 | try { 237 | this.inputNode.disconnect(); 238 | } catch (e) { 239 | if (e && e.name === 'InvalidAccessError') { 240 | log.debug( 241 | 'cannot disconnect input audio node as the input audio node is already disconnected', 242 | 'media' 243 | ); 244 | } else { 245 | throw e; 246 | } 247 | } 248 | } 249 | } 250 | 251 | private stopOutput() { 252 | if (this.audioOutput) { 253 | // HTMLAudioElement can't be stopped, but pause should have the same 254 | // effect. It should be garbage collected if we don't keep references to 255 | // it. 256 | this.audioOutput.pause(); 257 | this.audioOutput.srcObject = undefined; 258 | } 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /src/session-stats.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | 3 | import { Session as UserAgentSession } from 'sip.js/lib/api/session'; 4 | 5 | import { log } from './logger'; 6 | 7 | class StatsAggregation { 8 | private stats: { 9 | count: number; 10 | highest: number; 11 | last: number; 12 | lowest: number; 13 | sum: number; 14 | } = { 15 | count: 0, 16 | highest: undefined, 17 | last: undefined, 18 | lowest: undefined, 19 | sum: 0 20 | }; 21 | 22 | public add(sample: number) { 23 | if (this.stats.count === 0) { 24 | this.stats.lowest = sample; 25 | this.stats.highest = sample; 26 | } else { 27 | this.stats.lowest = Math.min(this.stats.lowest, sample); 28 | this.stats.highest = Math.max(this.stats.highest, sample); 29 | } 30 | this.stats.count += 1; 31 | this.stats.sum += sample; 32 | this.stats.last = sample; 33 | } 34 | 35 | public get last(): number { 36 | return this.stats.last; 37 | } 38 | 39 | public get count(): number { 40 | return this.stats.count; 41 | } 42 | 43 | public get sum(): number { 44 | return this.stats.sum; 45 | } 46 | 47 | public get lowest(): number { 48 | return this.stats.lowest; 49 | } 50 | 51 | public get highest(): number { 52 | return this.stats.highest; 53 | } 54 | 55 | public get average(): number { 56 | if (this.count === 0) { 57 | return undefined; 58 | } 59 | 60 | return this.sum / this.count; 61 | } 62 | } 63 | 64 | export class SessionStats extends EventEmitter { 65 | public readonly mos: StatsAggregation = new StatsAggregation(); 66 | 67 | private statsTimer: number; 68 | private statsInterval: number; 69 | 70 | public constructor( 71 | session: UserAgentSession, 72 | { 73 | statsInterval 74 | }: { 75 | statsInterval: number; 76 | } 77 | ) { 78 | super(); 79 | 80 | this.statsInterval = statsInterval; 81 | 82 | // Set up stats timer to periodically query and process the peer connection's 83 | // statistics and feed them to the stats aggregator. 84 | session.once('SessionDescriptionHandler-created', () => { 85 | this.statsTimer = window.setInterval(() => { 86 | const pc = (session.sessionDescriptionHandler as any).peerConnection; 87 | pc.getStats().then((stats: RTCStatsReport) => { 88 | if (this.add(stats)) { 89 | this.emit('statsUpdated', this); 90 | } else { 91 | log.debug('No useful stats' + stats, this.constructor.name); 92 | } 93 | }); 94 | }, this.statsInterval); 95 | }); 96 | } 97 | 98 | public clearStatsTimer() { 99 | if (this.statsTimer) { 100 | window.clearInterval(this.statsTimer); 101 | delete this.statsTimer; 102 | } 103 | } 104 | 105 | /** 106 | * Add stats for inbound RTP. 107 | * 108 | * See https://developer.mozilla.org/en-US/docs/Web/API/RTCStatsReport 109 | * @param {RTCStatsReport} stats - Stats returned by `pc.getStats()` 110 | * @return {boolean} False if report did not contain any useful stats. 111 | */ 112 | private add(stats: RTCStatsReport): boolean { 113 | let inbound: any; 114 | let candidatePair: any; 115 | 116 | for (const obj of stats.values()) { 117 | if (obj.type === 'inbound-rtp') { 118 | inbound = obj; 119 | } else if (obj.type === 'candidate-pair' && obj.nominated) { 120 | candidatePair = obj; 121 | } 122 | } 123 | 124 | if (inbound && candidatePair) { 125 | const measurement = { 126 | jitter: inbound.jitter, 127 | 128 | // Firefox doesn't have `fractionLost`, fallback to calculating the total 129 | // packet loss. TODO: It would be better to calculate the fraction of lost 130 | // packets since the last measurement. 131 | fractionLost: inbound.fractionLost || inbound.packetsLost / inbound.packetsReceived, 132 | 133 | // Firefox doesn't have or expose this property. Fallback to using 50ms as 134 | // a guess for RTT. 135 | rtt: candidatePair.currentRoundTripTime || 0.05 136 | }; 137 | 138 | this.mos.add(calculateMOS(measurement)); 139 | // this.app.logger.info(`${this}MOS=${measurements.mos.toFixed(2)}`, measurements); 140 | return true; 141 | } 142 | 143 | return false; 144 | } 145 | } 146 | 147 | export interface IMeasurement { 148 | rtt: number; 149 | jitter: number; 150 | fractionLost: number; 151 | } 152 | 153 | /** 154 | * Calculate a Mean Opinion Score (MOS). 155 | * 156 | * Calculation taken from: 157 | * https://www.pingman.com/kb/article/how-is-mos-calculated-in-pingplotter-pro-50.html 158 | * 159 | * @param {Object} options - Options. 160 | * @param {Number} options.rtt - Trip Time in seconds. 161 | * @param {Number} options.jitter - Jitter in seconds. 162 | * @param {Number} options.fractionLost - Fraction of packets lost (0.0 - 1.0) 163 | * @returns {Number} MOS value in range 0.0 (very bad) to 5.0 (very good) 164 | */ 165 | export function calculateMOS({ rtt, jitter, fractionLost }: IMeasurement): number { 166 | // Take the average latency, add jitter, but double the impact to latency 167 | // then add 10 for protocol latencies. 168 | const effectiveLatency = 1000 * (rtt + jitter * 2) + 10; 169 | 170 | // Implement a basic curve - deduct 4 for the R value at 160ms of latency 171 | // (round trip). Anything over that gets a much more aggressive deduction. 172 | let R: number; 173 | if (effectiveLatency < 160) { 174 | R = 93.2 - effectiveLatency / 40; 175 | } else { 176 | R = 93.2 - (effectiveLatency - 120) / 10; 177 | } 178 | 179 | // Now, let's deduct 2.5 R values per percentage of packet loss. 180 | // Never go below 0, then the MOS value would go up again. 181 | R = Math.max(R - fractionLost * 250, 0); 182 | 183 | // Convert the R into an MOS value (this is a known formula). 184 | const MOS = 1 + 0.035 * R + 0.000007 * R * (R - 60) * (100 - R); 185 | 186 | return MOS; 187 | } 188 | -------------------------------------------------------------------------------- /src/session.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import pTimeout from 'p-timeout'; 3 | 4 | import { Core, IncomingResponse, SessionDescriptionHandlerModifiers } from 'sip.js'; 5 | 6 | import { Invitation } from 'sip.js/lib/api/invitation'; 7 | import { Inviter } from 'sip.js/lib/api/inviter'; 8 | import { InviterInviteOptions } from 'sip.js/lib/api/inviter-invite-options'; 9 | import { InvitationRejectOptions } from 'sip.js/lib/api/invitation-reject-options'; 10 | import { Referrer } from 'sip.js/lib/api/referrer'; 11 | import { Session as UserAgentSession } from 'sip.js/lib/api/session'; 12 | import { SessionState } from 'sip.js/lib/api/session-state'; 13 | import { UserAgent } from 'sip.js/lib/api/user-agent'; 14 | import { SessionStatus } from './enums'; 15 | import { createFrozenProxy } from './lib/freeze'; 16 | import { log } from './logger'; 17 | import { checkAudioConnected } from './session-health'; 18 | import { SessionMedia } from './session-media'; 19 | import { SessionStats } from './session-stats'; 20 | import * as Time from './time'; 21 | import { IMedia, IRemoteIdentity } from './types'; 22 | 23 | export interface ISession { 24 | readonly id: string; 25 | readonly media: SessionMedia; 26 | readonly stats: SessionStats; 27 | readonly audioConnected: Promise; 28 | readonly isIncoming: boolean; 29 | saidBye: boolean; 30 | holdState: boolean; 31 | status: SessionStatus; 32 | 33 | /** 34 | * The remote identity of this session. 35 | * @returns {IRemoteIdentity} 36 | */ 37 | remoteIdentity: IRemoteIdentity; 38 | 39 | /** 40 | * The local stream of this session. 41 | * @returns {MediaStream} 42 | */ 43 | localStream: MediaStream; 44 | 45 | /** 46 | * The remote stream of this session. 47 | * @returns {MediaStream} 48 | */ 49 | remoteStream: MediaStream; 50 | 51 | /** 52 | * @returns {boolean} if auto answer is on for this session. 53 | */ 54 | autoAnswer: boolean; 55 | 56 | /** 57 | * @returns {string} Phone number of the remote identity. 58 | */ 59 | phoneNumber: string; 60 | 61 | /** 62 | * @returns {Date} Starting time of the call. 63 | */ 64 | startTime: any; 65 | 66 | /** 67 | * @returns {Date} End time of the call. 68 | */ 69 | endTime: any; 70 | 71 | accept(): Promise; 72 | reject(rejectOptions?: InvitationRejectOptions): Promise; 73 | /** 74 | * Terminate the session. 75 | */ 76 | terminate(): Promise; 77 | 78 | /** 79 | * Promise that resolves when the session is accepted or rejected. 80 | * @returns Promise 81 | */ 82 | accepted(): Promise; 83 | 84 | /** 85 | * Promise that resolves when the session is terminated. 86 | */ 87 | terminated(): Promise; 88 | 89 | reinvite(): Promise; 90 | 91 | /** 92 | * Put the session on hold. 93 | */ 94 | hold(): Promise; 95 | 96 | /** 97 | * Take the session out of hold. 98 | */ 99 | unhold(): Promise; 100 | 101 | /** 102 | * Blind transfer the current session to a target number. 103 | * @param {string} target - Number to transfer to. 104 | */ 105 | blindTransfer(target: string): Promise; 106 | bye(): void; 107 | 108 | /** 109 | * Send one or more DTMF tones. 110 | * @param tones May only contain the characters `0-9A-D#*,` 111 | */ 112 | dtmf(tones: string): void; 113 | 114 | /* tslint:disable:unified-signatures */ 115 | on(event: 'terminated', listener: ({ id: string }) => void): this; 116 | on(event: 'statusUpdate', listener: (session: { id: string; status: string }) => void): this; 117 | on(event: 'callQualityUpdate', listener: ({ id: string }, stats: SessionStats) => void): this; 118 | on( 119 | event: 'remoteIdentityUpdate', 120 | listener: ({ id: string }, remoteIdentity: IRemoteIdentity) => void 121 | ): this; 122 | /* tslint:enable:unified-signatures */ 123 | } 124 | 125 | /** 126 | * SIP already returns a reasonPhrase but for backwards compatibility purposes 127 | * we use this mapping to return an additional reasonCause. 128 | */ 129 | const CAUSE_MAPPING = { 130 | 480: 'temporarily_unavailable', 131 | 484: 'address_incomplete', 132 | 486: 'busy', 133 | 487: 'request_terminated' 134 | }; 135 | 136 | export interface ISessionAccept { 137 | accepted: boolean; 138 | rejectCode?: number; 139 | rejectCause?: string; 140 | rejectPhrase?: string; 141 | } 142 | 143 | export interface ISessionCancelled { 144 | reason?: string; 145 | } 146 | 147 | /** 148 | * @hidden 149 | */ 150 | export class SessionImpl extends EventEmitter implements ISession { 151 | public readonly id: string; 152 | public readonly media: SessionMedia; 153 | public readonly stats: SessionStats; 154 | public readonly audioConnected: Promise; 155 | public readonly isIncoming: boolean; 156 | public saidBye: boolean; 157 | public holdState: boolean; 158 | public status: SessionStatus = SessionStatus.TRYING; 159 | 160 | protected acceptedPromise: Promise; 161 | protected inviteOptions: InviterInviteOptions; 162 | protected session: Inviter | Invitation; 163 | protected terminatedReason?: string; 164 | protected cancelled?: ISessionCancelled; 165 | protected _remoteIdentity: IRemoteIdentity; 166 | 167 | private acceptedSession: any; 168 | 169 | private acceptPromise: Promise; 170 | private rejectPromise: Promise; 171 | private terminatedPromise: Promise; 172 | private reinvitePromise: Promise; 173 | 174 | private onTerminated: (sessionId: string) => void; 175 | 176 | protected constructor({ 177 | session, 178 | media, 179 | onTerminated, 180 | isIncoming 181 | }: { 182 | session: Inviter | Invitation; 183 | media: IMedia; 184 | onTerminated: (sessionId: string) => void; 185 | isIncoming: boolean; 186 | }) { 187 | super(); 188 | this.session = session; 189 | this.id = session.request.callId; 190 | this.media = new SessionMedia(this, media); 191 | this.media.on('mediaFailure', () => { 192 | this.session.bye(); 193 | }); 194 | this.onTerminated = onTerminated; 195 | this.isIncoming = isIncoming; 196 | 197 | // Session stats will calculate a MOS value of the inbound channel every 5 198 | // seconds. 199 | // TODO: make this setting configurable. 200 | this.stats = new SessionStats(this.session, { 201 | statsInterval: 5 * Time.second 202 | }); 203 | 204 | // Terminated promise will resolve when the session is terminated. It will 205 | // be rejected when there is some fault is detected with the session after it 206 | // has been accepted. 207 | this.terminatedPromise = new Promise(resolve => { 208 | this.session.stateChange.on((newState: SessionState) => { 209 | if (newState === SessionState.Terminated) { 210 | this.onTerminated(this.id); 211 | this.emit('terminated', { id: this.id }); 212 | this.status = SessionStatus.TERMINATED; 213 | this.emit('statusUpdate', { id: this.id, status: this.status }); 214 | 215 | // Make sure the stats timer stops periodically quering the peer 216 | // connections statistics. 217 | this.stats.clearStatsTimer(); 218 | 219 | // The cancelled object is currently only used by an Invitation. 220 | // For instance when an incoming call is cancelled by the other 221 | // party or system (i.e. call completed elsewhere). 222 | resolve(this.cancelled ? this.cancelled.reason : undefined); 223 | } 224 | }); 225 | }); 226 | 227 | this._remoteIdentity = this.extractRemoteIdentity(); 228 | 229 | // Track if the other side said bye before terminating. 230 | this.saidBye = false; 231 | this.session.once('bye', () => { 232 | this.saidBye = true; 233 | }); 234 | 235 | this.holdState = false; 236 | 237 | this.stats.on('statsUpdated', () => { 238 | this.emit('callQualityUpdate', { id: this.id }, this.stats); 239 | }); 240 | 241 | // Promise that will resolve when the session's audio is connected. 242 | // TODO: make these settings configurable. 243 | this.audioConnected = checkAudioConnected(this.session, { 244 | checkInterval: 0.5 * Time.second, 245 | noAudioTimeout: 10 * Time.second 246 | }); 247 | } 248 | 249 | get remoteIdentity(): IRemoteIdentity { 250 | return this._remoteIdentity; 251 | } 252 | 253 | get autoAnswer(): boolean { 254 | const callInfo = this.session.request.headers['Call-Info']; 255 | if (callInfo && callInfo[0]) { 256 | // ugly, not sure how to check if object with TS agreeing on my methods 257 | return (callInfo[0] as { parsed?: any; raw: string }).raw.includes('answer-after=0'); 258 | } 259 | 260 | return false; 261 | } 262 | 263 | get phoneNumber(): string { 264 | if (this.isIncoming) { 265 | return this.remoteIdentity.phoneNumber; 266 | } else { 267 | return this.session.request.to.uri.user; 268 | } 269 | } 270 | 271 | get startTime(): Date { 272 | return this.session.startTime; 273 | } 274 | 275 | get endTime(): Date { 276 | return this.session.endTime; 277 | } 278 | 279 | public accept(): Promise { 280 | throw new Error('Should be implemented in superclass'); 281 | } 282 | 283 | public reject(): Promise { 284 | throw new Error('Should be implemented in superclass'); 285 | } 286 | 287 | public accepted(): Promise { 288 | throw new Error('Should be implemented in superclass'); 289 | } 290 | 291 | public terminate(): Promise { 292 | return this.bye(); 293 | } 294 | 295 | public terminated(): Promise { 296 | return this.terminatedPromise; 297 | } 298 | 299 | public async reinvite(modifiers: SessionDescriptionHandlerModifiers = []): Promise { 300 | await new Promise((resolve, reject) => { 301 | this.session.invite( 302 | this.makeInviteOptions({ 303 | onAccept: resolve, 304 | onReject: reject, 305 | onRejectThrow: reject, 306 | onProgress: resolve, 307 | onTrying: resolve, 308 | sessionDescriptionHandlerModifiers: modifiers 309 | }) 310 | ); 311 | }); 312 | } 313 | 314 | public hold(): Promise { 315 | return this.setHoldState(true); 316 | } 317 | 318 | public unhold(): Promise { 319 | return this.setHoldState(false); 320 | } 321 | 322 | public async blindTransfer(target: string): Promise { 323 | return this.transfer(UserAgent.makeURI(target)).then(success => { 324 | if (success) { 325 | this.bye(); 326 | } 327 | 328 | return Promise.resolve(success); 329 | }); 330 | } 331 | 332 | public async attendedTransfer(target: SessionImpl): Promise { 333 | return this.transfer(target.session).then(success => { 334 | if (success) { 335 | this.bye(); 336 | } 337 | 338 | return Promise.resolve(success); 339 | }); 340 | } 341 | 342 | /** 343 | * Reconfigure the WebRTC peerconnection. 344 | */ 345 | public rebuildSessionDescriptionHandler() { 346 | (this.session as any)._sessionDescriptionHandler = undefined; 347 | (this.session as any).setupSessionDescriptionHandler(); 348 | } 349 | 350 | public bye() { 351 | return this.session.bye(); 352 | } 353 | 354 | /** 355 | * Returns true if the DTMF was successful. 356 | */ 357 | public dtmf(tones: string, options?: any): boolean { 358 | // Unfortunately there is no easy way to give feedback about the DTMF 359 | // tones. SIP.js uses one of two methods for sending the DTMF: 360 | // 361 | // 1. RTP (via the SDH) 362 | // Internally returns a `boolean` for the whole string. 363 | // 364 | // 2. INFO (websocket) 365 | // 366 | // Sends one tone after the other where the timeout is determined by the kind 367 | // of tone send. If one tone fails, the entire sequence is cleared. There is 368 | // no feedback about the failure. 369 | // 370 | // For now only use the RTP method using the session description handler. 371 | return this.session.sessionDescriptionHandler.sendDtmf(tones, options); 372 | } 373 | 374 | public get localStream() { 375 | return (this.session as any).__streams.localStream; 376 | } 377 | 378 | public get remoteStream() { 379 | return (this.session as any).__streams.remoteStream; 380 | } 381 | 382 | public freeze(): ISession { 383 | return createFrozenProxy({}, this, [ 384 | 'audioConnected', 385 | 'autoAnswer', 386 | 'endTime', 387 | 'holdState', 388 | 'id', 389 | 'isIncoming', 390 | 'media', 391 | 'phoneNumber', 392 | 'remoteIdentity', 393 | 'saidBye', 394 | 'startTime', 395 | 'stats', 396 | 'status', 397 | 'accept', 398 | 'accepted', 399 | 'attendedTransfer', 400 | 'blindTransfer', 401 | 'bye', 402 | 'dtmf', 403 | 'freeze', 404 | 'hold', 405 | 'reinvite', 406 | 'reject', 407 | 'terminate', 408 | 'terminated', 409 | 'unhold', 410 | 'on', 411 | 'once', 412 | 'removeAllListeners', 413 | 'removeListener', 414 | 'cancel', 415 | 'tried', 416 | 'localStream', 417 | 'remoteStream' 418 | ]); 419 | } 420 | 421 | protected makeInviteOptions({ 422 | onAccept, 423 | onReject, 424 | onRejectThrow, 425 | onProgress, 426 | onTrying, 427 | sessionDescriptionHandlerModifiers = [] 428 | }) { 429 | return { 430 | requestDelegate: { 431 | onAccept: () => { 432 | this.status = SessionStatus.ACTIVE; 433 | this.emit('statusUpdate', { id: this.id, status: this.status }); 434 | this._remoteIdentity = this.extractRemoteIdentity(); 435 | this.emit('remoteIdentityUpdate', this, this.remoteIdentity); 436 | 437 | onAccept({ accepted: true }); 438 | }, 439 | onReject: ({ message }: Core.IncomingResponse) => { 440 | log.info('Session is rejected.', this.constructor.name); 441 | log.debug(message, this.constructor.name); 442 | 443 | onReject({ 444 | accepted: false, 445 | rejectCode: message.statusCode, 446 | rejectCause: CAUSE_MAPPING[message.statusCode], 447 | rejectPhrase: message.reasonPhrase 448 | }); 449 | }, 450 | onProgress: ({ message }: Core.IncomingResponse) => { 451 | log.debug('Session is in progress', this.constructor.name); 452 | this.emit('progressUpdate', { message }); 453 | onProgress(); 454 | }, 455 | onTrying: () => { 456 | log.debug('Trying to setup the session', this.constructor.name); 457 | onTrying(); 458 | } 459 | }, 460 | sessionDescriptionHandlerOptions: { 461 | constraints: { 462 | audio: true, 463 | video: false 464 | } 465 | }, 466 | sessionDescriptionHandlerModifiers 467 | }; 468 | } 469 | 470 | protected extractRemoteIdentity() { 471 | let phoneNumber: string = this.session.remoteIdentity.uri.user; 472 | let displayName: string; 473 | if (this.session.assertedIdentity) { 474 | phoneNumber = this.session.assertedIdentity.uri.user; 475 | displayName = this.session.assertedIdentity.displayName; 476 | } 477 | 478 | return { phoneNumber, displayName }; 479 | } 480 | 481 | private async setHoldState(flag: boolean) { 482 | if (this.holdState === flag) { 483 | return this.reinvitePromise; 484 | } 485 | 486 | const modifiers = []; 487 | if (flag) { 488 | log.debug('Hold requested', this.constructor.name); 489 | modifiers.push(this.session.sessionDescriptionHandler.holdModifier); 490 | } else { 491 | log.debug('Unhold requested', this.constructor.name); 492 | } 493 | 494 | await this.reinvite(modifiers); 495 | 496 | this.holdState = flag; 497 | 498 | this.status = flag ? SessionStatus.ON_HOLD : SessionStatus.ACTIVE; 499 | this.emit('statusUpdate', { id: this.id, status: this.status }); 500 | 501 | return this.reinvitePromise; 502 | } 503 | 504 | /** 505 | * Generic transfer function that either does a blind or attended 506 | * transfer. Which kind of transfer is done is dependent on the type of 507 | * `target` passed. 508 | * 509 | * In the case of a BLIND transfer, a string can be passed along with a 510 | * number. 511 | * 512 | * In the case of an ATTENDED transfer, a NEW call should be made. This NEW 513 | * session (a.k.a. InviteClientContext/InviteServerContext depending on 514 | * whether it is outbound or inbound) should then be passed to this function. 515 | * 516 | * @param {UserAgentSession | string} target - Target to transfer this session to. 517 | * @returns {Promise} Promise that resolves when the transfer is made. 518 | */ 519 | private async transfer(target: Core.URI | UserAgentSession): Promise { 520 | return pTimeout(this.isTransferredPromise(target), 20000, () => { 521 | log.error('Could not transfer the call', this.constructor.name); 522 | return Promise.resolve(false); 523 | }); 524 | } 525 | 526 | private async isTransferredPromise(target: Core.URI | UserAgentSession) { 527 | return new Promise(resolve => { 528 | const referrer = new Referrer(this.session, target); 529 | 530 | referrer.refer({ 531 | requestDelegate: { 532 | onAccept: () => { 533 | log.info('Transferred session is accepted!', this.constructor.name); 534 | 535 | resolve(true); 536 | }, 537 | // Refer can be rejected with the following responses: 538 | // - 503: Service Unavailable (i.e. server can't handle one-legged transfers) 539 | // - 603: Declined 540 | onReject: () => { 541 | log.info('Transferred session is rejected!', this.constructor.name); 542 | resolve(false); 543 | }, 544 | onNotify: () => ({}) // To make sure the requestDelegate type is complete. 545 | } 546 | }); 547 | }); 548 | } 549 | } 550 | -------------------------------------------------------------------------------- /src/sound.ts: -------------------------------------------------------------------------------- 1 | import { audioContext } from './audio-context'; 2 | import * as Features from './features'; 3 | import { clamp } from './lib/utils'; 4 | import { log } from './logger'; 5 | import { MediaDeviceId } from './types'; 6 | 7 | interface ISoundOptions { 8 | volume?: number; 9 | overlap?: boolean; 10 | sinkId?: MediaDeviceId; 11 | } 12 | 13 | export class Sound { 14 | public readonly uri: string; 15 | 16 | private samples: HTMLAudioElement[] = []; 17 | private options: ISoundOptions; 18 | private stopTimer?: number; 19 | 20 | constructor(uri: string, options: ISoundOptions = {}) { 21 | this.uri = uri; 22 | this.options = { 23 | volume: options.volume === undefined ? 1.0 : options.volume, 24 | overlap: options.overlap === undefined ? false : options.overlap, 25 | sinkId: options.sinkId 26 | }; 27 | } 28 | 29 | public get playing(): boolean { 30 | return this.samples.length > 0; 31 | } 32 | 33 | public get volume(): number { 34 | return this.options.volume; 35 | } 36 | 37 | public set volume(newVolume: number) { 38 | this.options.volume = newVolume; 39 | this.samples.forEach(s => { 40 | s.volume = newVolume; 41 | }); 42 | } 43 | 44 | public get sinkId(): MediaDeviceId { 45 | return this.options.sinkId; 46 | } 47 | 48 | public set sinkId(newSinkId: MediaDeviceId) { 49 | this.options.sinkId = newSinkId; 50 | if (Features.webaudio.setSinkId) { 51 | this.samples.forEach(s => { 52 | (s as any).setSinkId(newSinkId); 53 | }); 54 | } else { 55 | log.warn('cannot set output device: setSinkId is not supported', 'sound'); 56 | } 57 | } 58 | 59 | public async play( 60 | { loop, timeout }: { loop: boolean; timeout: number } = { loop: false, timeout: undefined } 61 | ): Promise { 62 | if (this.options.overlap && loop) { 63 | throw new Error('loop and overlap cannot be combined'); 64 | } 65 | 66 | if (!this.options.overlap && this.playing) { 67 | log.warn('sound is already playing.', this.constructor.name); 68 | throw new Error('sound is already playing.'); 69 | } 70 | 71 | const sample = new Audio(); 72 | sample.volume = clamp(this.options.volume, 0.0, 1.0); 73 | sample.loop = loop; 74 | this.samples.push(sample); 75 | 76 | const cleanup = () => { 77 | if (this.stopTimer) { 78 | window.clearTimeout(this.stopTimer); 79 | delete this.stopTimer; 80 | } 81 | this.samples = this.samples.filter(s => s !== sample); 82 | }; 83 | 84 | const resultPromise = new Promise((resolve, reject) => { 85 | sample.addEventListener('error', e => { 86 | cleanup(); 87 | reject(e); 88 | }); 89 | 90 | sample.addEventListener('loadeddata', async () => { 91 | try { 92 | // Wake up audio context to prevent the error "require user interaction 93 | // before playing audio". 94 | await audioContext.resume(); 95 | 96 | // Set the output sink if applicable. 97 | if (this.options.sinkId) { 98 | if (Features.webaudio.setSinkId) { 99 | await (sample as any).setSinkId(this.options.sinkId); 100 | } else { 101 | log.warn('cannot set output device: setSinkId is not supported', 'sound'); 102 | } 103 | } 104 | 105 | if (!this.samples.includes(sample)) { 106 | resolve(); 107 | return; 108 | } 109 | 110 | if (timeout) { 111 | this.stopTimer = window.setTimeout(() => this.stop(), timeout); 112 | } 113 | 114 | sample.addEventListener('pause', () => { 115 | cleanup(); 116 | resolve(); 117 | }); 118 | 119 | sample.addEventListener('ended', () => { 120 | cleanup(); 121 | resolve(); 122 | }); 123 | 124 | await sample.play(); 125 | } catch (e) { 126 | cleanup(); 127 | reject(e); 128 | } 129 | }); 130 | }); 131 | 132 | sample.src = this.uri; 133 | 134 | return await resultPromise; 135 | } 136 | 137 | public stop() { 138 | this.samples.forEach(s => { 139 | s.currentTime = 0; 140 | s.pause(); 141 | }); 142 | 143 | this.samples = []; 144 | this.stopTimer = undefined; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/subscription.ts: -------------------------------------------------------------------------------- 1 | import { SubscriptionStatus } from './enums'; 2 | 3 | export { Subscription } from 'sip.js'; 4 | 5 | import { log } from './logger'; 6 | 7 | /** 8 | * Parse an incoming dialog XML request body and return 9 | * the account state from it. 10 | * @param {Request} notification - A SIP.js Request object. 11 | * @returns {string} - The state of the account. 12 | */ 13 | export function statusFromDialog(notification: any): SubscriptionStatus | string { 14 | const parser = new DOMParser(); 15 | const xmlDoc = parser ? parser.parseFromString(notification.request.body, 'text/xml') : null; 16 | const dialogNode = xmlDoc ? xmlDoc.getElementsByTagName('dialog-info')[0] : null; 17 | // Skip; an invalid dialog. 18 | if (!dialogNode) { 19 | log.error( 20 | `[blf] ${notification} \n did not result in a valid dialogNode`, 21 | 'subscription.statusFromDialog' 22 | ); 23 | return null; 24 | } 25 | 26 | const stateNode = dialogNode.getElementsByTagName('state')[0]; 27 | 28 | let state: SubscriptionStatus | string = SubscriptionStatus.AVAILABLE; 29 | 30 | // State node has final say, regardless of stateAttr! 31 | if (stateNode) { 32 | switch (stateNode.textContent) { 33 | case SubscriptionStatus.TRYING: 34 | case SubscriptionStatus.PROCEEDING: 35 | case SubscriptionStatus.EARLY: 36 | state = SubscriptionStatus.RINGING; 37 | break; 38 | case SubscriptionStatus.CONFIRMED: 39 | state = SubscriptionStatus.BUSY; 40 | break; 41 | case SubscriptionStatus.TERMINATED: 42 | state = SubscriptionStatus.AVAILABLE; 43 | break; 44 | default: 45 | state = stateNode.textContent; // To allow for custom statuses 46 | break; 47 | } 48 | } 49 | 50 | return state; 51 | } 52 | -------------------------------------------------------------------------------- /src/time.ts: -------------------------------------------------------------------------------- 1 | export const millisecond = 1; 2 | export const second = 1000 * millisecond; 3 | export const minute = 60 * second; 4 | export const hour = 60 * minute; 5 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { ITransportDelegate } from './transport'; 2 | 3 | export interface IClientOptions { 4 | account: { 5 | user: string; 6 | password: string; 7 | uri: string; 8 | name: string; 9 | }; 10 | transport: { 11 | wsServers: string; 12 | iceServers: string[]; 13 | delegate?: ITransportDelegate; 14 | }; 15 | media: IMedia; 16 | userAgentString?: string; 17 | } 18 | 19 | export type MediaDeviceId = string | undefined; 20 | 21 | export interface IMediaDevice { 22 | // undefined means let the browser pick the default. 23 | id: MediaDeviceId; 24 | 25 | volume: number; 26 | muted: boolean; 27 | } 28 | 29 | export interface IMediaInput extends IMediaDevice { 30 | audioProcessing: boolean; 31 | } 32 | 33 | export type IMediaOutput = IMediaDevice; 34 | 35 | export interface IMedia { 36 | input: IMediaInput; 37 | output: IMediaOutput; 38 | } 39 | 40 | export interface IRemoteIdentity { 41 | phoneNumber: string; 42 | displayName: string; 43 | } 44 | 45 | export interface IRetry { 46 | interval: number; 47 | limit: number; 48 | timeout: number; 49 | } 50 | -------------------------------------------------------------------------------- /test/_helpers.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import * as sinon from 'sinon'; 3 | import { UA as UABase } from 'sip.js'; 4 | 5 | import { UserAgent } from 'sip.js/lib/api/user-agent'; 6 | import { UserAgentOptions } from 'sip.js/lib/api/user-agent-options'; 7 | 8 | import { ClientImpl } from '../src/client'; 9 | import { ClientStatus } from '../src/enums'; 10 | import * as Features from '../src/features'; 11 | import { Client, IClientOptions } from '../src/index'; 12 | import { ReconnectableTransport, TransportFactory, UAFactory } from '../src/transport'; 13 | 14 | export function defaultUAFactory() { 15 | return (options: UserAgentOptions) => new UserAgent(options); 16 | } 17 | 18 | export function defaultTransportFactory() { 19 | return (uaFactory: UAFactory, options: IClientOptions) => 20 | new ReconnectableTransport(uaFactory, options); 21 | } 22 | 23 | export function createClientImpl( 24 | uaFactory: UAFactory, 25 | transportFactory: TransportFactory, 26 | additionalOptions: UserAgentOptions = {} 27 | ): ClientImpl { 28 | return new ClientImpl( 29 | uaFactory, 30 | transportFactory, 31 | Object.assign(minimalOptions(), additionalOptions) 32 | ); 33 | } 34 | 35 | export function createClient() { 36 | return new Client(minimalOptions()); 37 | } 38 | 39 | export function minimalOptions() { 40 | return { 41 | account: { 42 | user: '', 43 | password: '', 44 | uri: '', 45 | name: '' 46 | }, 47 | transport: { 48 | wsServers: '', 49 | iceServers: [] 50 | }, 51 | media: { 52 | input: { 53 | id: '', 54 | volume: 1.0, 55 | audioProcessing: false, 56 | muted: false 57 | }, 58 | output: { 59 | id: '', 60 | volume: 1.0, 61 | muted: false 62 | } 63 | } 64 | }; 65 | } 66 | 67 | // https://stackoverflow.com/a/37900956 68 | test.afterEach.always(() => { 69 | sinon.restore(); 70 | }); 71 | -------------------------------------------------------------------------------- /test/_setup-browser-env.js: -------------------------------------------------------------------------------- 1 | import browserEnv from 'browser-env'; 2 | browserEnv(['window', 'navigator']); 3 | -------------------------------------------------------------------------------- /test/client-connect.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import * as sinon from 'sinon'; 3 | import { Core, UA as UABase } from 'sip.js'; 4 | 5 | import { Registerer } from 'sip.js/lib/api/registerer'; 6 | import { RegistererState } from 'sip.js/lib/api/registerer-state'; 7 | import { UserAgent } from 'sip.js/lib/api/user-agent'; 8 | import { UserAgentOptions } from 'sip.js/lib/api/user-agent-options'; 9 | 10 | import { ClientImpl } from '../src/client'; 11 | import { ClientStatus } from '../src/enums'; 12 | import * as Features from '../src/features'; 13 | import { HealthChecker } from '../src/health-checker'; 14 | import { Client, IClientOptions } from '../src/index'; 15 | import { 16 | ReconnectableTransport, 17 | TransportFactory, 18 | UAFactory, 19 | WrappedTransport 20 | } from '../src/transport'; 21 | 22 | import { createClientImpl, defaultTransportFactory, defaultUAFactory } from './_helpers'; 23 | 24 | test.serial('client connect', async t => { 25 | sinon.stub(Features, 'checkRequired').returns(true); 26 | 27 | const transport = sinon.createStubInstance(ReconnectableTransport); 28 | transport.connect.returns(Promise.resolve()); 29 | transport.disconnect.returns(Promise.resolve()); 30 | 31 | const client = createClientImpl(defaultUAFactory(), () => transport); 32 | await client.connect(); 33 | t.true(transport.connect.called); 34 | }); 35 | 36 | test.serial('cannot connect client when recovering', async t => { 37 | sinon.stub(Features, 'checkRequired').returns(true); 38 | 39 | const client = createClientImpl(defaultUAFactory(), defaultTransportFactory()); 40 | (client as any).transport.status = ClientStatus.RECOVERING; 41 | 42 | const error = await t.throwsAsync(() => client.connect()); 43 | t.is(error.message, 'Can not connect while trying to recover.'); 44 | }); 45 | 46 | test.serial('return true when already connected', async t => { 47 | sinon.stub(Features, 'checkRequired').returns(true); 48 | 49 | const client = createClientImpl(defaultUAFactory(), defaultTransportFactory()); 50 | 51 | (client as any).transport.registeredPromise = Promise.resolve(true); 52 | (client as any).transport.status = ClientStatus.CONNECTED; 53 | 54 | const connected = client.connect(); 55 | t.true(await connected); 56 | }); 57 | 58 | test.serial.cb('emits connecting status after connect is called', t => { 59 | sinon.stub(Features, 'checkRequired').returns(true); 60 | 61 | const ua = sinon.createStubInstance(UserAgent, { 62 | start: Promise.resolve() 63 | }); 64 | 65 | (ua as any).transport = sinon.createStubInstance(WrappedTransport, { 66 | on: sinon.fake() as any 67 | }); 68 | 69 | const client = createClientImpl(() => (ua as unknown) as UserAgent, defaultTransportFactory()); 70 | 71 | (client as any).transport.createRegisteredPromise = () => { 72 | (client as any).transport.registerer = sinon.createStubInstance(Registerer); 73 | return Promise.resolve(); 74 | }; 75 | 76 | (client as any).transport.createHealthChecker = () => { 77 | (client as any).transport.healthChecker = sinon.createStubInstance(HealthChecker, { 78 | start: sinon.fake() 79 | }); 80 | }; 81 | 82 | t.plan(3); 83 | client.on('statusUpdate', status => { 84 | // Shortly after calling connect ClientStatus should be CONNECTING. 85 | t.is(status, ClientStatus.CONNECTING); 86 | t.is((client as any).transport.status, ClientStatus.CONNECTING); 87 | t.end(); 88 | }); 89 | 90 | t.is((client as any).transport.status, ClientStatus.DISCONNECTED); 91 | 92 | client.connect(); 93 | }); 94 | 95 | test.serial('emits connected status after register is emitted', async t => { 96 | sinon.stub(Features, 'checkRequired').returns(true); 97 | 98 | const uaFactory = (options: UserAgentOptions) => { 99 | const userAgent = new UserAgent(options); 100 | userAgent.start = () => Promise.resolve(); 101 | return userAgent; 102 | }; 103 | 104 | const client = createClientImpl(uaFactory, defaultTransportFactory()); 105 | 106 | t.plan(3); 107 | client.on('statusUpdate', status => { 108 | if (status === ClientStatus.CONNECTED) { 109 | t.is(status, ClientStatus.CONNECTED); 110 | } 111 | }); 112 | 113 | t.is((client as any).transport.status, ClientStatus.DISCONNECTED); 114 | 115 | (client as any).transport.createRegisteredPromise = () => { 116 | (client as any).transport.registerer = sinon.createStubInstance(Registerer); 117 | (client as any).transport.updateStatus(ClientStatus.CONNECTED); 118 | return Promise.resolve(); 119 | }; 120 | 121 | (client as any).transport.createHealthChecker = () => { 122 | (client as any).transport.healthChecker = sinon.createStubInstance(HealthChecker, { 123 | start: sinon.fake() 124 | }); 125 | }; 126 | 127 | await (client as any).transport.connect(); 128 | 129 | // After resolving connect ClientStatus should be CONNECTED. 130 | t.is((client as any).transport.status, ClientStatus.CONNECTED); 131 | }); 132 | 133 | test.serial('emits disconnected status after registrationFailed is emitted', async t => { 134 | sinon.stub(Features, 'checkRequired').returns(true); 135 | 136 | const uaFactory = (options: UserAgentOptions) => { 137 | const userAgent = new UserAgent(options); 138 | userAgent.start = () => Promise.resolve(); 139 | return userAgent; 140 | }; 141 | 142 | const transport = (ua: UAFactory, options: IClientOptions) => { 143 | const reconnectableTransport = new ReconnectableTransport(ua, options); 144 | reconnectableTransport.disconnect = sinon.fake(); 145 | return reconnectableTransport; 146 | }; 147 | 148 | const client = createClientImpl(uaFactory, transport); 149 | 150 | t.plan(4); 151 | client.on('statusUpdate', status => { 152 | if (status === ClientStatus.DISCONNECTED) { 153 | t.is(status, ClientStatus.DISCONNECTED); 154 | } 155 | }); 156 | 157 | t.is((client as any).transport.status, ClientStatus.DISCONNECTED); 158 | 159 | (client as any).transport.createRegisteredPromise = () => { 160 | (client as any).transport.registerer = sinon.createStubInstance(Registerer); 161 | (client as any).transport.updateStatus(ClientStatus.DISCONNECTED); 162 | return Promise.reject(new Error('Could not register.')); 163 | }; 164 | 165 | (client as any).transport.createHealthChecker = () => { 166 | (client as any).transport.healthChecker = sinon.createStubInstance(HealthChecker, { 167 | start: sinon.fake() 168 | }); 169 | }; 170 | 171 | const error = await t.throwsAsync(() => client.connect()); 172 | 173 | // After rejecting connect (and subsequently disconnecting) 174 | // ClientStatus should be DISCONNECTED. 175 | t.is((client as any).transport.status, ClientStatus.DISCONNECTED); 176 | }); 177 | 178 | test.serial("rejects when transport doesn't connect within timeout", async t => { 179 | sinon.stub(Features, 'checkRequired').returns(true); 180 | const uaFactory = (options: UserAgentOptions) => { 181 | const userAgent = new UserAgent(options); 182 | userAgent.start = () => { 183 | return new Promise(resolve => { 184 | setTimeout(() => resolve(), 250); 185 | }); 186 | }; 187 | return userAgent; 188 | }; 189 | 190 | const client = createClientImpl(uaFactory, defaultTransportFactory()); 191 | 192 | (client as any).transport.configureUA((client as any).transport.uaOptions); 193 | (client as any).transport.wsTimeout = 200; // setting timeout to 200 ms to avoid waiting 10s 194 | 195 | const error = await t.throwsAsync(() => (client as any).transport.connect()); 196 | t.is(error.message, 'Could not connect to the websocket in time.'); 197 | }); 198 | 199 | test.serial('ua.start called on first connect', t => { 200 | sinon.stub(Features, 'checkRequired').returns(true); 201 | const ua = sinon.createStubInstance(UserAgent, { start: Promise.resolve() }); 202 | (ua as any).transport = sinon.createStubInstance(WrappedTransport, { on: sinon.fake() as any }); 203 | 204 | const client = createClientImpl(() => (ua as unknown) as UserAgent, defaultTransportFactory()); 205 | 206 | (client as any).transport.createRegisteredPromise = () => { 207 | (client as any).transport.registerer = sinon.createStubInstance(Registerer); 208 | return Promise.resolve(); 209 | }; 210 | 211 | (client as any).transport.createHealthChecker = () => { 212 | (client as any).transport.healthChecker = sinon.createStubInstance(HealthChecker, { 213 | start: sinon.fake() 214 | }); 215 | }; 216 | 217 | client.connect(); 218 | 219 | t.true(ua.start.called); 220 | }); 221 | 222 | test.serial('userAgentString is correct', t => { 223 | sinon.stub(Features, 'checkRequired').returns(true); 224 | const userAgentString = 'Test UserAgent string'; 225 | const client = createClientImpl(defaultUAFactory(), defaultTransportFactory(), { 226 | userAgentString 227 | }); 228 | t.is((client as any).transport.uaOptions.userAgentString, userAgentString); 229 | }); 230 | -------------------------------------------------------------------------------- /test/client-disconnect.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import pTimeout from 'p-timeout'; 3 | import * as sinon from 'sinon'; 4 | import { Subscription, UA as UABase } from 'sip.js'; 5 | 6 | import { UserAgent } from 'sip.js/lib/api/user-agent'; 7 | import { UserAgentOptions } from 'sip.js/lib/api/user-agent-options'; 8 | 9 | import { ClientImpl } from '../src/client'; 10 | import { ClientStatus } from '../src/enums'; 11 | import * as Features from '../src/features'; 12 | import { Client, IClientOptions } from '../src/index'; 13 | import { log } from '../src/logger'; 14 | import { ReconnectableTransport, TransportFactory, UAFactory } from '../src/transport'; 15 | 16 | import { createClientImpl, defaultTransportFactory, defaultUAFactory } from './_helpers'; 17 | 18 | test.serial('remove subscriptions', async t => { 19 | sinon.stub(Features, 'checkRequired').returns(true); 20 | const transport = sinon.createStubInstance(ReconnectableTransport); 21 | const client = createClientImpl(defaultUAFactory(), () => transport); 22 | const subscription = sinon.createStubInstance(Subscription); 23 | 24 | (client as any).subscriptions = { '1337@someprovider': subscription }; 25 | await client.disconnect(); 26 | 27 | t.deepEqual((client as any).subscriptions, {}); 28 | }); 29 | 30 | test.serial('do not try to disconnect when already disconnected (no ua)', async t => { 31 | sinon.stub(Features, 'checkRequired').returns(true); 32 | log.info = sinon.fake(); 33 | 34 | const client = createClientImpl(defaultUAFactory(), defaultTransportFactory()); 35 | 36 | // UA is not configured here. 37 | (client as any).transport.status = ClientStatus.CONNECTED; 38 | 39 | await client.disconnect(); 40 | 41 | t.true((log.info as any).calledWith('Already disconnected.')); 42 | }); 43 | 44 | test.serial('do not try to disconnect when already disconnected (status DISCONNECTED)', async t => { 45 | sinon.stub(Features, 'checkRequired').returns(true); 46 | log.info = sinon.fake(); 47 | 48 | const client = createClientImpl(defaultUAFactory(), defaultTransportFactory()); 49 | 50 | (client as any).transport.configureUA((client as any).transport.uaOptions); 51 | (client as any).transport.status = ClientStatus.DISCONNECTED; 52 | 53 | await client.disconnect(); 54 | 55 | t.true((log.info as any).calledWith('Already disconnected.')); 56 | }); 57 | 58 | test.serial('status updates in order: DISCONNECTING > DISCONNECTED', async t => { 59 | sinon.stub(Features, 'checkRequired').returns(true); 60 | 61 | const ua = (options: UserAgentOptions) => { 62 | const userAgent = new UserAgent(options); 63 | userAgent.stop = () => Promise.resolve(); 64 | userAgent.transport.disconnect = () => Promise.resolve(); 65 | return userAgent; 66 | }; 67 | 68 | const client = createClientImpl(ua, defaultTransportFactory()); 69 | (client as any).transport.createUnregisteredPromise = () => { 70 | (client as any).transport.unregisteredPromise = () => Promise.resolve(); 71 | (client as any).transport.unregisterer = sinon.fake(); 72 | (client as any).transport.unregisterer.unregister = () => sinon.fake(); 73 | }; 74 | 75 | const status = []; 76 | client.on('statusUpdate', clientStatus => status.push(clientStatus)); 77 | 78 | (client as any).transport.configureUA((client as any).transport.uaOptions); 79 | (client as any).transport.status = ClientStatus.CONNECTED; 80 | 81 | await client.disconnect(); 82 | 83 | t.is(status.length, 2); 84 | t.is(status[0], ClientStatus.DISCONNECTING); 85 | t.is(status[1], ClientStatus.DISCONNECTED); 86 | t.is((client as any).transport.status, ClientStatus.DISCONNECTED); 87 | }); 88 | 89 | test.serial('disconnected does not resolve until unregistered', async t => { 90 | sinon.stub(Features, 'checkRequired').returns(true); 91 | 92 | const ua = (options: UserAgentOptions) => { 93 | const userAgent = new UserAgent(options); 94 | return userAgent; 95 | }; 96 | 97 | const client = createClientImpl(ua, defaultTransportFactory()); 98 | 99 | const status = []; 100 | client.on('statusUpdate', clientStatus => status.push(clientStatus)); 101 | 102 | (client as any).transport.configureUA((client as any).transport.uaOptions); 103 | (client as any).transport.status = ClientStatus.CONNECTED; 104 | 105 | // Wait for 100 ms and catch the error thrown because it never resolves. 106 | await t.throwsAsync(pTimeout(client.disconnect(), 100)); 107 | 108 | t.is(status.length, 1); 109 | t.is(status[0], ClientStatus.DISCONNECTING); 110 | t.is((client as any).transport.status, ClientStatus.DISCONNECTING); 111 | }); 112 | 113 | test.serial('ua.stop is not called without unregistered event', async t => { 114 | sinon.stub(Features, 'checkRequired').returns(true); 115 | 116 | const ua = (options: UserAgentOptions) => { 117 | const userAgent = new UserAgent(options); 118 | userAgent.stop = sinon.fake(); 119 | userAgent.transport.disconnect = () => Promise.resolve(); 120 | return userAgent; 121 | }; 122 | 123 | const client = createClientImpl(ua, defaultTransportFactory()); 124 | 125 | (client as any).transport.configureUA((client as any).transport.uaOptions); 126 | (client as any).transport.status = ClientStatus.CONNECTED; 127 | 128 | // calling ua.unregister will not cause ua to emit an unregistered event. 129 | // ua.disconnected will never be called as it waits for the unregistered 130 | // event. 131 | await t.throwsAsync(pTimeout(client.disconnect(), 100)); 132 | 133 | t.false((client as any).transport.userAgent.stop.called); 134 | }); 135 | 136 | test.serial('ua is removed after ua.disconnect', async t => { 137 | sinon.stub(Features, 'checkRequired').returns(true); 138 | 139 | const ua = (options: UserAgentOptions) => { 140 | const userAgent = new UserAgent(options); 141 | userAgent.stop = sinon.fake(); 142 | userAgent.transport.disconnect = () => Promise.resolve(); 143 | return userAgent; 144 | }; 145 | 146 | const client = createClientImpl(ua, defaultTransportFactory()); 147 | (client as any).transport.createUnregisteredPromise = () => { 148 | (client as any).transport.unregisteredPromise = () => Promise.resolve(); 149 | (client as any).transport.unregisterer = sinon.fake(); 150 | (client as any).transport.unregisterer.unregister = () => sinon.fake(); 151 | }; 152 | 153 | (client as any).transport.configureUA((client as any).transport.uaOptions); 154 | (client as any).transport.status = ClientStatus.CONNECTED; 155 | 156 | t.false((client as any).transport.userAgent === undefined); 157 | 158 | await client.disconnect(); 159 | 160 | t.true((client as any).transport.userAgent === undefined); 161 | }); 162 | 163 | test.serial('not waiting for unregistered if hasRegistered = false', async t => { 164 | sinon.stub(Features, 'checkRequired').returns(true); 165 | const ua = (options: UserAgentOptions) => { 166 | const userAgent = new UserAgent(options); 167 | userAgent.stop = sinon.fake(); 168 | userAgent.transport.disconnect = () => Promise.resolve(); 169 | return userAgent; 170 | }; 171 | 172 | const client = createClientImpl(ua, defaultTransportFactory()); 173 | client.disconnect = async () => { 174 | await (client as any).transport.disconnect({ hasRegistered: false }); 175 | }; 176 | 177 | const status = []; 178 | client.on('statusUpdate', clientStatus => status.push(clientStatus)); 179 | 180 | (client as any).transport.configureUA((client as any).transport.uaOptions); 181 | (client as any).transport.status = ClientStatus.CONNECTED; 182 | 183 | await client.disconnect(); 184 | 185 | t.is(status.length, 2); 186 | t.is(status[0], ClientStatus.DISCONNECTING); 187 | t.is(status[1], ClientStatus.DISCONNECTED); 188 | t.is((client as any).transport.status, ClientStatus.DISCONNECTED); 189 | }); 190 | -------------------------------------------------------------------------------- /test/client.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import * as sinon from 'sinon'; 3 | 4 | import * as Features from '../src/features'; 5 | import { 6 | createClient, 7 | createClientImpl, 8 | defaultTransportFactory, 9 | defaultUAFactory 10 | } from './_helpers'; 11 | 12 | test.serial('cannot create client with unsupported browser', t => { 13 | sinon.stub(Features, 'checkRequired').returns(false); 14 | t.throws(() => createClientImpl(defaultUAFactory(), defaultTransportFactory())); 15 | }); 16 | 17 | test.serial('client is frozen', t => { 18 | sinon.stub(Features, 'checkRequired').returns(true); 19 | 20 | const client = createClient(); 21 | 22 | // Extending client is not allowed. 23 | const sym = Symbol(); 24 | t.throws(() => (client[sym] = 123)); 25 | t.false(sym in client); 26 | 27 | // Changing properties is not allowed. 28 | t.throws(() => (client.connect = null)); 29 | t.true(client.connect !== null); 30 | }); 31 | -------------------------------------------------------------------------------- /test/utils.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { fc, testProp } from 'ava-fast-check'; 3 | 4 | import { eqSet, increaseTimeout, jitter } from '../src/lib/utils'; 5 | 6 | test('eqSet', t => { 7 | t.true(eqSet(new Set(), new Set())); 8 | t.true(eqSet(new Set([]), new Set([]))); 9 | t.true(eqSet(new Set([1, 2, 3, 4]), new Set([4, 3, 2, 1]))); 10 | t.false(eqSet(new Set([1, 2, 3]), new Set([1, 2, 4]))); 11 | t.false(eqSet(new Set([1, 2, 3, 4]), new Set([1, 2]))); 12 | t.false(eqSet(new Set([1, 2]), new Set([1, 2, 3, 4]))); 13 | }); 14 | 15 | test('jitter basics', t => { 16 | t.is(jitter(2, 0), 2); 17 | t.is(jitter(100, 0), 100); 18 | t.true(jitter(100, 100) >= 0); 19 | t.true(jitter(100, 100) <= 200); 20 | }); 21 | 22 | testProp('jitter is in range', [fc.nat(), fc.nat(100)], (interval, percentage) => { 23 | const min = Math.ceil(interval * ((100 - percentage) / 100)); 24 | const max = Math.floor(interval * ((100 + percentage) / 100)); 25 | const sample = jitter(interval, percentage); 26 | return sample >= 0 && min <= sample && sample <= max; 27 | }); 28 | 29 | test('increaseTimeout doubles interval', t => { 30 | const retry = increaseTimeout({ interval: 1, limit: 10 }); 31 | t.is(retry.interval, 2); 32 | t.true(retry.timeout > 1); 33 | }); 34 | 35 | test('increaseTimeout honors limit', t => { 36 | const retry = increaseTimeout({ interval: 8, limit: 10 }); 37 | t.is(retry.interval, 10); 38 | }); 39 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2019", 4 | "moduleResolution": "node", 5 | "outDir": "build/", 6 | "declaration": true 7 | }, 8 | "include": ["src"], 9 | "typedocOptions": { 10 | "mode": "file", 11 | "name": "Webphone Lib", 12 | "excludeNotExported": true, 13 | "excludePrivate": true 14 | } 15 | } 16 | --------------------------------------------------------------------------------