diff --git a/.gitignore b/.gitignore index b0a5c349..216bf0eb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ -/node_modules/ -/dist/ +/node_modules +/lib +/dist diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..a824e542 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,10 @@ +{ + "useTabs": true, + "tabWidth": 2, + "arrowParens": "avoid", + "bracketSpacing": true, + "semi": true, + "singleQuote": true, + "trailingComma": "es5", + "endOfLine": "auto" +} diff --git a/eslint.config.js b/eslint.config.js deleted file mode 100644 index aaf9247a..00000000 --- a/eslint.config.js +++ /dev/null @@ -1,245 +0,0 @@ -const { - defineConfig, - globalIgnores, -} = require("eslint/config"); - -const globals = require("globals"); -const js = require("@eslint/js"); - -const { - FlatCompat, -} = require("@eslint/eslintrc"); - -const compat = new FlatCompat({ - baseDirectory: __dirname, - recommendedConfig: js.configs.recommended, - allConfig: js.configs.all -}); - -module.exports = defineConfig([ - { - files: ['test/**/*.js'], // Apply these settings only to test files - languageOptions: { - globals: { - jest: true, // Enables Jest global variables like `test`, `describe`, and `expect` - describe: true, // TMP: explicitly enable globally. - test: true, // TMP: explicitly enable globally. - expect: true, // TMP: explicitly enable globally. - }, - }, - plugins: { - jest: require('eslint-plugin-jest'), // Load the Jest plugin - }, - rules: { - ...require('eslint-plugin-jest').configs.recommended.rules, // Use Jest's recommended rules - }, - }, - { - languageOptions: { - globals: { - ...globals.browser, - ...globals.node, - }, - - ecmaVersion: 6, - sourceType: "module", - - parserOptions: { - ecmaFeatures: { - impliedStrict: true, - }, - }, - }, - - plugins: {}, - extends: compat.extends("eslint:recommended"), - settings: {}, - - rules: { - "array-bracket-spacing": [2, "always", { - objectsInArrays: true, - arraysInArrays: true, - }], - - "arrow-parens": [2, "always"], - "arrow-spacing": 2, - "block-spacing": [2, "always"], - - "brace-style": [2, "allman", { - allowSingleLine: true, - }], - - "camelcase": 0, - "comma-dangle": 2, - - "comma-spacing": [2, { - before: false, - after: true, - }], - - "comma-style": 2, - "computed-property-spacing": 2, - "constructor-super": 2, - "func-call-spacing": 2, - "generator-star-spacing": 2, - "guard-for-in": 2, - - "indent": [2, 2, { - "SwitchCase": 1, - }], - - "key-spacing": [2, { - singleLine: { - beforeColon: false, - afterColon: true, - }, - - multiLine: { - beforeColon: true, - afterColon: true, - align: "colon", - }, - }], - - "keyword-spacing": 2, - "linebreak-style": [2, "unix"], - - "lines-around-comment": [2, { - allowBlockStart: true, - allowObjectStart: true, - beforeBlockComment: true, - beforeLineComment: false, - }], - - "max-len": [2, 90, { - tabWidth: 2, - comments: 110, - ignoreUrls: true, - ignoreStrings: true, - ignoreTemplateLiterals: true, - ignoreRegExpLiterals: true, - }], - - "newline-after-var": 2, - "newline-before-return": 2, - "newline-per-chained-call": 2, - "no-alert": 2, - "no-caller": 2, - "no-case-declarations": 2, - "no-catch-shadow": 2, - "no-class-assign": 2, - "no-confusing-arrow": 2, - - "no-console": [2, { - allow: ["warn"], - }], - - "no-const-assign": 2, - - "no-constant-condition": [2, { - "checkLoops": false, - }], - - "no-debugger": 2, - "no-dupe-args": 2, - "no-dupe-keys": 2, - "no-duplicate-case": 2, - "no-div-regex": 2, - - "no-empty": [2, { - allowEmptyCatch: true, - }], - - "no-empty-pattern": 2, - "no-else-return": 0, - "no-eval": 2, - "no-extend-native": 2, - "no-ex-assign": 2, - "no-extra-bind": 2, - "no-extra-boolean-cast": 2, - "no-extra-label": 2, - "no-extra-semi": 2, - "no-fallthrough": 2, - "no-func-assign": 2, - "no-global-assign": 2, - "no-implicit-coercion": 2, - "no-implicit-globals": 2, - "no-inner-declarations": 2, - "no-invalid-regexp": 2, - "no-invalid-this": 0, - "no-irregular-whitespace": 2, - "no-lonely-if": 2, - "no-mixed-operators": 2, - "no-mixed-spaces-and-tabs": 2, - "no-multi-spaces": 2, - "no-multi-str": 2, - "no-multiple-empty-lines": 2, - "no-native-reassign": 2, - "no-negated-in-lhs": 2, - "no-new": 2, - "no-new-func": 2, - "no-new-wrappers": 2, - "no-obj-calls": 2, - "no-proto": 2, - "no-prototype-builtins": 0, - "no-redeclare": 2, - "no-regex-spaces": 2, - "no-restricted-imports": 2, - "no-return-assign": 2, - "no-self-assign": 2, - "no-self-compare": 2, - "no-sequences": 2, - "no-shadow": 2, - "no-shadow-restricted-names": 2, - "no-spaced-func": 2, - "no-sparse-arrays": 2, - "no-this-before-super": 2, - "no-throw-literal": 2, - "no-trailing-spaces": 2, - "no-undef": 2, - "no-unexpected-multiline": 2, - "no-unmodified-loop-condition": 2, - "no-unreachable": 2, - - "no-unused-vars": [1, { - vars: "all", - args: "after-used", - }], - - "no-use-before-define": [2, { - functions: false, - }], - - "no-useless-call": 2, - "no-useless-computed-key": 2, - "no-useless-concat": 2, - "no-useless-rename": 2, - "no-var": 2, - "no-whitespace-before-property": 2, - "object-curly-newline": 0, - "object-curly-spacing": [2, "always"], - - "object-property-newline": [2, { - allowMultiplePropertiesPerLine: true, - }], - - "prefer-const": 2, - "prefer-rest-params": 2, - "prefer-spread": 2, - "prefer-template": 2, - - "quotes": [2, "single", { - avoidEscape: true, - }], - - "semi": [2, "always"], - "semi-spacing": 2, - "space-before-blocks": 2, - "space-before-function-paren": [2, "never"], - "space-in-parens": [2, "never"], - "spaced-comment": [2, "always"], - "strict": 2, - "valid-typeof": 2, - "yoda": 2, - }, -}, globalIgnores(["src/Grammar.js"])]); diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 00000000..e23db216 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,186 @@ +import eslint from '@eslint/js'; +import tsEslint from 'typescript-eslint'; +import jestEslint from 'eslint-plugin-jest'; +import prettierRecommendedEslint from 'eslint-plugin-prettier/recommended'; +import globals from 'globals'; + +const config = tsEslint.config( + { + languageOptions: { + sourceType: 'module', + globals: { ...globals.node, ...globals.jest }, + }, + linterOptions: { + noInlineConfig: false, + reportUnusedDisableDirectives: 'error', + }, + }, + eslint.configs.recommended, + prettierRecommendedEslint, + { + rules: { + 'constructor-super': 2, + curly: [2, 'all'], + // Unfortunatelly `curly` does not apply to blocks in `switch` cases so + // this is needed. + 'no-restricted-syntax': [ + 2, + { + selector: 'SwitchCase > *.consequent[type!="BlockStatement"]', + message: 'Switch cases without blocks are disallowed', + }, + ], + 'guard-for-in': 2, + 'newline-after-var': 2, + 'newline-before-return': 2, + 'no-alert': 2, + 'no-caller': 2, + 'no-case-declarations': 2, + 'no-catch-shadow': 2, + 'no-class-assign': 2, + 'no-console': 2, + 'no-const-assign': 2, + 'no-debugger': 2, + 'no-dupe-args': 2, + 'no-dupe-keys': 2, + 'no-duplicate-case': 2, + 'no-div-regex': 2, + 'no-empty': [2, { allowEmptyCatch: true }], + 'no-empty-pattern': 2, + 'no-eval': 2, + 'no-extend-native': 2, + 'no-ex-assign': 2, + 'no-extra-bind': 2, + 'no-extra-boolean-cast': 2, + 'no-extra-label': 2, + 'no-fallthrough': 2, + 'no-func-assign': 2, + 'no-global-assign': 2, + 'no-implicit-coercion': 2, + 'no-implicit-globals': 2, + 'no-inner-declarations': 2, + 'no-invalid-regexp': 2, + 'no-invalid-this': 2, + 'no-irregular-whitespace': 2, + 'no-lonely-if': 2, + 'no-multi-str': 2, + 'no-native-reassign': 2, + 'no-negated-in-lhs': 2, + 'no-new': 2, + 'no-new-func': 2, + 'no-new-wrappers': 2, + 'no-obj-calls': 2, + 'no-proto': 2, + 'no-prototype-builtins': 0, + 'no-redeclare': 2, + 'no-regex-spaces': 2, + 'no-restricted-imports': 2, + 'no-return-assign': 2, + 'no-self-assign': 2, + 'no-self-compare': 2, + 'no-sequences': 2, + 'no-shadow': 2, + 'no-shadow-restricted-names': 2, + 'no-sparse-arrays': 2, + 'no-this-before-super': 2, + 'no-throw-literal': 2, + 'no-undef': 2, + 'no-unmodified-loop-condition': 2, + 'no-unreachable': 2, + 'no-unused-vars': [ + 2, + { vars: 'all', args: 'after-used', caughtErrors: 'none' }, + ], + 'no-use-before-define': 0, + 'no-useless-call': 2, + 'no-useless-computed-key': 2, + 'no-useless-concat': 2, + 'no-useless-rename': 2, + 'no-var': 2, + 'object-curly-newline': 0, + 'prefer-const': 2, + 'prefer-rest-params': 2, + 'prefer-spread': 2, + 'prefer-template': 2, + 'spaced-comment': [2, 'always'], + strict: 2, + 'valid-typeof': 2, + yoda: 2, + }, + }, + // NOTE: We need to apply this only to .ts source files (and not to .mjs + // files). + ...tsEslint.configs.recommendedTypeChecked.map(item => ({ + ...item, + files: ['src/**/*.ts'], + })), + // NOTE: We need to apply this only to .ts source files (and not to .mjs + // files). + ...tsEslint.configs.stylisticTypeChecked.map(item => ({ + ...item, + files: ['src/**/*.ts'], + })), + { + name: '.ts source files', + files: ['src/**/*.ts'], + languageOptions: { + parserOptions: { + project: 'tsconfig.json', + }, + }, + rules: { + '@typescript-eslint/consistent-generic-constructors': [ + 2, + 'type-annotation', + ], + '@typescript-eslint/prefer-function-type': 0, + '@typescript-eslint/dot-notation': 0, + '@typescript-eslint/no-unused-vars': [ + 2, + { + vars: 'all', + args: 'after-used', + caughtErrors: 'none', + ignoreRestSiblings: false, + }, + ], + // We want to use `type` instead of `interface`. + '@typescript-eslint/consistent-type-definitions': 0, + '@typescript-eslint/explicit-function-return-type': [ + 2, + { allowExpressions: true }, + ], + '@typescript-eslint/no-inferrable-types': 0, + '@typescript-eslint/no-unsafe-member-access': 0, + '@typescript-eslint/no-unsafe-assignment': 0, + '@typescript-eslint/no-unsafe-call': 0, + '@typescript-eslint/no-unsafe-return': 0, + '@typescript-eslint/no-unsafe-argument': 0, + '@typescript-eslint/consistent-indexed-object-style': 0, + '@typescript-eslint/no-empty-function': 0, + '@typescript-eslint/prefer-nullish-coalescing': 0, + '@typescript-eslint/prefer-regexp-exec': 0, + '@typescript-eslint/require-await': 0, + '@typescript-eslint/restrict-template-expressions': 0, + '@typescript-eslint/unbound-method': 0, + '@typescript-eslint/no-redundant-type-constituents': 0, + }, + }, + { + name: '.ts test files', + ...jestEslint.configs['flat/recommended'], + files: ['src/test/**/*.ts'], + rules: { + ...jestEslint.configs['flat/recommended'].rules, + 'jest/no-disabled-tests': 2, + 'jest/prefer-expect-assertions': 0, + '@typescript-eslint/no-unnecessary-type-assertion': 0, + }, + }, + { + name: 'lib/ files', + ignores: ['lib/**'], + } +); + +export default config; diff --git a/jest.config.js b/jest.config.js index 5e022fc2..37e20811 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,3 +1,3 @@ module.exports = { - testRegex : 'test/test-.*\\.js' + testRegex: 'test/test-.*\\.js', }; diff --git a/npm-scripts.js b/npm-scripts.js deleted file mode 100644 index 0ff5de2f..00000000 --- a/npm-scripts.js +++ /dev/null @@ -1,159 +0,0 @@ -const esbuild = require("esbuild"); -const fs = require('fs'); -const path = require('path'); -const process = require('process'); -const { execSync } = require('child_process'); -const pkg = require('./package.json'); - -const task = process.argv.slice(2).join(' '); - -const ESLINT_PATHS = [ 'src', 'test' ].join(' '); - -// eslint-disable-next-line no-console -console.log(`npm-scripts.js [INFO] running task "${task}"`); - -void run(); - -async function run() { - switch (task) - { - case 'grammar': { - grammar(); - break; - } - - case 'lint': { - lint(); - break; - } - - case 'test': { - test(); - break; - } - - case 'build': { - await build(true /* minify */); - await build(false /* minify */); - - break; - } - - case 'release': { - lint(); - test(); - executeCmd(`git commit -am '${pkg.version}'`); - executeCmd(`git tag -a ${pkg.version} -m '${pkg.version}'`); - executeCmd('git push origin master && git push origin --tags'); - executeCmd('npm publish'); - - // eslint-disable-next-line no-console - console.log('update tryit-jssip and JsSIP website'); - break; - } - - default: { - throw new TypeError(`unknown task "${task}"`); - } - } -} - -function lint() -{ - logInfo('lint()'); - - executeCmd(`eslint -c eslint.config.js --max-warnings 0 ${ESLINT_PATHS}`); -} - -function test() -{ - logInfo('test()'); - - executeCmd('jest'); -} - -function grammar() -{ - logInfo('grammar()'); - - const local_pegjs = path.resolve('./node_modules/.bin/pegjs'); - const Grammar_pegjs = path.resolve('src/Grammar.pegjs'); - const Grammar_js = path.resolve('src/Grammar.js'); - - logInfo('compiling Grammar.pegjs into Grammar.js...'); - - executeCmd(`${local_pegjs} ${Grammar_pegjs} ${Grammar_js}`); - - logInfo('grammar compiled'); - - // Modify the generated Grammar.js file with custom changes. - logInfo('applying custom changes to Grammar.js...'); - - const current_grammar = fs.readFileSync('src/Grammar.js').toString(); - let modified_grammar = current_grammar.replace( - /throw new this\.SyntaxError\(([\s\S]*?)\);([\s\S]*?)}([\s\S]*?)return result;/, - 'new this.SyntaxError($1);\n return -1;$2}$3return data;' - ); - - modified_grammar = modified_grammar.replace(/\s+$/gm, ''); - fs.writeFileSync('src/Grammar.js', modified_grammar); - - logInfo('grammar done'); -} - -// Build sources into a file for publishing. -async function build(minify = true) { - const entry = path.resolve("src/JsSIP.js"); - const outfile = path.resolve("./dist", `jssip${minify ? '.min' : ''}.js`); - const banner = ` - /* - * JsSIP ${pkg.version} - * ${pkg.description} - * Copyright: 2012-${new Date().getFullYear()} ${pkg.contributors.join(' ')} - * Homepage: ${pkg.homepage} - * License: ${pkg.license} - */`; - - await esbuild.build({ - entryPoints: [entry], - outfile, - bundle: true, - minify, - sourcemap: false, - // https://esbuild.github.io/api/#global-name. - format: "iife", - globalName: "JsSIP", - platform: "browser", - target: ["es2015"], - // Make the generated output a single line. - supported: { - "template-literal": false, - }, - // Add banner. - banner: { - js: banner, - }, - }); -} - -function executeCmd(command) -{ - // eslint-disable-next-line no-console - console.log(`npm-scripts.js [INFO] executing command: ${command}`); - - try - { - execSync(command, { stdio: [ 'ignore', process.stdout, process.stderr ] }); - } - // eslint-disable-next-line no-unused-vars - catch (error) - { - process.exit(1); - } -} - -function logInfo(...args) -{ - // eslint-disable-next-line no-console - console.log(`npm-scripts.mjs \x1b[36m[INFO] [${task}]\x1b[0m`, ...args); -} diff --git a/npm-scripts.mjs b/npm-scripts.mjs new file mode 100644 index 00000000..306e538e --- /dev/null +++ b/npm-scripts.mjs @@ -0,0 +1,201 @@ +import esbuild from 'esbuild'; +import fs from 'fs'; +import path from 'path'; +import process from 'process'; +import { execSync } from 'child_process'; +import pkg from './package.json' with { type: 'json' }; + +const task = process.argv.slice(2).join(' '); +const taskArgs = process.argv.slice(3).join(' '); + +// Paths for ESLint to check. Converted to string for convenience. +const ESLINT_PATHS = [ + 'eslint.config.mjs', + // "jest.config.mjs", + 'npm-scripts.mjs', + 'src', + 'test', +].join(' '); + +// Paths for ESLint to ignore. Converted to string argument for convenience. +const ESLINT_IGNORE_PATTERN_ARGS = ['src/Grammar.pegs', 'src/Grammar.js'] + .map(entry => `--ignore-pattern ${entry}`) + .join(' '); + +logInfo(`running task "${task}"`); +logInfo(taskArgs ? `[args:"${taskArgs}"]` : ''); + +void run(); + +async function run() { + switch (task) { + case 'grammar': { + grammar(); + break; + } + + case 'lint': { + lint(); + break; + } + + case 'lint:fix': { + lint(true); + break; + } + + case 'test': { + test(); + break; + } + + case 'build': { + buildTypescript(); + await build(true /* minify */); + await build(false /* minify */); + + break; + } + + case 'typescript:build': { + buildTypescript(); + + break; + } + + case 'release': { + lint(); + test(); + executeCmd(`git commit -am '${pkg.version}'`); + executeCmd(`git tag -a ${pkg.version} -m '${pkg.version}'`); + executeCmd('git push origin master && git push origin --tags'); + executeCmd('npm publish'); + + // eslint-disable-next-line no-console + console.log('update tryit-jssip and JsSIP website'); + break; + } + + default: { + throw new TypeError(`unknown task "${task}"`); + } + } +} + +function lint(fix = false) { + logInfo(`lint() [fix:${fix}]`); + + executeCmd( + `eslint -c eslint.config.mjs --max-warnings 0 ${fix ? '--fix' : ''} ${ESLINT_PATHS} ${ESLINT_IGNORE_PATTERN_ARGS}` + ); +} + +function test() { + logInfo('test()'); + + // TODO: remove when tests are written in TS. + buildTypescript(); + executeCmd('jest'); +} + +function grammar() { + logInfo('grammar()'); + + const local_pegjs = path.resolve('./node_modules/.bin/pegjs'); + const Grammar_pegjs = path.resolve('src/Grammar.pegjs'); + const Grammar_js = path.resolve('src/Grammar.js'); + + logInfo('compiling Grammar.pegjs into Grammar.js...'); + + executeCmd(`${local_pegjs} ${Grammar_pegjs} ${Grammar_js}`); + + logInfo('grammar compiled'); + + // Modify the generated Grammar.js file with custom changes. + logInfo('applying custom changes to Grammar.js...'); + + const current_grammar = fs.readFileSync('src/Grammar.js').toString(); + let modified_grammar = current_grammar.replace( + /throw new this\.SyntaxError\(([\s\S]*?)\);([\s\S]*?)}([\s\S]*?)return result;/, + 'new this.SyntaxError($1);\n return -1;$2}$3return data;' + ); + + modified_grammar = modified_grammar.replace(/\s+$/gm, ''); + fs.writeFileSync('src/Grammar.js', modified_grammar); + + logInfo('grammar done'); +} + +// Build sources into a file for publishing. +async function build(minify = true) { + const entry = path.resolve('lib/JsSIP.js'); + const outfile = path.resolve('./dist', `jssip${minify ? '.min' : ''}.js`); + const banner = ` + /* + * JsSIP ${pkg.version} + * ${pkg.description} + * Copyright: 2012-${new Date().getFullYear()} ${pkg.contributors.join(' ')} + * Homepage: ${pkg.homepage} + * License: ${pkg.license} + */`; + + await esbuild.build({ + entryPoints: [entry], + outfile, + bundle: true, + minify, + sourcemap: false, + // https://esbuild.github.io/api/#global-name. + format: 'iife', + globalName: 'JsSIP', + platform: 'browser', + target: ['es2015'], + // Make the generated output a single line. + supported: { + 'template-literal': false, + }, + // Add banner. + banner: { + js: banner, + }, + }); +} + +function buildTypescript() { + logInfo('buildTypescript()'); + + deleteLib(); + + // Generate .js CommonJS files in lib/. + executeCmd(`tsc ${taskArgs}`); + + // Copy manual .d.ts files to lib/ until code is moved to TS and declaration files + // are automatically created. + executeCmd("cpx 'src/**/**.d.ts' lib/"); +} + +function deleteLib() { + if (!fs.existsSync('lib')) { + return; + } + + logInfo('deleteLib()'); + + fs.rmSync('lib', { recursive: true, force: true }); +} + +function executeCmd(command) { + // eslint-disable-next-line no-console + console.log(`npm-scripts.js [INFO] executing command: ${command}`); + + try { + execSync(command, { stdio: ['ignore', process.stdout, process.stderr] }); + } catch (error) { + process.exit(1); + } +} + +function logInfo(...args) { + // eslint-disable-next-line no-console + console.log(`npm-scripts.mjs \x1b[36m[INFO] [${task}]\x1b[0m`, ...args); +} diff --git a/package.json b/package.json index d03357ac..603ef16b 100644 --- a/package.json +++ b/package.json @@ -1,52 +1,67 @@ { - "name": "jssip", - "title": "JsSIP", - "description": "The Javascript SIP library", - "version": "3.12.0", - "homepage": "https://jssip.net", - "contributors": [ - "José Luis Millán (https://github.com/jmillan)", - "Iñaki Baz Castillo (https://inakibaz.me)" - ], - "types": "src/JsSIP.d.ts", - "main": "src/JsSIP.js", - "keywords": [ - "sip", - "websocket", - "webrtc", - "node", - "browser", - "library" - ], - "license": "MIT", - "repository": { - "type": "git", - "url": "https://github.com/versatica/JsSIP.git" - }, - "bugs": { - "url": "https://github.com/versatica/JsSIP/issues" - }, - "dependencies": { - "debug": "^4.3.1", - "events": "^3.3.0", - "sdp-transform": "^2.14.1" - }, - "devDependencies": { - "@eslint/eslintrc": "^3.3.3", - "@eslint/js": "^9.39.2", - "@types/debug": "^4.1.12", - "@types/events": "^3.0.3", - "esbuild": "^0.27.2", - "eslint": "^9.39.1", - "eslint-plugin-jest": "^29.12.1", - "globals": "^17.0.0", - "jest": "^30.2.0", - "pegjs": "^0.7.0" - }, - "scripts": { - "lint": "node npm-scripts.js lint", - "test": "node npm-scripts.js test", - "build": "node npm-scripts.js build", - "release": "node npm-scripts.js release" - } + "name": "jssip", + "title": "JsSIP", + "description": "The Javascript SIP library", + "version": "3.12.0", + "homepage": "https://jssip.net", + "contributors": [ + "José Luis Millán (https://github.com/jmillan)", + "Iñaki Baz Castillo (https://inakibaz.me)" + ], + "types": "lib/JsSIP.d.ts", + "main": "lib/JsSIP.js", + "keywords": [ + "sip", + "websocket", + "webrtc", + "node", + "browser", + "library" + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/versatica/JsSIP.git" + }, + "bugs": { + "url": "https://github.com/versatica/JsSIP/issues" + }, + "files": [ + "LICENSE", + "README.md", + "npm-scripts.mjs", + "lib" + ], + "scripts": { + "lint": "node npm-scripts.mjs lint", + "lint:fix": "node npm-scripts.mjs lint:fix", + "test": "node npm-scripts.mjs test", + "build": "node npm-scripts.mjs build", + "typescript:build": "node npm-scripts.mjs typescript:build", + "release": "node npm-scripts.js release" + }, + "dependencies": { + "debug": "^4.3.1", + "events": "^3.3.0", + "sdp-transform": "^2.14.1" + }, + "devDependencies": { + "@eslint/eslintrc": "^3.3.3", + "@eslint/js": "^9.39.2", + "@types/debug": "^4.1.12", + "@types/events": "^3.0.3", + "@types/node": "^25.0.10", + "cpx": "^1.5.0", + "esbuild": "^0.27.2", + "eslint": "^9.39.1", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-jest": "^29.12.1", + "eslint-plugin-prettier": "^5.5.5", + "globals": "^17.0.0", + "jest": "^30.2.0", + "pegjs": "^0.7.0", + "prettier": "^3.8.1", + "typescript": "^5.9.3", + "typescript-eslint": "^8.53.1" + } } diff --git a/src/Config.js b/src/Config.js index 46f929e4..64689fff 100644 --- a/src/Config.js +++ b/src/Config.js @@ -7,397 +7,308 @@ const Exceptions = require('./Exceptions'); // Default settings. exports.settings = { - // SIP authentication. - authorization_user : null, - password : null, - realm : null, - ha1 : null, - authorization_jwt : null, - - // SIP account. - display_name : null, - uri : null, - contact_uri : null, - - // SIP instance id (GRUU). - instance_id : null, - - // Preloaded SIP Route header field. - use_preloaded_route : false, - - // Session parameters. - session_timers : true, - session_timers_refresh_method : JsSIP_C.UPDATE, - session_timers_force_refresher : false, - no_answer_timeout : 60, - - // Registration parameters. - register : true, - register_expires : 600, - register_from_tag_trail : '', - registrar_server : null, - - // Connection options. - sockets : null, - connection_recovery_max_interval : JsSIP_C.CONNECTION_RECOVERY_MAX_INTERVAL, - connection_recovery_min_interval : JsSIP_C.CONNECTION_RECOVERY_MIN_INTERVAL, - - // Global extra headers, to be added to every request and response - extra_headers : null, - - /* - * Host address. - * Value to be set in Via sent_by and host part of Contact FQDN. - */ - via_host : `${Utils.createRandomToken(12)}.invalid` + // SIP authentication. + authorization_user: null, + password: null, + realm: null, + ha1: null, + authorization_jwt: null, + + // SIP account. + display_name: null, + uri: null, + contact_uri: null, + + // SIP instance id (GRUU). + instance_id: null, + + // Preloaded SIP Route header field. + use_preloaded_route: false, + + // Session parameters. + session_timers: true, + session_timers_refresh_method: JsSIP_C.UPDATE, + session_timers_force_refresher: false, + no_answer_timeout: 60, + + // Registration parameters. + register: true, + register_expires: 600, + register_from_tag_trail: '', + registrar_server: null, + + // Connection options. + sockets: null, + connection_recovery_max_interval: JsSIP_C.CONNECTION_RECOVERY_MAX_INTERVAL, + connection_recovery_min_interval: JsSIP_C.CONNECTION_RECOVERY_MIN_INTERVAL, + + // Global extra headers, to be added to every request and response + extra_headers: null, + + /* + * Host address. + * Value to be set in Via sent_by and host part of Contact FQDN. + */ + via_host: `${Utils.createRandomToken(12)}.invalid`, }; // Configuration checks. const checks = { - mandatory : { - - sockets(sockets) - { - /* Allow defining sockets parameter as: - * Socket: socket - * Array of Socket: [socket1, socket2] - * Array of Objects: [{socket: socket1, weight:1}, {socket: Socket2, weight:0}] - * Array of Objects and Socket: [{socket: socket1}, socket2] - */ - const _sockets = []; - - if (Socket.isSocket(sockets)) - { - _sockets.push({ socket: sockets }); - } - else if (Array.isArray(sockets) && sockets.length) - { - for (const socket of sockets) - { - if (Object.prototype.hasOwnProperty.call(socket, 'socket') && - Socket.isSocket(socket.socket)) - { - _sockets.push(socket); - } - else if (Socket.isSocket(socket)) - { - _sockets.push({ socket: socket }); - } - } - } - else - { - return; - } - - return _sockets; - }, - - uri(uri) - { - if (!/^sip:/i.test(uri)) - { - uri = `${JsSIP_C.SIP}:${uri}`; - } - const parsed = URI.parse(uri); - - if (!parsed) - { - return; - } - else if (!parsed.user) - { - return; - } - else - { - return parsed; - } - } - }, - - optional : { - - authorization_user(authorization_user) - { - if (Grammar.parse(`"${authorization_user}"`, 'quoted_string') === -1) - { - return; - } - else - { - return authorization_user; - } - }, - authorization_jwt(authorization_jwt) - { - if (typeof authorization_jwt === 'string') - { - return authorization_jwt; - } - }, - user_agent(user_agent) - { - if (typeof user_agent === 'string') - { - return user_agent; - } - }, - - connection_recovery_max_interval(connection_recovery_max_interval) - { - if (Utils.isDecimal(connection_recovery_max_interval)) - { - const value = Number(connection_recovery_max_interval); - - if (value > 0) - { - return value; - } - } - }, - - connection_recovery_min_interval(connection_recovery_min_interval) - { - if (Utils.isDecimal(connection_recovery_min_interval)) - { - const value = Number(connection_recovery_min_interval); - - if (value > 0) - { - return value; - } - } - }, - - contact_uri(contact_uri) - { - if (typeof contact_uri === 'string') - { - const uri = Grammar.parse(contact_uri, 'SIP_URI'); - - if (uri !== -1) - { - return uri; - } - } - }, - - display_name(display_name) - { - return display_name; - }, - - instance_id(instance_id) - { - if ((/^uuid:/i.test(instance_id))) - { - instance_id = instance_id.substr(5); - } - - if (Grammar.parse(instance_id, 'uuid') === -1) - { - return; - } - else - { - return instance_id; - } - }, - - no_answer_timeout(no_answer_timeout) - { - if (Utils.isDecimal(no_answer_timeout)) - { - const value = Number(no_answer_timeout); - - if (value > 0) - { - return value; - } - } - }, - - session_timers(session_timers) - { - if (typeof session_timers === 'boolean') - { - return session_timers; - } - }, - - session_timers_refresh_method(method) - { - if (typeof method === 'string') - { - method = method.toUpperCase(); - - if (method === JsSIP_C.INVITE || method === JsSIP_C.UPDATE) - { - return method; - } - } - }, - - session_timers_force_refresher(session_timers_force_refresher) - { - if (typeof session_timers_force_refresher === 'boolean') - { - return session_timers_force_refresher; - } - }, - - password(password) - { - return String(password); - }, - - realm(realm) - { - return String(realm); - }, - - ha1(ha1) - { - return String(ha1); - }, - - register(register) - { - if (typeof register === 'boolean') - { - return register; - } - }, - - register_expires(register_expires) - { - if (Utils.isDecimal(register_expires)) - { - const value = Number(register_expires); - - if (value >= 0) - { - return value; - } - } - }, - - register_from_tag_trail(register_from_tag_trail) - { - if (typeof register_from_tag_trail === 'function') - { - return register_from_tag_trail; - } - - return String(register_from_tag_trail); - }, - - registrar_server(registrar_server) - { - if (!/^sip:/i.test(registrar_server)) - { - registrar_server = `${JsSIP_C.SIP}:${registrar_server}`; - } - - const parsed = URI.parse(registrar_server); - - if (!parsed) - { - return; - } - else if (parsed.user) - { - return; - } - else - { - return parsed; - } - }, - - use_preloaded_route(use_preloaded_route) - { - if (typeof use_preloaded_route === 'boolean') - { - return use_preloaded_route; - } - }, - - extra_headers(extra_headers) - { - const _extraHeaders = []; - - if (Array.isArray(extra_headers) && extra_headers.length) - { - for (const header of extra_headers) - { - if (typeof header === 'string') - { - _extraHeaders.push(header); - } - } - } - else - { - return; - } - - return _extraHeaders; - } - } + mandatory: { + sockets(sockets) { + /* Allow defining sockets parameter as: + * Socket: socket + * Array of Socket: [socket1, socket2] + * Array of Objects: [{socket: socket1, weight:1}, {socket: Socket2, weight:0}] + * Array of Objects and Socket: [{socket: socket1}, socket2] + */ + const _sockets = []; + + if (Socket.isSocket(sockets)) { + _sockets.push({ socket: sockets }); + } else if (Array.isArray(sockets) && sockets.length) { + for (const socket of sockets) { + if ( + Object.prototype.hasOwnProperty.call(socket, 'socket') && + Socket.isSocket(socket.socket) + ) { + _sockets.push(socket); + } else if (Socket.isSocket(socket)) { + _sockets.push({ socket: socket }); + } + } + } else { + return; + } + + return _sockets; + }, + + uri(uri) { + if (!/^sip:/i.test(uri)) { + uri = `${JsSIP_C.SIP}:${uri}`; + } + const parsed = URI.parse(uri); + + if (!parsed) { + return; + } else if (!parsed.user) { + return; + } else { + return parsed; + } + }, + }, + + optional: { + authorization_user(authorization_user) { + if (Grammar.parse(`"${authorization_user}"`, 'quoted_string') === -1) { + return; + } else { + return authorization_user; + } + }, + authorization_jwt(authorization_jwt) { + if (typeof authorization_jwt === 'string') { + return authorization_jwt; + } + }, + user_agent(user_agent) { + if (typeof user_agent === 'string') { + return user_agent; + } + }, + + connection_recovery_max_interval(connection_recovery_max_interval) { + if (Utils.isDecimal(connection_recovery_max_interval)) { + const value = Number(connection_recovery_max_interval); + + if (value > 0) { + return value; + } + } + }, + + connection_recovery_min_interval(connection_recovery_min_interval) { + if (Utils.isDecimal(connection_recovery_min_interval)) { + const value = Number(connection_recovery_min_interval); + + if (value > 0) { + return value; + } + } + }, + + contact_uri(contact_uri) { + if (typeof contact_uri === 'string') { + const uri = Grammar.parse(contact_uri, 'SIP_URI'); + + if (uri !== -1) { + return uri; + } + } + }, + + display_name(display_name) { + return display_name; + }, + + instance_id(instance_id) { + if (/^uuid:/i.test(instance_id)) { + instance_id = instance_id.substr(5); + } + + if (Grammar.parse(instance_id, 'uuid') === -1) { + return; + } else { + return instance_id; + } + }, + + no_answer_timeout(no_answer_timeout) { + if (Utils.isDecimal(no_answer_timeout)) { + const value = Number(no_answer_timeout); + + if (value > 0) { + return value; + } + } + }, + + session_timers(session_timers) { + if (typeof session_timers === 'boolean') { + return session_timers; + } + }, + + session_timers_refresh_method(method) { + if (typeof method === 'string') { + method = method.toUpperCase(); + + if (method === JsSIP_C.INVITE || method === JsSIP_C.UPDATE) { + return method; + } + } + }, + + session_timers_force_refresher(session_timers_force_refresher) { + if (typeof session_timers_force_refresher === 'boolean') { + return session_timers_force_refresher; + } + }, + + password(password) { + return String(password); + }, + + realm(realm) { + return String(realm); + }, + + ha1(ha1) { + return String(ha1); + }, + + register(register) { + if (typeof register === 'boolean') { + return register; + } + }, + + register_expires(register_expires) { + if (Utils.isDecimal(register_expires)) { + const value = Number(register_expires); + + if (value >= 0) { + return value; + } + } + }, + + register_from_tag_trail(register_from_tag_trail) { + if (typeof register_from_tag_trail === 'function') { + return register_from_tag_trail; + } + + return String(register_from_tag_trail); + }, + + registrar_server(registrar_server) { + if (!/^sip:/i.test(registrar_server)) { + registrar_server = `${JsSIP_C.SIP}:${registrar_server}`; + } + + const parsed = URI.parse(registrar_server); + + if (!parsed) { + return; + } else if (parsed.user) { + return; + } else { + return parsed; + } + }, + + use_preloaded_route(use_preloaded_route) { + if (typeof use_preloaded_route === 'boolean') { + return use_preloaded_route; + } + }, + + extra_headers(extra_headers) { + const _extraHeaders = []; + + if (Array.isArray(extra_headers) && extra_headers.length) { + for (const header of extra_headers) { + if (typeof header === 'string') { + _extraHeaders.push(header); + } + } + } else { + return; + } + + return _extraHeaders; + }, + }, }; -exports.load = (dst, src) => -{ - // Check Mandatory parameters. - for (const parameter in checks.mandatory) - { - if (!src.hasOwnProperty(parameter)) - { - throw new Exceptions.ConfigurationError(parameter); - } - else - { - const value = src[parameter]; - const checked_value = checks.mandatory[parameter](value); - - if (checked_value !== undefined) - { - dst[parameter] = checked_value; - } - else - { - throw new Exceptions.ConfigurationError(parameter, value); - } - } - } - - // Check Optional parameters. - for (const parameter in checks.optional) - { - if (src.hasOwnProperty(parameter)) - { - const value = src[parameter]; - - /* If the parameter value is null, empty string, undefined, empty array - * or it's a number with NaN value, then apply its default value. - */ - if (Utils.isEmpty(value)) - { - continue; - } - - const checked_value = checks.optional[parameter](value); - - if (checked_value !== undefined) - { - dst[parameter] = checked_value; - } - else - { - throw new Exceptions.ConfigurationError(parameter, value); - } - } - } +exports.load = (dst, src) => { + // Check Mandatory parameters. + for (const parameter in checks.mandatory) { + if (!src.hasOwnProperty(parameter)) { + throw new Exceptions.ConfigurationError(parameter); + } else { + const value = src[parameter]; + const checked_value = checks.mandatory[parameter](value); + + if (checked_value !== undefined) { + dst[parameter] = checked_value; + } else { + throw new Exceptions.ConfigurationError(parameter, value); + } + } + } + + // Check Optional parameters. + for (const parameter in checks.optional) { + if (src.hasOwnProperty(parameter)) { + const value = src[parameter]; + + /* If the parameter value is null, empty string, undefined, empty array + * or it's a number with NaN value, then apply its default value. + */ + if (Utils.isEmpty(value)) { + continue; + } + + const checked_value = checks.optional[parameter](value); + + if (checked_value !== undefined) { + dst[parameter] = checked_value; + } else { + throw new Exceptions.ConfigurationError(parameter, value); + } + } + } }; diff --git a/src/Constants.d.ts b/src/Constants.d.ts index 31297cc6..b12d171a 100644 --- a/src/Constants.d.ts +++ b/src/Constants.d.ts @@ -1,66 +1,68 @@ -export const USER_AGENT: string -export const SIP = 'sip' -export const SIPS = 'sips' +export const USER_AGENT: string; +export const SIP = 'sip'; +export const SIPS = 'sips'; export declare enum causes { - CONNECTION_ERROR = 'Connection Error', - REQUEST_TIMEOUT = 'Request Timeout', - SIP_FAILURE_CODE = 'SIP Failure Code', - INTERNAL_ERROR = 'Internal Error', - BUSY = 'Busy', - REJECTED = 'Rejected', - REDIRECTED = 'Redirected', - UNAVAILABLE = 'Unavailable', - NOT_FOUND = 'Not Found', - ADDRESS_INCOMPLETE = 'Address Incomplete', - INCOMPATIBLE_SDP = 'Incompatible SDP', - MISSING_SDP = 'Missing SDP', - AUTHENTICATION_ERROR = 'Authentication Error', - BYE = 'Terminated', - WEBRTC_ERROR = 'WebRTC Error', - CANCELED = 'Canceled', - NO_ANSWER = 'No Answer', - EXPIRES = 'Expires', - NO_ACK = 'No ACK', - DIALOG_ERROR = 'Dialog Error', - USER_DENIED_MEDIA_ACCESS = 'User Denied Media Access', - BAD_MEDIA_DESCRIPTION = 'Bad Media Description', - RTP_TIMEOUT = 'RTP Timeout', + CONNECTION_ERROR = 'Connection Error', + REQUEST_TIMEOUT = 'Request Timeout', + SIP_FAILURE_CODE = 'SIP Failure Code', + INTERNAL_ERROR = 'Internal Error', + BUSY = 'Busy', + REJECTED = 'Rejected', + REDIRECTED = 'Redirected', + UNAVAILABLE = 'Unavailable', + NOT_FOUND = 'Not Found', + ADDRESS_INCOMPLETE = 'Address Incomplete', + INCOMPATIBLE_SDP = 'Incompatible SDP', + MISSING_SDP = 'Missing SDP', + AUTHENTICATION_ERROR = 'Authentication Error', + BYE = 'Terminated', + WEBRTC_ERROR = 'WebRTC Error', + CANCELED = 'Canceled', + NO_ANSWER = 'No Answer', + EXPIRES = 'Expires', + NO_ACK = 'No ACK', + DIALOG_ERROR = 'Dialog Error', + USER_DENIED_MEDIA_ACCESS = 'User Denied Media Access', + BAD_MEDIA_DESCRIPTION = 'Bad Media Description', + RTP_TIMEOUT = 'RTP Timeout', } export const SIP_ERROR_CAUSES: { - REDIRECTED: [300, 301, 302, 305, 380], - BUSY: [486, 600], - REJECTED: [403, 603], - NOT_FOUND: [404, 604], - UNAVAILABLE: [480, 410, 408, 430], - ADDRESS_INCOMPLETE: [484, 424], - INCOMPATIBLE_SDP: [488, 606], - AUTHENTICATION_ERROR: [401, 407] -} -export const ACK = 'ACK' -export const BYE = 'BYE' -export const CANCEL = 'CANCEL' -export const INFO = 'INFO' -export const INVITE = 'INVITE' -export const MESSAGE = 'MESSAGE' -export const NOTIFY = 'NOTIFY' -export const OPTIONS = 'OPTIONS' -export const REGISTER = 'REGISTER' -export const REFER = 'REFER' -export const UPDATE = 'UPDATE' -export const SUBSCRIBE = 'SUBSCRIBE' + REDIRECTED: [300, 301, 302, 305, 380]; + BUSY: [486, 600]; + REJECTED: [403, 603]; + NOT_FOUND: [404, 604]; + UNAVAILABLE: [480, 410, 408, 430]; + ADDRESS_INCOMPLETE: [484, 424]; + INCOMPATIBLE_SDP: [488, 606]; + AUTHENTICATION_ERROR: [401, 407]; +}; +export const ACK = 'ACK'; +export const BYE = 'BYE'; +export const CANCEL = 'CANCEL'; +export const INFO = 'INFO'; +export const INVITE = 'INVITE'; +export const MESSAGE = 'MESSAGE'; +export const NOTIFY = 'NOTIFY'; +export const OPTIONS = 'OPTIONS'; +export const REGISTER = 'REGISTER'; +export const REFER = 'REFER'; +export const UPDATE = 'UPDATE'; +export const SUBSCRIBE = 'SUBSCRIBE'; export declare enum DTMF_TRANSPORT { - INFO = 'INFO', - RFC2833 = 'RFC2833', + // eslint-disable-next-line no-shadow + INFO = 'INFO', + RFC2833 = 'RFC2833', } -export const REASON_PHRASE: Record -export const ALLOWED_METHODS = 'INVITE,ACK,CANCEL,BYE,UPDATE,MESSAGE,OPTIONS,REFER,INFO,NOTIFY,SUBSCRIBE' -export const ACCEPTED_BODY_TYPES = 'application/sdp, application/dtmf-relay' -export const MAX_FORWARDS = 69 -export const SESSION_EXPIRES = 90 -export const MIN_SESSION_EXPIRES = 60 -export const CONNECTION_RECOVERY_MAX_INTERVAL = 30 -export const CONNECTION_RECOVERY_MIN_INTERVAL = 2 +export const REASON_PHRASE: Record; +export const ALLOWED_METHODS = + 'INVITE,ACK,CANCEL,BYE,UPDATE,MESSAGE,OPTIONS,REFER,INFO,NOTIFY,SUBSCRIBE'; +export const ACCEPTED_BODY_TYPES = 'application/sdp, application/dtmf-relay'; +export const MAX_FORWARDS = 69; +export const SESSION_EXPIRES = 90; +export const MIN_SESSION_EXPIRES = 60; +export const CONNECTION_RECOVERY_MAX_INTERVAL = 30; +export const CONNECTION_RECOVERY_MIN_INTERVAL = 2; diff --git a/src/Constants.js b/src/Constants.js index bb10550d..0212f562 100644 --- a/src/Constants.js +++ b/src/Constants.js @@ -1,159 +1,160 @@ const pkg = require('../package.json'); module.exports = { - USER_AGENT : `${pkg.title} ${pkg.version}`, + USER_AGENT: `${pkg.title} ${pkg.version}`, - // SIP scheme. - SIP : 'sip', - SIPS : 'sips', + // SIP scheme. + SIP: 'sip', + SIPS: 'sips', - // End and Failure causes. - causes : { - // Generic error causes. - CONNECTION_ERROR : 'Connection Error', - REQUEST_TIMEOUT : 'Request Timeout', - SIP_FAILURE_CODE : 'SIP Failure Code', - INTERNAL_ERROR : 'Internal Error', + // End and Failure causes. + causes: { + // Generic error causes. + CONNECTION_ERROR: 'Connection Error', + REQUEST_TIMEOUT: 'Request Timeout', + SIP_FAILURE_CODE: 'SIP Failure Code', + INTERNAL_ERROR: 'Internal Error', - // SIP error causes. - BUSY : 'Busy', - REJECTED : 'Rejected', - REDIRECTED : 'Redirected', - UNAVAILABLE : 'Unavailable', - NOT_FOUND : 'Not Found', - ADDRESS_INCOMPLETE : 'Address Incomplete', - INCOMPATIBLE_SDP : 'Incompatible SDP', - MISSING_SDP : 'Missing SDP', - AUTHENTICATION_ERROR : 'Authentication Error', + // SIP error causes. + BUSY: 'Busy', + REJECTED: 'Rejected', + REDIRECTED: 'Redirected', + UNAVAILABLE: 'Unavailable', + NOT_FOUND: 'Not Found', + ADDRESS_INCOMPLETE: 'Address Incomplete', + INCOMPATIBLE_SDP: 'Incompatible SDP', + MISSING_SDP: 'Missing SDP', + AUTHENTICATION_ERROR: 'Authentication Error', - // Session error causes. - BYE : 'Terminated', - WEBRTC_ERROR : 'WebRTC Error', - CANCELED : 'Canceled', - NO_ANSWER : 'No Answer', - EXPIRES : 'Expires', - NO_ACK : 'No ACK', - DIALOG_ERROR : 'Dialog Error', - USER_DENIED_MEDIA_ACCESS : 'User Denied Media Access', - BAD_MEDIA_DESCRIPTION : 'Bad Media Description', - RTP_TIMEOUT : 'RTP Timeout' - }, + // Session error causes. + BYE: 'Terminated', + WEBRTC_ERROR: 'WebRTC Error', + CANCELED: 'Canceled', + NO_ANSWER: 'No Answer', + EXPIRES: 'Expires', + NO_ACK: 'No ACK', + DIALOG_ERROR: 'Dialog Error', + USER_DENIED_MEDIA_ACCESS: 'User Denied Media Access', + BAD_MEDIA_DESCRIPTION: 'Bad Media Description', + RTP_TIMEOUT: 'RTP Timeout', + }, - SIP_ERROR_CAUSES : { - REDIRECTED : [ 300, 301, 302, 305, 380 ], - BUSY : [ 486, 600 ], - REJECTED : [ 403, 603 ], - NOT_FOUND : [ 404, 604 ], - UNAVAILABLE : [ 480, 410, 408, 430 ], - ADDRESS_INCOMPLETE : [ 484, 424 ], - INCOMPATIBLE_SDP : [ 488, 606 ], - AUTHENTICATION_ERROR : [ 401, 407 ] - }, + SIP_ERROR_CAUSES: { + REDIRECTED: [300, 301, 302, 305, 380], + BUSY: [486, 600], + REJECTED: [403, 603], + NOT_FOUND: [404, 604], + UNAVAILABLE: [480, 410, 408, 430], + ADDRESS_INCOMPLETE: [484, 424], + INCOMPATIBLE_SDP: [488, 606], + AUTHENTICATION_ERROR: [401, 407], + }, - // SIP Methods. - ACK : 'ACK', - BYE : 'BYE', - CANCEL : 'CANCEL', - INFO : 'INFO', - INVITE : 'INVITE', - MESSAGE : 'MESSAGE', - NOTIFY : 'NOTIFY', - OPTIONS : 'OPTIONS', - REGISTER : 'REGISTER', - REFER : 'REFER', - UPDATE : 'UPDATE', - SUBSCRIBE : 'SUBSCRIBE', + // SIP Methods. + ACK: 'ACK', + BYE: 'BYE', + CANCEL: 'CANCEL', + INFO: 'INFO', + INVITE: 'INVITE', + MESSAGE: 'MESSAGE', + NOTIFY: 'NOTIFY', + OPTIONS: 'OPTIONS', + REGISTER: 'REGISTER', + REFER: 'REFER', + UPDATE: 'UPDATE', + SUBSCRIBE: 'SUBSCRIBE', - // DTMF transport methods. - DTMF_TRANSPORT : { - INFO : 'INFO', - RFC2833 : 'RFC2833' - }, + // DTMF transport methods. + DTMF_TRANSPORT: { + INFO: 'INFO', + RFC2833: 'RFC2833', + }, - /* SIP Response Reasons - * DOC: https://www.iana.org/assignments/sip-parameters - * Copied from https://github.com/versatica/OverSIP/blob/master/lib/oversip/sip/constants.rb#L7 - */ - REASON_PHRASE : { - 100 : 'Trying', - 180 : 'Ringing', - 181 : 'Call Is Being Forwarded', - 182 : 'Queued', - 183 : 'Session Progress', - 199 : 'Early Dialog Terminated', // draft-ietf-sipcore-199 - 200 : 'OK', - 202 : 'Accepted', // RFC 3265 - 204 : 'No Notification', // RFC 5839 - 300 : 'Multiple Choices', - 301 : 'Moved Permanently', - 302 : 'Moved Temporarily', - 305 : 'Use Proxy', - 380 : 'Alternative Service', - 400 : 'Bad Request', - 401 : 'Unauthorized', - 402 : 'Payment Required', - 403 : 'Forbidden', - 404 : 'Not Found', - 405 : 'Method Not Allowed', - 406 : 'Not Acceptable', - 407 : 'Proxy Authentication Required', - 408 : 'Request Timeout', - 410 : 'Gone', - 412 : 'Conditional Request Failed', // RFC 3903 - 413 : 'Request Entity Too Large', - 414 : 'Request-URI Too Long', - 415 : 'Unsupported Media Type', - 416 : 'Unsupported URI Scheme', - 417 : 'Unknown Resource-Priority', // RFC 4412 - 420 : 'Bad Extension', - 421 : 'Extension Required', - 422 : 'Session Interval Too Small', // RFC 4028 - 423 : 'Interval Too Brief', - 424 : 'Bad Location Information', // RFC 6442 - 428 : 'Use Identity Header', // RFC 4474 - 429 : 'Provide Referrer Identity', // RFC 3892 - 430 : 'Flow Failed', // RFC 5626 - 433 : 'Anonymity Disallowed', // RFC 5079 - 436 : 'Bad Identity-Info', // RFC 4474 - 437 : 'Unsupported Certificate', // RFC 4744 - 438 : 'Invalid Identity Header', // RFC 4744 - 439 : 'First Hop Lacks Outbound Support', // RFC 5626 - 440 : 'Max-Breadth Exceeded', // RFC 5393 - 469 : 'Bad Info Package', // draft-ietf-sipcore-info-events - 470 : 'Consent Needed', // RFC 5360 - 478 : 'Unresolvable Destination', // Custom code copied from Kamailio. - 480 : 'Temporarily Unavailable', - 481 : 'Call/Transaction Does Not Exist', - 482 : 'Loop Detected', - 483 : 'Too Many Hops', - 484 : 'Address Incomplete', - 485 : 'Ambiguous', - 486 : 'Busy Here', - 487 : 'Request Terminated', - 488 : 'Not Acceptable Here', - 489 : 'Bad Event', // RFC 3265 - 491 : 'Request Pending', - 493 : 'Undecipherable', - 494 : 'Security Agreement Required', // RFC 3329 - 500 : 'JsSIP Internal Error', - 501 : 'Not Implemented', - 502 : 'Bad Gateway', - 503 : 'Service Unavailable', - 504 : 'Server Time-out', - 505 : 'Version Not Supported', - 513 : 'Message Too Large', - 580 : 'Precondition Failure', // RFC 3312 - 600 : 'Busy Everywhere', - 603 : 'Decline', - 604 : 'Does Not Exist Anywhere', - 606 : 'Not Acceptable' - }, + /* SIP Response Reasons + * DOC: https://www.iana.org/assignments/sip-parameters + * Copied from https://github.com/versatica/OverSIP/blob/master/lib/oversip/sip/constants.rb#L7 + */ + REASON_PHRASE: { + 100: 'Trying', + 180: 'Ringing', + 181: 'Call Is Being Forwarded', + 182: 'Queued', + 183: 'Session Progress', + 199: 'Early Dialog Terminated', // draft-ietf-sipcore-199 + 200: 'OK', + 202: 'Accepted', // RFC 3265 + 204: 'No Notification', // RFC 5839 + 300: 'Multiple Choices', + 301: 'Moved Permanently', + 302: 'Moved Temporarily', + 305: 'Use Proxy', + 380: 'Alternative Service', + 400: 'Bad Request', + 401: 'Unauthorized', + 402: 'Payment Required', + 403: 'Forbidden', + 404: 'Not Found', + 405: 'Method Not Allowed', + 406: 'Not Acceptable', + 407: 'Proxy Authentication Required', + 408: 'Request Timeout', + 410: 'Gone', + 412: 'Conditional Request Failed', // RFC 3903 + 413: 'Request Entity Too Large', + 414: 'Request-URI Too Long', + 415: 'Unsupported Media Type', + 416: 'Unsupported URI Scheme', + 417: 'Unknown Resource-Priority', // RFC 4412 + 420: 'Bad Extension', + 421: 'Extension Required', + 422: 'Session Interval Too Small', // RFC 4028 + 423: 'Interval Too Brief', + 424: 'Bad Location Information', // RFC 6442 + 428: 'Use Identity Header', // RFC 4474 + 429: 'Provide Referrer Identity', // RFC 3892 + 430: 'Flow Failed', // RFC 5626 + 433: 'Anonymity Disallowed', // RFC 5079 + 436: 'Bad Identity-Info', // RFC 4474 + 437: 'Unsupported Certificate', // RFC 4744 + 438: 'Invalid Identity Header', // RFC 4744 + 439: 'First Hop Lacks Outbound Support', // RFC 5626 + 440: 'Max-Breadth Exceeded', // RFC 5393 + 469: 'Bad Info Package', // draft-ietf-sipcore-info-events + 470: 'Consent Needed', // RFC 5360 + 478: 'Unresolvable Destination', // Custom code copied from Kamailio. + 480: 'Temporarily Unavailable', + 481: 'Call/Transaction Does Not Exist', + 482: 'Loop Detected', + 483: 'Too Many Hops', + 484: 'Address Incomplete', + 485: 'Ambiguous', + 486: 'Busy Here', + 487: 'Request Terminated', + 488: 'Not Acceptable Here', + 489: 'Bad Event', // RFC 3265 + 491: 'Request Pending', + 493: 'Undecipherable', + 494: 'Security Agreement Required', // RFC 3329 + 500: 'JsSIP Internal Error', + 501: 'Not Implemented', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + 504: 'Server Time-out', + 505: 'Version Not Supported', + 513: 'Message Too Large', + 580: 'Precondition Failure', // RFC 3312 + 600: 'Busy Everywhere', + 603: 'Decline', + 604: 'Does Not Exist Anywhere', + 606: 'Not Acceptable', + }, - ALLOWED_METHODS : 'INVITE,ACK,CANCEL,BYE,UPDATE,MESSAGE,OPTIONS,REFER,INFO,NOTIFY,SUBSCRIBE', - ACCEPTED_BODY_TYPES : 'application/sdp, application/dtmf-relay', - MAX_FORWARDS : 69, - SESSION_EXPIRES : 90, - MIN_SESSION_EXPIRES : 60, - CONNECTION_RECOVERY_MAX_INTERVAL : 30, - CONNECTION_RECOVERY_MIN_INTERVAL : 2 + ALLOWED_METHODS: + 'INVITE,ACK,CANCEL,BYE,UPDATE,MESSAGE,OPTIONS,REFER,INFO,NOTIFY,SUBSCRIBE', + ACCEPTED_BODY_TYPES: 'application/sdp, application/dtmf-relay', + MAX_FORWARDS: 69, + SESSION_EXPIRES: 90, + MIN_SESSION_EXPIRES: 60, + CONNECTION_RECOVERY_MAX_INTERVAL: 30, + CONNECTION_RECOVERY_MIN_INTERVAL: 2, }; diff --git a/src/Dialog.js b/src/Dialog.js index 8ec47a6b..c8f68ccd 100644 --- a/src/Dialog.js +++ b/src/Dialog.js @@ -8,329 +8,300 @@ const Utils = require('./Utils'); const logger = new Logger('Dialog'); const C = { - // Dialog states. - STATUS_EARLY : 1, - STATUS_CONFIRMED : 2, - STATUS_TERMINATED : 3 + // Dialog states. + STATUS_EARLY: 1, + STATUS_CONFIRMED: 2, + STATUS_TERMINATED: 3, }; // RFC 3261 12.1. -module.exports = class Dialog -{ - // Expose C object. - static get C() - { - return C; - } - - constructor(owner, message, type, state = C.STATUS_CONFIRMED) - { - this._owner = owner; - this._ua = owner._ua; - - this._uac_pending_reply = false; - this._uas_pending_reply = false; - - if (!message.hasHeader('contact')) - { - return { - error : 'unable to create a Dialog without Contact header field' - }; - } - - if (message instanceof SIPMessage.IncomingResponse) - { - state = (message.status_code < 200) ? C.STATUS_EARLY : C.STATUS_CONFIRMED; - } - - const contact = message.parseHeader('contact'); - - // RFC 3261 12.1.1. - if (type === 'UAS') - { - this._id = { - call_id : message.call_id, - local_tag : message.to_tag, - remote_tag : message.from_tag, - toString() - { - return this.call_id + this.local_tag + this.remote_tag; - } - }; - this._state = state; - this._remote_seqnum = message.cseq; - this._local_uri = message.parseHeader('to').uri; - this._remote_uri = message.parseHeader('from').uri; - this._remote_target = contact.uri; - this._route_set = message.getHeaders('record-route'); - this.incoming_ack_seqnum = message.cseq; - this.outgoing_ack_seqnum = null; - } - // RFC 3261 12.1.2. - else if (type === 'UAC') - { - this._id = { - call_id : message.call_id, - local_tag : message.from_tag, - remote_tag : message.to_tag, - toString() - { - return this.call_id + this.local_tag + this.remote_tag; - } - }; - this._state = state; - this._local_seqnum = message.cseq; - this._local_uri = message.parseHeader('from').uri; - this._remote_uri = message.parseHeader('to').uri; - this._remote_target = contact.uri; - this._route_set = message.getHeaders('record-route').reverse(); - this.incoming_ack_seqnum = null; - this.outgoing_ack_seqnum = this._local_seqnum; - - } - - this._ua.newDialog(this); - logger.debug(`new ${type} dialog created with status ${this._state === C.STATUS_EARLY ? 'EARLY': 'CONFIRMED'}`); - } - - get id() - { - return this._id; - } - - get local_seqnum() - { - return this._local_seqnum; - } - - set local_seqnum(num) - { - this._local_seqnum = num; - } - - get owner() - { - return this._owner; - } - - get uac_pending_reply() - { - return this._uac_pending_reply; - } - - set uac_pending_reply(pending) - { - this._uac_pending_reply = pending; - } - - get uas_pending_reply() - { - return this._uas_pending_reply; - } - - isTerminated() - { - return this._status === C.STATUS_TERMINATED; - } - - update(message, type) - { - this._state = C.STATUS_CONFIRMED; - - logger.debug(`dialog ${this._id.toString()} changed to CONFIRMED state`); - - if (type === 'UAC') - { - // RFC 3261 13.2.2.4. - this._route_set = message.getHeaders('record-route').reverse(); - } - } - - terminate() - { - logger.debug(`dialog ${this._id.toString()} deleted`); - - this._ua.destroyDialog(this); - this._state = C.STATUS_TERMINATED; - } - - sendRequest(method, options = {}) - { - const extraHeaders = Utils.cloneArray(options.extraHeaders); - const eventHandlers = Utils.cloneObject(options.eventHandlers); - const body = options.body || null; - const request = this._createRequest(method, extraHeaders, body); - - // Increase the local CSeq on authentication. - eventHandlers.onAuthenticated = () => - { - this._local_seqnum += 1; - - // In case of re-INVITE store outgoing ack_seqnum for its CANCEL or ACK. - if (request.method === JsSIP_C.INVITE) - { - this._outgoing_ack_seqnum = this._local_seqnum; - } - }; - - const request_sender = new Dialog_RequestSender(this, request, eventHandlers); - - request_sender.send(); - - // Return the instance of OutgoingRequest. - return request; - } - - receiveRequest(request) - { - // Check in-dialog request. - if (!this._checkInDialogRequest(request)) - { - return; - } - - // ACK received. Cleanup this._ack_seqnum. - if (request.method === JsSIP_C.ACK && this.incoming_ack_seqnum !== null) - { - this.incoming_ack_seqnum = null; - } - // INVITE received. Set this._ack_seqnum. - else if (request.method === JsSIP_C.INVITE) - { - this.incoming_ack_seqnum = request.cseq; - } - - this._owner.receiveRequest(request); - } - - // RFC 3261 12.2.1.1. - _createRequest(method, extraHeaders, body) - { - extraHeaders = Utils.cloneArray(extraHeaders); - - if (!this._local_seqnum) { this._local_seqnum = Math.floor(Math.random() * 10000); } - - // CANCEL and ACK must use the same sequence number as the INVITE. - const cseq = (method === JsSIP_C.CANCEL || method === JsSIP_C.ACK) ? - this.outgoing_ack_seqnum : - this._local_seqnum += 1; - - // In case of re-INVITE store ack_seqnum for future CANCEL or ACK. - if (method === JsSIP_C.INVITE) - { - this.outgoing_ack_seqnum = cseq; - } - - const request = new SIPMessage.OutgoingRequest( - method, - this._remote_target, - this._ua, { - 'cseq' : cseq, - 'call_id' : this._id.call_id, - 'from_uri' : this._local_uri, - 'from_tag' : this._id.local_tag, - 'to_uri' : this._remote_uri, - 'to_tag' : this._id.remote_tag, - 'route_set' : this._route_set - }, extraHeaders, body); - - return request; - } - - // RFC 3261 12.2.2. - _checkInDialogRequest(request) - { - - if (!this._remote_seqnum) - { - this._remote_seqnum = request.cseq; - } - else if (request.cseq < this._remote_seqnum) - { - if (request.method === JsSIP_C.ACK) - { - // We are not expecting any ACK with lower seqnum than the current one. - // Or this is not the ACK we are waiting for. - if (this.incoming_ack_seqnum === null || - request.cseq !== this.incoming_ack_seqnum) - { - return false; - } - } - else - { - request.reply(500); - - return false; - } - } - else if (request.cseq > this._remote_seqnum) - { - this._remote_seqnum = request.cseq; - } - - // RFC3261 14.2 Modifying an Existing Session -UAS BEHAVIOR-. - if (request.method === JsSIP_C.INVITE || - (request.method === JsSIP_C.UPDATE && request.body)) - { - if (this._uac_pending_reply === true) - { - request.reply(491); - } - else if (this._uas_pending_reply === true) - { - const retryAfter = (Math.random() * 10 | 0) + 1; - - request.reply(500, null, [ `Retry-After:${retryAfter}` ]); - - return false; - } - else - { - this._uas_pending_reply = true; - - const stateChanged = () => - { - if (request.server_transaction.state === Transactions.C.STATUS_ACCEPTED || - request.server_transaction.state === Transactions.C.STATUS_COMPLETED || - request.server_transaction.state === Transactions.C.STATUS_TERMINATED) - { - - request.server_transaction.removeListener('stateChanged', stateChanged); - this._uas_pending_reply = false; - } - }; - - request.server_transaction.on('stateChanged', stateChanged); - } - - // RFC3261 12.2.2 Replace the dialog`s remote target URI if the request is accepted. - if (request.hasHeader('contact')) - { - request.server_transaction.on('stateChanged', () => - { - if (request.server_transaction.state === Transactions.C.STATUS_ACCEPTED) - { - this._remote_target = request.parseHeader('contact').uri; - } - }); - } - } - else if (request.method === JsSIP_C.NOTIFY) - { - // RFC6665 3.2 Replace the dialog`s remote target URI if the request is accepted. - if (request.hasHeader('contact')) - { - request.server_transaction.on('stateChanged', () => - { - if (request.server_transaction.state === Transactions.C.STATUS_COMPLETED) - { - this._remote_target = request.parseHeader('contact').uri; - } - }); - } - } - - return true; - } +module.exports = class Dialog { + // Expose C object. + static get C() { + return C; + } + + constructor(owner, message, type, state = C.STATUS_CONFIRMED) { + this._owner = owner; + this._ua = owner._ua; + + this._uac_pending_reply = false; + this._uas_pending_reply = false; + + if (!message.hasHeader('contact')) { + return { + error: 'unable to create a Dialog without Contact header field', + }; + } + + if (message instanceof SIPMessage.IncomingResponse) { + state = message.status_code < 200 ? C.STATUS_EARLY : C.STATUS_CONFIRMED; + } + + const contact = message.parseHeader('contact'); + + // RFC 3261 12.1.1. + if (type === 'UAS') { + this._id = { + call_id: message.call_id, + local_tag: message.to_tag, + remote_tag: message.from_tag, + toString() { + return this.call_id + this.local_tag + this.remote_tag; + }, + }; + this._state = state; + this._remote_seqnum = message.cseq; + this._local_uri = message.parseHeader('to').uri; + this._remote_uri = message.parseHeader('from').uri; + this._remote_target = contact.uri; + this._route_set = message.getHeaders('record-route'); + this.incoming_ack_seqnum = message.cseq; + this.outgoing_ack_seqnum = null; + } + // RFC 3261 12.1.2. + else if (type === 'UAC') { + this._id = { + call_id: message.call_id, + local_tag: message.from_tag, + remote_tag: message.to_tag, + toString() { + return this.call_id + this.local_tag + this.remote_tag; + }, + }; + this._state = state; + this._local_seqnum = message.cseq; + this._local_uri = message.parseHeader('from').uri; + this._remote_uri = message.parseHeader('to').uri; + this._remote_target = contact.uri; + this._route_set = message.getHeaders('record-route').reverse(); + this.incoming_ack_seqnum = null; + this.outgoing_ack_seqnum = this._local_seqnum; + } + + this._ua.newDialog(this); + logger.debug( + `new ${type} dialog created with status ${this._state === C.STATUS_EARLY ? 'EARLY' : 'CONFIRMED'}` + ); + } + + get id() { + return this._id; + } + + get local_seqnum() { + return this._local_seqnum; + } + + set local_seqnum(num) { + this._local_seqnum = num; + } + + get owner() { + return this._owner; + } + + get uac_pending_reply() { + return this._uac_pending_reply; + } + + set uac_pending_reply(pending) { + this._uac_pending_reply = pending; + } + + get uas_pending_reply() { + return this._uas_pending_reply; + } + + isTerminated() { + return this._status === C.STATUS_TERMINATED; + } + + update(message, type) { + this._state = C.STATUS_CONFIRMED; + + logger.debug(`dialog ${this._id.toString()} changed to CONFIRMED state`); + + if (type === 'UAC') { + // RFC 3261 13.2.2.4. + this._route_set = message.getHeaders('record-route').reverse(); + } + } + + terminate() { + logger.debug(`dialog ${this._id.toString()} deleted`); + + this._ua.destroyDialog(this); + this._state = C.STATUS_TERMINATED; + } + + sendRequest(method, options = {}) { + const extraHeaders = Utils.cloneArray(options.extraHeaders); + const eventHandlers = Utils.cloneObject(options.eventHandlers); + const body = options.body || null; + const request = this._createRequest(method, extraHeaders, body); + + // Increase the local CSeq on authentication. + eventHandlers.onAuthenticated = () => { + this._local_seqnum += 1; + + // In case of re-INVITE store outgoing ack_seqnum for its CANCEL or ACK. + if (request.method === JsSIP_C.INVITE) { + this._outgoing_ack_seqnum = this._local_seqnum; + } + }; + + const request_sender = new Dialog_RequestSender( + this, + request, + eventHandlers + ); + + request_sender.send(); + + // Return the instance of OutgoingRequest. + return request; + } + + receiveRequest(request) { + // Check in-dialog request. + if (!this._checkInDialogRequest(request)) { + return; + } + + // ACK received. Cleanup this._ack_seqnum. + if (request.method === JsSIP_C.ACK && this.incoming_ack_seqnum !== null) { + this.incoming_ack_seqnum = null; + } + // INVITE received. Set this._ack_seqnum. + else if (request.method === JsSIP_C.INVITE) { + this.incoming_ack_seqnum = request.cseq; + } + + this._owner.receiveRequest(request); + } + + // RFC 3261 12.2.1.1. + _createRequest(method, extraHeaders, body) { + extraHeaders = Utils.cloneArray(extraHeaders); + + if (!this._local_seqnum) { + this._local_seqnum = Math.floor(Math.random() * 10000); + } + + // CANCEL and ACK must use the same sequence number as the INVITE. + const cseq = + method === JsSIP_C.CANCEL || method === JsSIP_C.ACK + ? this.outgoing_ack_seqnum + : (this._local_seqnum += 1); + + // In case of re-INVITE store ack_seqnum for future CANCEL or ACK. + if (method === JsSIP_C.INVITE) { + this.outgoing_ack_seqnum = cseq; + } + + const request = new SIPMessage.OutgoingRequest( + method, + this._remote_target, + this._ua, + { + cseq: cseq, + call_id: this._id.call_id, + from_uri: this._local_uri, + from_tag: this._id.local_tag, + to_uri: this._remote_uri, + to_tag: this._id.remote_tag, + route_set: this._route_set, + }, + extraHeaders, + body + ); + + return request; + } + + // RFC 3261 12.2.2. + _checkInDialogRequest(request) { + if (!this._remote_seqnum) { + this._remote_seqnum = request.cseq; + } else if (request.cseq < this._remote_seqnum) { + if (request.method === JsSIP_C.ACK) { + // We are not expecting any ACK with lower seqnum than the current one. + // Or this is not the ACK we are waiting for. + if ( + this.incoming_ack_seqnum === null || + request.cseq !== this.incoming_ack_seqnum + ) { + return false; + } + } else { + request.reply(500); + + return false; + } + } else if (request.cseq > this._remote_seqnum) { + this._remote_seqnum = request.cseq; + } + + // RFC3261 14.2 Modifying an Existing Session -UAS BEHAVIOR-. + if ( + request.method === JsSIP_C.INVITE || + (request.method === JsSIP_C.UPDATE && request.body) + ) { + if (this._uac_pending_reply === true) { + request.reply(491); + } else if (this._uas_pending_reply === true) { + const retryAfter = ((Math.random() * 10) | 0) + 1; + + request.reply(500, null, [`Retry-After:${retryAfter}`]); + + return false; + } else { + this._uas_pending_reply = true; + + const stateChanged = () => { + if ( + request.server_transaction.state === + Transactions.C.STATUS_ACCEPTED || + request.server_transaction.state === + Transactions.C.STATUS_COMPLETED || + request.server_transaction.state === + Transactions.C.STATUS_TERMINATED + ) { + request.server_transaction.removeListener( + 'stateChanged', + stateChanged + ); + this._uas_pending_reply = false; + } + }; + + request.server_transaction.on('stateChanged', stateChanged); + } + + // RFC3261 12.2.2 Replace the dialog`s remote target URI if the request is accepted. + if (request.hasHeader('contact')) { + request.server_transaction.on('stateChanged', () => { + if ( + request.server_transaction.state === Transactions.C.STATUS_ACCEPTED + ) { + this._remote_target = request.parseHeader('contact').uri; + } + }); + } + } else if (request.method === JsSIP_C.NOTIFY) { + // RFC6665 3.2 Replace the dialog`s remote target URI if the request is accepted. + if (request.hasHeader('contact')) { + request.server_transaction.on('stateChanged', () => { + if ( + request.server_transaction.state === Transactions.C.STATUS_COMPLETED + ) { + this._remote_target = request.parseHeader('contact').uri; + } + }); + } + } + + return true; + } }; diff --git a/src/Dialog/RequestSender.js b/src/Dialog/RequestSender.js index 88d1b7ff..adeeb393 100644 --- a/src/Dialog/RequestSender.js +++ b/src/Dialog/RequestSender.js @@ -4,123 +4,110 @@ const RequestSender = require('../RequestSender'); // Default event handlers. const EventHandlers = { - onRequestTimeout : () => {}, - onTransportError : () => {}, - onSuccessResponse : () => {}, - onErrorResponse : () => {}, - onAuthenticated : () => {}, - onDialogError : () => {} + onRequestTimeout: () => {}, + onTransportError: () => {}, + onSuccessResponse: () => {}, + onErrorResponse: () => {}, + onAuthenticated: () => {}, + onDialogError: () => {}, }; -module.exports = class DialogRequestSender -{ - constructor(dialog, request, eventHandlers) - { - this._dialog = dialog; - this._ua = dialog._ua; - this._request = request; - this._eventHandlers = eventHandlers; +module.exports = class DialogRequestSender { + constructor(dialog, request, eventHandlers) { + this._dialog = dialog; + this._ua = dialog._ua; + this._request = request; + this._eventHandlers = eventHandlers; - // RFC3261 14.1 Modifying an Existing Session. UAC Behavior. - this._reattempt = false; - this._reattemptTimer = null; + // RFC3261 14.1 Modifying an Existing Session. UAC Behavior. + this._reattempt = false; + this._reattemptTimer = null; - // Define the undefined handlers. - for (const handler in EventHandlers) - { - if (Object.prototype.hasOwnProperty.call(EventHandlers, handler)) - { - if (!this._eventHandlers[handler]) - { - this._eventHandlers[handler] = EventHandlers[handler]; - } - } - } - } + // Define the undefined handlers. + for (const handler in EventHandlers) { + if (Object.prototype.hasOwnProperty.call(EventHandlers, handler)) { + if (!this._eventHandlers[handler]) { + this._eventHandlers[handler] = EventHandlers[handler]; + } + } + } + } - get request() - { - return this._request; - } + get request() { + return this._request; + } - send() - { - const request_sender = new RequestSender(this._ua, this._request, { - onRequestTimeout : () => - { - this._eventHandlers.onRequestTimeout(); - }, - onTransportError : () => - { - this._eventHandlers.onTransportError(); - }, - onAuthenticated : (request) => - { - this._eventHandlers.onAuthenticated(request); - }, - onReceiveResponse : (response) => - { - this._receiveResponse(response); - } - }); + send() { + const request_sender = new RequestSender(this._ua, this._request, { + onRequestTimeout: () => { + this._eventHandlers.onRequestTimeout(); + }, + onTransportError: () => { + this._eventHandlers.onTransportError(); + }, + onAuthenticated: request => { + this._eventHandlers.onAuthenticated(request); + }, + onReceiveResponse: response => { + this._receiveResponse(response); + }, + }); - request_sender.send(); + request_sender.send(); - // RFC3261 14.2 Modifying an Existing Session -UAC BEHAVIOR-. - if ((this._request.method === JsSIP_C.INVITE || - (this._request.method === JsSIP_C.UPDATE && this._request.body)) && - request_sender.clientTransaction.state !== Transactions.C.STATUS_TERMINATED) - { - this._dialog.uac_pending_reply = true; + // RFC3261 14.2 Modifying an Existing Session -UAC BEHAVIOR-. + if ( + (this._request.method === JsSIP_C.INVITE || + (this._request.method === JsSIP_C.UPDATE && this._request.body)) && + request_sender.clientTransaction.state !== + Transactions.C.STATUS_TERMINATED + ) { + this._dialog.uac_pending_reply = true; - const stateChanged = () => - { - if (request_sender.clientTransaction.state === Transactions.C.STATUS_ACCEPTED || - request_sender.clientTransaction.state === Transactions.C.STATUS_COMPLETED || - request_sender.clientTransaction.state === Transactions.C.STATUS_TERMINATED) - { - request_sender.clientTransaction.removeListener('stateChanged', stateChanged); - this._dialog.uac_pending_reply = false; - } - }; + const stateChanged = () => { + if ( + request_sender.clientTransaction.state === + Transactions.C.STATUS_ACCEPTED || + request_sender.clientTransaction.state === + Transactions.C.STATUS_COMPLETED || + request_sender.clientTransaction.state === + Transactions.C.STATUS_TERMINATED + ) { + request_sender.clientTransaction.removeListener( + 'stateChanged', + stateChanged + ); + this._dialog.uac_pending_reply = false; + } + }; - request_sender.clientTransaction.on('stateChanged', stateChanged); - } - } + request_sender.clientTransaction.on('stateChanged', stateChanged); + } + } - _receiveResponse(response) - { - // RFC3261 12.2.1.2 408 or 481 is received for a request within a dialog. - if (response.status_code === 408 || response.status_code === 481) - { - this._eventHandlers.onDialogError(response); - } - else if (response.method === JsSIP_C.INVITE && response.status_code === 491) - { - if (this._reattempt) - { - this._eventHandlers.onErrorResponse(response); - } - else - { - this._request.cseq = this._dialog.local_seqnum += 1; - this._reattemptTimer = setTimeout(() => - { - if (!this._dialog.isTerminated()) - { - this._reattempt = true; - this.send(); - } - }, 1000); - } - } - else if (response.status_code >= 200 && response.status_code < 300) - { - this._eventHandlers.onSuccessResponse(response); - } - else if (response.status_code >= 300) - { - this._eventHandlers.onErrorResponse(response); - } - } + _receiveResponse(response) { + // RFC3261 12.2.1.2 408 or 481 is received for a request within a dialog. + if (response.status_code === 408 || response.status_code === 481) { + this._eventHandlers.onDialogError(response); + } else if ( + response.method === JsSIP_C.INVITE && + response.status_code === 491 + ) { + if (this._reattempt) { + this._eventHandlers.onErrorResponse(response); + } else { + this._request.cseq = this._dialog.local_seqnum += 1; + this._reattemptTimer = setTimeout(() => { + if (!this._dialog.isTerminated()) { + this._reattempt = true; + this.send(); + } + }, 1000); + } + } else if (response.status_code >= 200 && response.status_code < 300) { + this._eventHandlers.onSuccessResponse(response); + } else if (response.status_code >= 300) { + this._eventHandlers.onErrorResponse(response); + } + } }; diff --git a/src/DigestAuthentication.js b/src/DigestAuthentication.js index 44b439d7..4758cadf 100644 --- a/src/DigestAuthentication.js +++ b/src/DigestAuthentication.js @@ -3,232 +3,225 @@ const Utils = require('./Utils'); const logger = new Logger('DigestAuthentication'); -module.exports = class DigestAuthentication -{ - constructor(credentials) - { - this._credentials = credentials; - this._cnonce = null; - this._nc = 0; - this._ncHex = '00000000'; - this._algorithm = null; - this._realm = null; - this._nonce = null; - this._opaque = null; - this._stale = null; - this._qop = null; - this._method = null; - this._uri = null; - this._ha1 = null; - this._response = null; - } - - get(parameter) - { - switch (parameter) - { - case 'realm': - return this._realm; - - case 'ha1': - return this._ha1; - - default: - logger.warn('get() | cannot get "%s" parameter', parameter); - - return undefined; - } - } - - /** - * Performs Digest authentication given a SIP request and the challenge - * received in a response to that request. - * Returns true if auth was successfully generated, false otherwise. - */ - authenticate({ method, ruri, body }, challenge, cnonce = null /* test interface */) - { - this._algorithm = challenge.algorithm; - this._realm = challenge.realm; - this._nonce = challenge.nonce; - this._opaque = challenge.opaque; - this._stale = challenge.stale; - - if (this._algorithm) - { - if (this._algorithm !== 'MD5') - { - logger.warn('authenticate() | challenge with Digest algorithm different than "MD5", authentication aborted'); - - return false; - } - } - else - { - this._algorithm = 'MD5'; - } - - if (!this._nonce) - { - logger.warn('authenticate() | challenge without Digest nonce, authentication aborted'); - - return false; - } - - if (!this._realm) - { - logger.warn('authenticate() | challenge without Digest realm, authentication aborted'); - - return false; - } - - // If no plain SIP password is provided. - if (!this._credentials.password) - { - // If ha1 is not provided we cannot authenticate. - if (!this._credentials.ha1) - { - logger.warn('authenticate() | no plain SIP password nor ha1 provided, authentication aborted'); - - return false; - } - - // If the realm does not match the stored realm we cannot authenticate. - if (this._credentials.realm !== this._realm) - { - logger.warn('authenticate() | no plain SIP password, and stored `realm` does not match the given `realm`, cannot authenticate [stored:"%s", given:"%s"]', this._credentials.realm, this._realm); - - return false; - } - } - - // 'qop' can contain a list of values (Array). Let's choose just one. - if (challenge.qop) - { - if (challenge.qop.indexOf('auth-int') > -1) - { - this._qop = 'auth-int'; - } - else if (challenge.qop.indexOf('auth') > -1) - { - this._qop = 'auth'; - } - else - { - // Otherwise 'qop' is present but does not contain 'auth' or 'auth-int', so abort here. - logger.warn('authenticate() | challenge without Digest qop different than "auth" or "auth-int", authentication aborted'); - - return false; - } - } - else - { - this._qop = null; - } - - // Fill other attributes. - - this._method = method; - this._uri = ruri; - this._cnonce = cnonce || Utils.createRandomToken(12); - this._nc += 1; - const hex = Number(this._nc).toString(16); - - this._ncHex = '00000000'.substr(0, 8-hex.length) + hex; - - // Nc-value = 8LHEX. Max value = 'FFFFFFFF'. - if (this._nc === 4294967296) - { - this._nc = 1; - this._ncHex = '00000001'; - } - - // Calculate the Digest "response" value. - - // If we have plain SIP password then regenerate ha1. - if (this._credentials.password) - { - // HA1 = MD5(A1) = MD5(username:realm:password). - this._ha1 = Utils.calculateMD5(`${this._credentials.username}:${this._realm}:${this._credentials.password}`); - } - // Otherwise reuse the stored ha1. - else - { - this._ha1 = this._credentials.ha1; - } - - let a2; - let ha2; - - if (this._qop === 'auth') - { - // HA2 = MD5(A2) = MD5(method:digestURI). - a2 = `${this._method}:${this._uri}`; - ha2 = Utils.calculateMD5(a2); - - logger.debug('authenticate() | using qop=auth [a2:"%s"]', a2); - - // Response = MD5(HA1:nonce:nonceCount:credentialsNonce:qop:HA2). - this._response = Utils.calculateMD5(`${this._ha1}:${this._nonce}:${this._ncHex}:${this._cnonce}:auth:${ha2}`); - - } - else if (this._qop === 'auth-int') - { - // HA2 = MD5(A2) = MD5(method:digestURI:MD5(entityBody)). - a2 = `${this._method}:${this._uri}:${Utils.calculateMD5(body ? body : '')}`; - ha2 = Utils.calculateMD5(a2); - - logger.debug('authenticate() | using qop=auth-int [a2:"%s"]', a2); - - // Response = MD5(HA1:nonce:nonceCount:credentialsNonce:qop:HA2). - this._response = Utils.calculateMD5(`${this._ha1}:${this._nonce}:${this._ncHex}:${this._cnonce}:auth-int:${ha2}`); - - } - else if (this._qop === null) - { - // HA2 = MD5(A2) = MD5(method:digestURI). - a2 = `${this._method}:${this._uri}`; - ha2 = Utils.calculateMD5(a2); - - logger.debug('authenticate() | using qop=null [a2:"%s"]', a2); - - // Response = MD5(HA1:nonce:HA2). - this._response = Utils.calculateMD5(`${this._ha1}:${this._nonce}:${ha2}`); - } - - logger.debug('authenticate() | response generated'); - - return true; - } - - /** - * Return the Proxy-Authorization or WWW-Authorization header value. - */ - toString() - { - const auth_params = []; - - if (!this._response) - { - throw new Error('response field does not exist, cannot generate Authorization header'); - } - - auth_params.push(`algorithm=${this._algorithm}`); - auth_params.push(`username="${this._credentials.username}"`); - auth_params.push(`realm="${this._realm}"`); - auth_params.push(`nonce="${this._nonce}"`); - auth_params.push(`uri="${this._uri}"`); - auth_params.push(`response="${this._response}"`); - if (this._opaque) - { - auth_params.push(`opaque="${this._opaque}"`); - } - if (this._qop) - { - auth_params.push(`qop=${this._qop}`); - auth_params.push(`cnonce="${this._cnonce}"`); - auth_params.push(`nc=${this._ncHex}`); - } - - return `Digest ${auth_params.join(', ')}`; - } +module.exports = class DigestAuthentication { + constructor(credentials) { + this._credentials = credentials; + this._cnonce = null; + this._nc = 0; + this._ncHex = '00000000'; + this._algorithm = null; + this._realm = null; + this._nonce = null; + this._opaque = null; + this._stale = null; + this._qop = null; + this._method = null; + this._uri = null; + this._ha1 = null; + this._response = null; + } + + get(parameter) { + switch (parameter) { + case 'realm': { + return this._realm; + } + + case 'ha1': { + return this._ha1; + } + + default: { + logger.warn('get() | cannot get "%s" parameter', parameter); + + return undefined; + } + } + } + + /** + * Performs Digest authentication given a SIP request and the challenge + * received in a response to that request. + * Returns true if auth was successfully generated, false otherwise. + */ + authenticate( + { method, ruri, body }, + challenge, + cnonce = null /* test interface */ + ) { + this._algorithm = challenge.algorithm; + this._realm = challenge.realm; + this._nonce = challenge.nonce; + this._opaque = challenge.opaque; + this._stale = challenge.stale; + + if (this._algorithm) { + if (this._algorithm !== 'MD5') { + logger.warn( + 'authenticate() | challenge with Digest algorithm different than "MD5", authentication aborted' + ); + + return false; + } + } else { + this._algorithm = 'MD5'; + } + + if (!this._nonce) { + logger.warn( + 'authenticate() | challenge without Digest nonce, authentication aborted' + ); + + return false; + } + + if (!this._realm) { + logger.warn( + 'authenticate() | challenge without Digest realm, authentication aborted' + ); + + return false; + } + + // If no plain SIP password is provided. + if (!this._credentials.password) { + // If ha1 is not provided we cannot authenticate. + if (!this._credentials.ha1) { + logger.warn( + 'authenticate() | no plain SIP password nor ha1 provided, authentication aborted' + ); + + return false; + } + + // If the realm does not match the stored realm we cannot authenticate. + if (this._credentials.realm !== this._realm) { + logger.warn( + 'authenticate() | no plain SIP password, and stored `realm` does not match the given `realm`, cannot authenticate [stored:"%s", given:"%s"]', + this._credentials.realm, + this._realm + ); + + return false; + } + } + + // 'qop' can contain a list of values (Array). Let's choose just one. + if (challenge.qop) { + if (challenge.qop.indexOf('auth-int') > -1) { + this._qop = 'auth-int'; + } else if (challenge.qop.indexOf('auth') > -1) { + this._qop = 'auth'; + } else { + // Otherwise 'qop' is present but does not contain 'auth' or 'auth-int', so abort here. + logger.warn( + 'authenticate() | challenge without Digest qop different than "auth" or "auth-int", authentication aborted' + ); + + return false; + } + } else { + this._qop = null; + } + + // Fill other attributes. + + this._method = method; + this._uri = ruri; + this._cnonce = cnonce || Utils.createRandomToken(12); + this._nc += 1; + const hex = Number(this._nc).toString(16); + + this._ncHex = '00000000'.substr(0, 8 - hex.length) + hex; + + // Nc-value = 8LHEX. Max value = 'FFFFFFFF'. + if (this._nc === 4294967296) { + this._nc = 1; + this._ncHex = '00000001'; + } + + // Calculate the Digest "response" value. + + // If we have plain SIP password then regenerate ha1. + if (this._credentials.password) { + // HA1 = MD5(A1) = MD5(username:realm:password). + this._ha1 = Utils.calculateMD5( + `${this._credentials.username}:${this._realm}:${this._credentials.password}` + ); + } + // Otherwise reuse the stored ha1. + else { + this._ha1 = this._credentials.ha1; + } + + let a2; + let ha2; + + if (this._qop === 'auth') { + // HA2 = MD5(A2) = MD5(method:digestURI). + a2 = `${this._method}:${this._uri}`; + ha2 = Utils.calculateMD5(a2); + + logger.debug('authenticate() | using qop=auth [a2:"%s"]', a2); + + // Response = MD5(HA1:nonce:nonceCount:credentialsNonce:qop:HA2). + this._response = Utils.calculateMD5( + `${this._ha1}:${this._nonce}:${this._ncHex}:${this._cnonce}:auth:${ha2}` + ); + } else if (this._qop === 'auth-int') { + // HA2 = MD5(A2) = MD5(method:digestURI:MD5(entityBody)). + a2 = `${this._method}:${this._uri}:${Utils.calculateMD5(body ? body : '')}`; + ha2 = Utils.calculateMD5(a2); + + logger.debug('authenticate() | using qop=auth-int [a2:"%s"]', a2); + + // Response = MD5(HA1:nonce:nonceCount:credentialsNonce:qop:HA2). + this._response = Utils.calculateMD5( + `${this._ha1}:${this._nonce}:${this._ncHex}:${this._cnonce}:auth-int:${ha2}` + ); + } else if (this._qop === null) { + // HA2 = MD5(A2) = MD5(method:digestURI). + a2 = `${this._method}:${this._uri}`; + ha2 = Utils.calculateMD5(a2); + + logger.debug('authenticate() | using qop=null [a2:"%s"]', a2); + + // Response = MD5(HA1:nonce:HA2). + this._response = Utils.calculateMD5(`${this._ha1}:${this._nonce}:${ha2}`); + } + + logger.debug('authenticate() | response generated'); + + return true; + } + + /** + * Return the Proxy-Authorization or WWW-Authorization header value. + */ + toString() { + const auth_params = []; + + if (!this._response) { + throw new Error( + 'response field does not exist, cannot generate Authorization header' + ); + } + + auth_params.push(`algorithm=${this._algorithm}`); + auth_params.push(`username="${this._credentials.username}"`); + auth_params.push(`realm="${this._realm}"`); + auth_params.push(`nonce="${this._nonce}"`); + auth_params.push(`uri="${this._uri}"`); + auth_params.push(`response="${this._response}"`); + if (this._opaque) { + auth_params.push(`opaque="${this._opaque}"`); + } + if (this._qop) { + auth_params.push(`qop=${this._qop}`); + auth_params.push(`cnonce="${this._cnonce}"`); + auth_params.push(`nc=${this._ncHex}`); + } + + return `Digest ${auth_params.join(', ')}`; + } }; diff --git a/src/Exceptions.d.ts b/src/Exceptions.d.ts index 03c9fd85..818bf4ac 100644 --- a/src/Exceptions.d.ts +++ b/src/Exceptions.d.ts @@ -1,24 +1,26 @@ declare class BaseError extends Error { - code: number + code: number; } export class ConfigurationError extends BaseError { - parameter: string - value: any + parameter: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: any; - constructor(parameter: string, value?: any); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(parameter: string, value?: any); } export class InvalidStateError extends BaseError { - status: number + status: number; - constructor(status: number); + constructor(status: number); } export class NotSupportedError extends BaseError { - constructor(message: string); + constructor(message: string); } export class NotReadyError extends BaseError { - constructor(message: string); + constructor(message: string); } diff --git a/src/Exceptions.js b/src/Exceptions.js index 70492c4b..00d9ebcf 100644 --- a/src/Exceptions.js +++ b/src/Exceptions.js @@ -1,59 +1,51 @@ -class ConfigurationError extends Error -{ - constructor(parameter, value) - { - super(); - - this.code = 1; - this.name = 'CONFIGURATION_ERROR'; - this.parameter = parameter; - this.value = value; - this.message = (!this.value)? - `Missing parameter: ${this.parameter}` : - `Invalid value ${JSON.stringify(this.value)} for parameter "${this.parameter}"`; - } +class ConfigurationError extends Error { + constructor(parameter, value) { + super(); + + this.code = 1; + this.name = 'CONFIGURATION_ERROR'; + this.parameter = parameter; + this.value = value; + this.message = !this.value + ? `Missing parameter: ${this.parameter}` + : `Invalid value ${JSON.stringify(this.value)} for parameter "${this.parameter}"`; + } } -class InvalidStateError extends Error -{ - constructor(status) - { - super(); - - this.code = 2; - this.name = 'INVALID_STATE_ERROR'; - this.status = status; - this.message = `Invalid status: ${status}`; - } +class InvalidStateError extends Error { + constructor(status) { + super(); + + this.code = 2; + this.name = 'INVALID_STATE_ERROR'; + this.status = status; + this.message = `Invalid status: ${status}`; + } } -class NotSupportedError extends Error -{ - constructor(message) - { - super(); +class NotSupportedError extends Error { + constructor(message) { + super(); - this.code = 3; - this.name = 'NOT_SUPPORTED_ERROR'; - this.message = message; - } + this.code = 3; + this.name = 'NOT_SUPPORTED_ERROR'; + this.message = message; + } } -class NotReadyError extends Error -{ - constructor(message) - { - super(); +class NotReadyError extends Error { + constructor(message) { + super(); - this.code = 4; - this.name = 'NOT_READY_ERROR'; - this.message = message; - } + this.code = 4; + this.name = 'NOT_READY_ERROR'; + this.message = message; + } } module.exports = { - ConfigurationError, - InvalidStateError, - NotSupportedError, - NotReadyError + ConfigurationError, + InvalidStateError, + NotSupportedError, + NotReadyError, }; diff --git a/src/Grammar.d.ts b/src/Grammar.d.ts index 6ac75018..3baa70c1 100644 --- a/src/Grammar.d.ts +++ b/src/Grammar.d.ts @@ -1,5 +1,7 @@ +// eslint-disable-next-line @typescript-eslint/no-explicit-any export type Grammar = any; export function parse(input: string, startRule?: string): Grammar; +// eslint-disable-next-line @typescript-eslint/no-explicit-any export function toSource(): any; diff --git a/src/JsSIP.d.ts b/src/JsSIP.d.ts index 42edaffb..e9a896e2 100644 --- a/src/JsSIP.d.ts +++ b/src/JsSIP.d.ts @@ -1,18 +1,18 @@ -import {Debug} from 'debug' +import { Debug } from 'debug'; -import * as C from './Constants' -import * as Exceptions from './Exceptions' -import * as Grammar from './Grammar' -import * as Utils from './Utils' +import * as C from './Constants'; +import * as Exceptions from './Exceptions'; +import * as Grammar from './Grammar'; +import * as Utils from './Utils'; export { C, Exceptions, Grammar, Utils }; -export {UA} from './UA' -export {URI} from './URI' -export {NameAddrHeader} from './NameAddrHeader' -export {WebSocketInterface} from './WebSocketInterface' -export {Socket, WeightedSocket} from './Socket' +export { UA } from './UA'; +export { URI } from './URI'; +export { NameAddrHeader } from './NameAddrHeader'; +export { WebSocketInterface } from './WebSocketInterface'; +export { Socket, WeightedSocket } from './Socket'; -export const debug: Debug -export const name: string -export const version: string +export const debug: Debug; +export const name: string; +export const version: string; diff --git a/src/JsSIP.js b/src/JsSIP.js index bb9fa249..39481d17 100644 --- a/src/JsSIP.js +++ b/src/JsSIP.js @@ -16,17 +16,21 @@ debug('version %s', pkg.version); * Expose the JsSIP module. */ module.exports = { - C, - Exceptions, - Utils, - UA, - URI, - NameAddrHeader, - WebSocketInterface, - Grammar, - RTCSession, - // Expose the debug module. - debug : require('debug'), - get name() { return pkg.title; }, - get version() { return pkg.version; } + C, + Exceptions, + Utils, + UA, + URI, + NameAddrHeader, + WebSocketInterface, + Grammar, + RTCSession, + // Expose the debug module. + debug: require('debug'), + get name() { + return pkg.title; + }, + get version() { + return pkg.version; + }, }; diff --git a/src/Logger.js b/src/Logger.js index 4feeceb1..4abb8e7c 100644 --- a/src/Logger.js +++ b/src/Logger.js @@ -2,41 +2,33 @@ const debug = require('debug'); const APP_NAME = 'JsSIP'; -module.exports = class Logger -{ - constructor(prefix) - { - if (prefix) - { - this._debug = debug.default(`${APP_NAME}:${prefix}`); - this._warn = debug.default(`${APP_NAME}:WARN:${prefix}`); - this._error = debug.default(`${APP_NAME}:ERROR:${prefix}`); - } - else - { - this._debug = debug.default(APP_NAME); - this._warn = debug.default(`${APP_NAME}:WARN`); - this._error = debug.default(`${APP_NAME}:ERROR`); - } - /* eslint-disable no-console */ - this._debug.log = console.info.bind(console); - this._warn.log = console.warn.bind(console); - this._error.log = console.error.bind(console); - /* eslint-enable no-console */ - } +module.exports = class Logger { + constructor(prefix) { + if (prefix) { + this._debug = debug.default(`${APP_NAME}:${prefix}`); + this._warn = debug.default(`${APP_NAME}:WARN:${prefix}`); + this._error = debug.default(`${APP_NAME}:ERROR:${prefix}`); + } else { + this._debug = debug.default(APP_NAME); + this._warn = debug.default(`${APP_NAME}:WARN`); + this._error = debug.default(`${APP_NAME}:ERROR`); + } + /* eslint-disable no-console */ + this._debug.log = console.info.bind(console); + this._warn.log = console.warn.bind(console); + this._error.log = console.error.bind(console); + /* eslint-enable no-console */ + } - get debug() - { - return this._debug; - } + get debug() { + return this._debug; + } - get warn() - { - return this._warn; - } + get warn() { + return this._warn; + } - get error() - { - return this._error; - } + get error() { + return this._error; + } }; diff --git a/src/Message.d.ts b/src/Message.d.ts index 8ee3685c..e0cc24b2 100644 --- a/src/Message.d.ts +++ b/src/Message.d.ts @@ -1,46 +1,55 @@ -import {EventEmitter} from 'events' - -import {ExtraHeaders, Originator, OutgoingListener, SessionDirection, TerminateOptions} from './RTCSession' -import {IncomingResponse} from './SIPMessage' -import {NameAddrHeader} from './NameAddrHeader' -import {causes} from './Constants'; +import { EventEmitter } from 'events'; + +import { + ExtraHeaders, + Originator, + OutgoingListener, + SessionDirection, + TerminateOptions, +} from './RTCSession'; +import { IncomingResponse } from './SIPMessage'; +import { NameAddrHeader } from './NameAddrHeader'; +import { causes } from './Constants'; export interface AcceptOptions extends ExtraHeaders { - body?: string; + body?: string; } export interface MessageFailedEvent { - originator: Originator; - response: IncomingResponse; - cause?: causes; + originator: Originator; + response: IncomingResponse; + cause?: causes; } export type MessageFailedListener = (event: MessageFailedEvent) => void; export interface MessageEventMap { - succeeded: OutgoingListener; - failed: MessageFailedListener; + succeeded: OutgoingListener; + failed: MessageFailedListener; } export interface SendMessageOptions extends ExtraHeaders { - contentType?: string; - eventHandlers?: Partial; - fromUserName?: string; - fromDisplayName?: string; + contentType?: string; + eventHandlers?: Partial; + fromUserName?: string; + fromDisplayName?: string; } export class Message extends EventEmitter { - get direction(): SessionDirection; + get direction(): SessionDirection; - get local_identity(): NameAddrHeader; + get local_identity(): NameAddrHeader; - get remote_identity(): NameAddrHeader; + get remote_identity(): NameAddrHeader; - send(target: string, body: string, options?: SendMessageOptions): void; + send(target: string, body: string, options?: SendMessageOptions): void; - accept(options: AcceptOptions): void; + accept(options: AcceptOptions): void; - reject(options: TerminateOptions): void; + reject(options: TerminateOptions): void; - on(type: T, listener: MessageEventMap[T]): this; + on( + type: T, + listener: MessageEventMap[T] + ): this; } diff --git a/src/Message.js b/src/Message.js index daadbc5c..801f8233 100644 --- a/src/Message.js +++ b/src/Message.js @@ -9,287 +9,264 @@ const URI = require('./URI'); const logger = new Logger('Message'); -module.exports = class Message extends EventEmitter -{ - constructor(ua) - { - super(); - - this._ua = ua; - this._request = null; - this._closed = false; - - this._direction = null; - this._local_identity = null; - this._remote_identity = null; - - // Whether an incoming message has been replied. - this._is_replied = false; - - // Custom message empty object for high level use. - this._data = {}; - } - - get direction() - { - return this._direction; - } - - get local_identity() - { - return this._local_identity; - } - - get remote_identity() - { - return this._remote_identity; - } - - send(target, body, options = {}) - { - const originalTarget = target; - - if (target === undefined || body === undefined) - { - throw new TypeError('Not enough arguments'); - } - - // Check target validity. - target = this._ua.normalizeTarget(target); - if (!target) - { - throw new TypeError(`Invalid target: ${originalTarget}`); - } - - // Get call options. - const extraHeaders = Utils.cloneArray(options.extraHeaders); - const eventHandlers = Utils.cloneObject(options.eventHandlers); - const contentType = options.contentType || 'text/plain'; - - const requestParams = {}; - - if (options.fromUserName) - { - requestParams.from_uri = new URI('sip', options.fromUserName, this._ua.configuration.uri.host); - - extraHeaders.push(`P-Preferred-Identity: ${this._ua.configuration.uri.toString()}`); - } - - if (options.fromDisplayName) - { - requestParams.from_display_name = options.fromDisplayName; - } - - // Set event handlers. - for (const event in eventHandlers) - { - if (Object.prototype.hasOwnProperty.call(eventHandlers, event)) - { - this.on(event, eventHandlers[event]); - } - } - - extraHeaders.push(`Content-Type: ${contentType}`); - - this._request = new SIPMessage.OutgoingRequest( - JsSIP_C.MESSAGE, target, this._ua, requestParams, extraHeaders); - - if (body) - { - this._request.body = body; - } - - const request_sender = new RequestSender(this._ua, this._request, { - onRequestTimeout : () => - { - this._onRequestTimeout(); - }, - onTransportError : () => - { - this._onTransportError(); - }, - onReceiveResponse : (response) => - { - this._receiveResponse(response); - } - }); - - this._newMessage('local', this._request); - - request_sender.send(); - } - - init_incoming(request) - { - this._request = request; - - this._newMessage('remote', request); - - // Reply with a 200 OK if the user didn't reply. - if (!this._is_replied) - { - this._is_replied = true; - request.reply(200); - } - - this._close(); - } - - /** - * Accept the incoming Message - * Only valid for incoming Messages - */ - accept(options = {}) - { - const extraHeaders = Utils.cloneArray(options.extraHeaders); - const body = options.body; - - if (this._direction !== 'incoming') - { - throw new Exceptions.NotSupportedError('"accept" not supported for outgoing Message'); - } - - if (this._is_replied) - { - throw new Error('incoming Message already replied'); - } - - this._is_replied = true; - this._request.reply(200, null, extraHeaders, body); - } - - /** - * Reject the incoming Message - * Only valid for incoming Messages - */ - reject(options = {}) - { - const status_code = options.status_code || 480; - const reason_phrase = options.reason_phrase; - const extraHeaders = Utils.cloneArray(options.extraHeaders); - const body = options.body; - - if (this._direction !== 'incoming') - { - throw new Exceptions.NotSupportedError('"reject" not supported for outgoing Message'); - } - - if (this._is_replied) - { - throw new Error('incoming Message already replied'); - } - - if (status_code < 300 || status_code >= 700) - { - throw new TypeError(`Invalid status_code: ${status_code}`); - } - - this._is_replied = true; - this._request.reply(status_code, reason_phrase, extraHeaders, body); - } - - _receiveResponse(response) - { - if (this._closed) - { - return; - } - switch (true) - { - case /^1[0-9]{2}$/.test(response.status_code): - // Ignore provisional responses. - break; - - case /^2[0-9]{2}$/.test(response.status_code): - this._succeeded('remote', response); - break; - - default: - { - const cause = Utils.sipErrorCause(response.status_code); - - this._failed('remote', response, cause); - break; - } - } - } - - _onRequestTimeout() - { - if (this._closed) - { - return; - } - this._failed('system', null, JsSIP_C.causes.REQUEST_TIMEOUT); - } - - _onTransportError() - { - if (this._closed) - { - return; - } - this._failed('system', null, JsSIP_C.causes.CONNECTION_ERROR); - } - - _close() - { - this._closed = true; - this._ua.destroyMessage(this); - } - - /** - * Internal Callbacks - */ - - _newMessage(originator, request) - { - if (originator === 'remote') - { - this._direction = 'incoming'; - this._local_identity = request.to; - this._remote_identity = request.from; - } - else if (originator === 'local') - { - this._direction = 'outgoing'; - this._local_identity = request.from; - this._remote_identity = request.to; - } - - this._ua.newMessage(this, { - originator, - message : this, - request - }); - } - - _failed(originator, response, cause) - { - logger.debug('MESSAGE failed'); - - this._close(); - - logger.debug('emit "failed"'); - - this.emit('failed', { - originator, - response : response || null, - cause - }); - } - - _succeeded(originator, response) - { - logger.debug('MESSAGE succeeded'); - - this._close(); - - logger.debug('emit "succeeded"'); - - this.emit('succeeded', { - originator, - response - }); - } +module.exports = class Message extends EventEmitter { + constructor(ua) { + super(); + + this._ua = ua; + this._request = null; + this._closed = false; + + this._direction = null; + this._local_identity = null; + this._remote_identity = null; + + // Whether an incoming message has been replied. + this._is_replied = false; + + // Custom message empty object for high level use. + this._data = {}; + } + + get direction() { + return this._direction; + } + + get local_identity() { + return this._local_identity; + } + + get remote_identity() { + return this._remote_identity; + } + + send(target, body, options = {}) { + const originalTarget = target; + + if (target === undefined || body === undefined) { + throw new TypeError('Not enough arguments'); + } + + // Check target validity. + target = this._ua.normalizeTarget(target); + if (!target) { + throw new TypeError(`Invalid target: ${originalTarget}`); + } + + // Get call options. + const extraHeaders = Utils.cloneArray(options.extraHeaders); + const eventHandlers = Utils.cloneObject(options.eventHandlers); + const contentType = options.contentType || 'text/plain'; + + const requestParams = {}; + + if (options.fromUserName) { + requestParams.from_uri = new URI( + 'sip', + options.fromUserName, + this._ua.configuration.uri.host + ); + + extraHeaders.push( + `P-Preferred-Identity: ${this._ua.configuration.uri.toString()}` + ); + } + + if (options.fromDisplayName) { + requestParams.from_display_name = options.fromDisplayName; + } + + // Set event handlers. + for (const event in eventHandlers) { + if (Object.prototype.hasOwnProperty.call(eventHandlers, event)) { + this.on(event, eventHandlers[event]); + } + } + + extraHeaders.push(`Content-Type: ${contentType}`); + + this._request = new SIPMessage.OutgoingRequest( + JsSIP_C.MESSAGE, + target, + this._ua, + requestParams, + extraHeaders + ); + + if (body) { + this._request.body = body; + } + + const request_sender = new RequestSender(this._ua, this._request, { + onRequestTimeout: () => { + this._onRequestTimeout(); + }, + onTransportError: () => { + this._onTransportError(); + }, + onReceiveResponse: response => { + this._receiveResponse(response); + }, + }); + + this._newMessage('local', this._request); + + request_sender.send(); + } + + init_incoming(request) { + this._request = request; + + this._newMessage('remote', request); + + // Reply with a 200 OK if the user didn't reply. + if (!this._is_replied) { + this._is_replied = true; + request.reply(200); + } + + this._close(); + } + + /** + * Accept the incoming Message + * Only valid for incoming Messages + */ + accept(options = {}) { + const extraHeaders = Utils.cloneArray(options.extraHeaders); + const body = options.body; + + if (this._direction !== 'incoming') { + throw new Exceptions.NotSupportedError( + '"accept" not supported for outgoing Message' + ); + } + + if (this._is_replied) { + throw new Error('incoming Message already replied'); + } + + this._is_replied = true; + this._request.reply(200, null, extraHeaders, body); + } + + /** + * Reject the incoming Message + * Only valid for incoming Messages + */ + reject(options = {}) { + const status_code = options.status_code || 480; + const reason_phrase = options.reason_phrase; + const extraHeaders = Utils.cloneArray(options.extraHeaders); + const body = options.body; + + if (this._direction !== 'incoming') { + throw new Exceptions.NotSupportedError( + '"reject" not supported for outgoing Message' + ); + } + + if (this._is_replied) { + throw new Error('incoming Message already replied'); + } + + if (status_code < 300 || status_code >= 700) { + throw new TypeError(`Invalid status_code: ${status_code}`); + } + + this._is_replied = true; + this._request.reply(status_code, reason_phrase, extraHeaders, body); + } + + _receiveResponse(response) { + if (this._closed) { + return; + } + switch (true) { + case /^1[0-9]{2}$/.test(response.status_code): { + // Ignore provisional responses. + break; + } + + case /^2[0-9]{2}$/.test(response.status_code): { + this._succeeded('remote', response); + break; + } + + default: { + const cause = Utils.sipErrorCause(response.status_code); + + this._failed('remote', response, cause); + break; + } + } + } + + _onRequestTimeout() { + if (this._closed) { + return; + } + this._failed('system', null, JsSIP_C.causes.REQUEST_TIMEOUT); + } + + _onTransportError() { + if (this._closed) { + return; + } + this._failed('system', null, JsSIP_C.causes.CONNECTION_ERROR); + } + + _close() { + this._closed = true; + this._ua.destroyMessage(this); + } + + /** + * Internal Callbacks + */ + + _newMessage(originator, request) { + if (originator === 'remote') { + this._direction = 'incoming'; + this._local_identity = request.to; + this._remote_identity = request.from; + } else if (originator === 'local') { + this._direction = 'outgoing'; + this._local_identity = request.from; + this._remote_identity = request.to; + } + + this._ua.newMessage(this, { + originator, + message: this, + request, + }); + } + + _failed(originator, response, cause) { + logger.debug('MESSAGE failed'); + + this._close(); + + logger.debug('emit "failed"'); + + this.emit('failed', { + originator, + response: response || null, + cause, + }); + } + + _succeeded(originator, response) { + logger.debug('MESSAGE succeeded'); + + this._close(); + + logger.debug('emit "succeeded"'); + + this.emit('succeeded', { + originator, + response, + }); + } }; diff --git a/src/NameAddrHeader.d.ts b/src/NameAddrHeader.d.ts index 6529c100..59e15cb0 100644 --- a/src/NameAddrHeader.d.ts +++ b/src/NameAddrHeader.d.ts @@ -1,27 +1,28 @@ -import {Parameters, URI} from './URI' -import {Grammar} from './Grammar' +import { Parameters, URI } from './URI'; +import { Grammar } from './Grammar'; export class NameAddrHeader { - get display_name(): string; - set display_name(value: string); + get display_name(): string; + set display_name(value: string); - get uri(): URI; + get uri(): URI; - constructor(uri: URI, display_name?: string, parameters?: Parameters); + constructor(uri: URI, display_name?: string, parameters?: Parameters); - setParam(key: string, value?: string): void; + setParam(key: string, value?: string): void; - getParam(key: string): T; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getParam(key: string): T; - hasParam(key: string): boolean; + hasParam(key: string): boolean; - deleteParam(key: string): void; + deleteParam(key: string): void; - clearParams(): void; + clearParams(): void; - clone(): this; + clone(): this; - toString(): string; + toString(): string; - static parse(uri: string): Grammar | undefined; + static parse(uri: string): Grammar | undefined; } diff --git a/src/NameAddrHeader.js b/src/NameAddrHeader.js index dd4e4540..81193316 100644 --- a/src/NameAddrHeader.js +++ b/src/NameAddrHeader.js @@ -1,139 +1,118 @@ const URI = require('./URI'); const Grammar = require('./Grammar'); -module.exports = class NameAddrHeader -{ - /** - * Parse the given string and returns a NameAddrHeader instance or undefined if - * it is an invalid NameAddrHeader. - */ - static parse(name_addr_header) - { - name_addr_header = Grammar.parse(name_addr_header, 'Name_Addr_Header'); - - if (name_addr_header !== -1) - { - return name_addr_header; - } - else - { - return undefined; - } - } - - constructor(uri, display_name, parameters) - { - // Checks. - if (!uri || !(uri instanceof URI)) - { - throw new TypeError('missing or invalid "uri" parameter'); - } - - // Initialize parameters. - this._uri = uri; - this._parameters = {}; - this.display_name = display_name; - - for (const param in parameters) - { - if (Object.prototype.hasOwnProperty.call(parameters, param)) - { - this.setParam(param, parameters[param]); - } - } - } - - get uri() - { - return this._uri; - } - - get display_name() - { - return this._display_name; - } - - set display_name(value) - { - this._display_name = (value === 0) ? '0' : value; - } - - setParam(key, value) - { - if (key) - { - this._parameters[key.toLowerCase()] = (typeof value === 'undefined' || value === null) ? null : value.toString(); - } - } - - getParam(key) - { - if (key) - { - return this._parameters[key.toLowerCase()]; - } - } - - hasParam(key) - { - if (key) - { - return (this._parameters.hasOwnProperty(key.toLowerCase()) && true) || false; - } - } - - deleteParam(parameter) - { - parameter = parameter.toLowerCase(); - if (this._parameters.hasOwnProperty(parameter)) - { - const value = this._parameters[parameter]; - - delete this._parameters[parameter]; - - return value; - } - } - - clearParams() - { - this._parameters = {}; - } - - clone() - { - return new NameAddrHeader( - this._uri.clone(), - this._display_name, - JSON.parse(JSON.stringify(this._parameters))); - } - - _quote(str) - { - return str - .replace(/\\/g, '\\\\') - .replace(/"/g, '\\"'); - } - - toString() - { - let body = this._display_name ? `"${this._quote(this._display_name)}" ` : ''; - - body += `<${this._uri.toString()}>`; - - for (const parameter in this._parameters) - { - if (Object.prototype.hasOwnProperty.call(this._parameters, parameter)) - { - body += `;${parameter}`; - - if (this._parameters[parameter] !== null) - { - body += `=${this._parameters[parameter]}`; - } - } - } - - return body; - } +module.exports = class NameAddrHeader { + /** + * Parse the given string and returns a NameAddrHeader instance or undefined if + * it is an invalid NameAddrHeader. + */ + static parse(name_addr_header) { + name_addr_header = Grammar.parse(name_addr_header, 'Name_Addr_Header'); + + if (name_addr_header !== -1) { + return name_addr_header; + } else { + return undefined; + } + } + + constructor(uri, display_name, parameters) { + // Checks. + if (!uri || !(uri instanceof URI)) { + throw new TypeError('missing or invalid "uri" parameter'); + } + + // Initialize parameters. + this._uri = uri; + this._parameters = {}; + this.display_name = display_name; + + for (const param in parameters) { + if (Object.prototype.hasOwnProperty.call(parameters, param)) { + this.setParam(param, parameters[param]); + } + } + } + + get uri() { + return this._uri; + } + + get display_name() { + return this._display_name; + } + + set display_name(value) { + this._display_name = value === 0 ? '0' : value; + } + + setParam(key, value) { + if (key) { + this._parameters[key.toLowerCase()] = + typeof value === 'undefined' || value === null + ? null + : value.toString(); + } + } + + getParam(key) { + if (key) { + return this._parameters[key.toLowerCase()]; + } + } + + hasParam(key) { + if (key) { + return ( + (this._parameters.hasOwnProperty(key.toLowerCase()) && true) || false + ); + } + } + + deleteParam(parameter) { + parameter = parameter.toLowerCase(); + if (this._parameters.hasOwnProperty(parameter)) { + const value = this._parameters[parameter]; + + delete this._parameters[parameter]; + + return value; + } + } + + clearParams() { + this._parameters = {}; + } + + clone() { + return new NameAddrHeader( + this._uri.clone(), + this._display_name, + JSON.parse(JSON.stringify(this._parameters)) + ); + } + + _quote(str) { + return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + } + + toString() { + let body = this._display_name + ? `"${this._quote(this._display_name)}" ` + : ''; + + body += `<${this._uri.toString()}>`; + + for (const parameter in this._parameters) { + if (Object.prototype.hasOwnProperty.call(this._parameters, parameter)) { + body += `;${parameter}`; + + if (this._parameters[parameter] !== null) { + body += `=${this._parameters[parameter]}`; + } + } + } + + return body; + } }; diff --git a/src/Notifier.d.ts b/src/Notifier.d.ts index aa863eaf..85d2bbb0 100644 --- a/src/Notifier.d.ts +++ b/src/Notifier.d.ts @@ -1,40 +1,52 @@ -import {EventEmitter} from 'events' -import {IncomingRequest} from './SIPMessage' -import {UA} from './UA' +import { EventEmitter } from 'events'; +import { IncomingRequest } from './SIPMessage'; +import { UA } from './UA'; declare enum NotifierTerminationReason { - NOTIFY_RESPONSE_TIMEOUT = 0, - NOTIFY_TRANSPORT_ERROR = 1, - NOTIFY_NON_OK_RESPONSE = 2, - NOTIFY_AUTHENTICATION_FAILED = 3, - FINAL_NOTIFY_SENT = 4, - UNSUBSCRIBE_RECEIVED = 5, - SUBSCRIPTION_EXPIRED = 6 + NOTIFY_RESPONSE_TIMEOUT = 0, + NOTIFY_TRANSPORT_ERROR = 1, + NOTIFY_NON_OK_RESPONSE = 2, + NOTIFY_AUTHENTICATION_FAILED = 3, + FINAL_NOTIFY_SENT = 4, + UNSUBSCRIBE_RECEIVED = 5, + SUBSCRIPTION_EXPIRED = 6, } export interface MessageEventMap { - terminated: [terminationCode: NotifierTerminationReason]; - subscribe: [isUnsubscribe: boolean, request: IncomingRequest, body: string | undefined, contentType: string | undefined]; - expired: []; + terminated: [terminationCode: NotifierTerminationReason]; + subscribe: [ + isUnsubscribe: boolean, + request: IncomingRequest, + body: string | undefined, + contentType: string | undefined, + ]; + expired: []; } interface NotifierOptions { - extraHeaders?: Array; - allowEvents?: string; - pending?: boolean; - defaultExpires?: number; + extraHeaders?: string[]; + allowEvents?: string; + pending?: boolean; + defaultExpires?: number; } export class Notifier extends EventEmitter { - constructor(ua: UA, subscribe: IncomingRequest, contentType: string, options: NotifierOptions) - start(): void; - setActiveState(): void; - notify(body?: string): void; - terminate(body?: string, reason?: string, retryAfter?: number): void; - get state(): string; - get id(): string; - set data(_data: any); - get data(): any; - static get C(): typeof NotifierTerminationReason; - get C(): typeof NotifierTerminationReason; + constructor( + ua: UA, + subscribe: IncomingRequest, + contentType: string, + options: NotifierOptions + ); + start(): void; + setActiveState(): void; + notify(body?: string): void; + terminate(body?: string, reason?: string, retryAfter?: number): void; + get state(): string; + get id(): string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + set data(_data: any); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + get data(): any; + static get C(): typeof NotifierTerminationReason; + get C(): typeof NotifierTerminationReason; } diff --git a/src/Notifier.js b/src/Notifier.js index 529db4a7..deea8bbf 100644 --- a/src/Notifier.js +++ b/src/Notifier.js @@ -11,459 +11,420 @@ const logger = new Logger('Notifier'); * Termination codes. */ const C = { - // Termination codes. - NOTIFY_RESPONSE_TIMEOUT : 0, - NOTIFY_TRANSPORT_ERROR : 1, - NOTIFY_NON_OK_RESPONSE : 2, - NOTIFY_AUTHENTICATION_FAILED : 3, - FINAL_NOTIFY_SENT : 4, - UNSUBSCRIBE_RECEIVED : 5, - SUBSCRIPTION_EXPIRED : 6, - - // Notifer states. - STATE_PENDING : 0, - STATE_ACTIVE : 1, - STATE_TERMINATED : 2, - - // RFC 6665 3.1.1, default expires value. - DEFAULT_EXPIRES_SEC : 900 + // Termination codes. + NOTIFY_RESPONSE_TIMEOUT: 0, + NOTIFY_TRANSPORT_ERROR: 1, + NOTIFY_NON_OK_RESPONSE: 2, + NOTIFY_AUTHENTICATION_FAILED: 3, + FINAL_NOTIFY_SENT: 4, + UNSUBSCRIBE_RECEIVED: 5, + SUBSCRIPTION_EXPIRED: 6, + + // Notifer states. + STATE_PENDING: 0, + STATE_ACTIVE: 1, + STATE_TERMINATED: 2, + + // RFC 6665 3.1.1, default expires value. + DEFAULT_EXPIRES_SEC: 900, }; /** * RFC 6665 Notifier implementation. */ -module.exports = class Notifier extends EventEmitter -{ - /** - * Expose C object. - */ - static get C() - { - return C; - } - - static init_incoming(request, callback) - { - try - { - Notifier.checkSubscribe(request); - } - catch (error) - { - logger.warn('Notifier.init_incoming: invalid request. Error: ', error.message); - - request.reply(405); - - return; - } - - callback(); - } - - static checkSubscribe(subscribe) - { - if (!subscribe) - { - throw new TypeError('Not enough arguments. Missing subscribe request'); - } - if (subscribe.method !== JsSIP_C.SUBSCRIBE) - { - throw new TypeError('Invalid method for Subscribe request'); - } - if (!subscribe.hasHeader('contact')) - { - throw new TypeError('Missing Contact header in subscribe request'); - } - if (!subscribe.hasHeader('event')) - { - throw new TypeError('Missing Event header in subscribe request'); - } - const expires = subscribe.getHeader('expires'); - - if (expires) - { - const parsed_expires = parseInt(expires); - - if (!Utils.isDecimal(parsed_expires) || parsed_expires < 0) - { - throw new TypeError('Invalid Expires header field in subscribe request'); - } - } - } - - /** - * @param {UA} ua - JsSIP User Agent instance. - * @param {IncomingRequest} subscribe - Subscribe request. - * @param {string} contentType - Content-Type header value. - * @param {NotifierOptions} options - Optional parameters. - * @param {Array} extraHeaders - Additional SIP headers. - * @param {string} allowEvents - Allow-Events header value. - * @param {boolean} pending - Set initial dialog state as "pending". - * @param {number} defaultExpires - Default expires value (seconds). - */ - constructor(ua, subscribe, contentType, { - extraHeaders, allowEvents, pending, defaultExpires - }) - { - logger.debug('new'); - - super(); - - if (!contentType) - { - throw new TypeError('Not enough arguments. Missing contentType'); - } - - Notifier.checkSubscribe(subscribe); - - const eventName = subscribe.getHeader('event'); - - this._ua = ua; - this._initial_subscribe = subscribe; - this._expires_timestamp = null; - this._expires_timer = null; - this._defaultExpires = defaultExpires || C.DEFAULT_EXPIRES_SEC; - - // Notifier state: pending, active, terminated. - this._state = pending ? C.STATE_PENDING : C.STATE_ACTIVE; - - this._content_type = contentType; - this._headers = Utils.cloneArray(extraHeaders); - this._headers.push(`Event: ${eventName}`); - - // Use contact from extraHeaders or create it. - this._contact = this._headers.find((header) => header.startsWith('Contact')); - if (!this._contact) - { - this._contact = `Contact: ${this._ua._contact.toString()}`; - - this._headers.push(this._contact); - } - - if (allowEvents) - { - this._headers.push(`Allow-Events: ${allowEvents}`); - } - - this._target = subscribe.from.uri.user; - subscribe.to_tag = Utils.newTag(); - - // Custom session empty object for high level use. - this._data = {}; - } - - // Expose Notifier constants as a property of the Notifier instance. - get C() - { - return C; - } - - /** - * Get dialog state. - */ - get state() - { - return this._state; - } - - /** - * Get dialog id. - */ - get id() - { - return this._dialog ? this._dialog.id : null; - } - - get data() - { - return this._data; - } - - set data(_data) - { - this._data = _data; - } - - /** - * Dialog callback. - * Called also for initial subscribe. - * Supported RFC 6665 4.4.3: initial fetch subscribe (with expires: 0). - */ - receiveRequest(request) - { - if (request.method !== JsSIP_C.SUBSCRIBE) - { - request.reply(405); - - return; - } - - this._setExpires(request); - - // Create dialog for normal and fetch-subscribe. - if (!this._dialog) - { - this._dialog = new Dialog(this, request, 'UAS'); - } - - request.reply(200, null, [ `Expires: ${this._expires}`, `${this._contact}` ]); - - const body = request.body; - const content_type = request.getHeader('content-type'); - const is_unsubscribe = this._expires === 0; - - if (!is_unsubscribe) - { - this._setExpiresTimer(); - } - - logger.debug('emit "subscribe"'); - - this.emit('subscribe', is_unsubscribe, request, body, content_type); - - if (is_unsubscribe) - { - this._terminateDialog(C.UNSUBSCRIBE_RECEIVED); - } - } - - /** - * User API - */ - - /** - * Call this method after creating the Notifier instance and setting the event handlers. - */ - start() - { - logger.debug('start()'); - - if (this._state === C.STATE_TERMINATED) - { - throw new Exceptions.InvalidStateError(this._state); - } - - this.receiveRequest(this._initial_subscribe); - } - - /** - * Switch pending dialog state to active. - */ - setActiveState() - { - logger.debug('setActiveState()'); - - if (this._state === C.STATE_TERMINATED) - { - throw new Exceptions.InvalidStateError(this._state); - } - - if (this._state === C.STATE_PENDING) - { - this._state = C.STATE_ACTIVE; - } - } - - /** - * Send the initial and subsequent notify request. - * @param {string} body - notify request body. - */ - notify(body=null) - { - logger.debug('notify()'); - - if (this._state === C.STATE_TERMINATED) - { - throw new Exceptions.InvalidStateError(this._state); - } - - const expires = Math.floor((this._expires_timestamp - new Date().getTime()) / 1000); - - // expires_timer is about to trigger. Clean up the timer and terminate. - if (expires <= 0) - { - if (!this._expires_timer) - { - logger.error('expires timer is not set'); - } - - clearTimeout(this._expires_timer); - - this.terminate(body, 'timeout'); - } - else - { - this._sendNotify([ `;expires=${expires}` ], body); - } - } - - /** - * Terminate. (Send the final NOTIFY request). - * - * @param {string} body - Notify message body. - * @param {string} reason - Set Subscription-State reason parameter. - * @param {number} retryAfter - Set Subscription-State retry-after parameter. - */ - terminate(body = null, reason = null, retryAfter = null) - { - logger.debug('terminate()'); - - if (this._state === C.STATE_TERMINATED) - { - return; - } - - const subsStateParameters = []; - - if (reason) - { - subsStateParameters.push(`;reason=${reason}`); - } - - if (retryAfter !== null) - { - subsStateParameters.push(`;retry-after=${retryAfter}`); - } - - this._sendNotify(subsStateParameters, body, null, 'terminated'); - - this._terminateDialog(reason === 'timeout' ? C.SUBSCRIPTION_EXPIRED : C.FINAL_NOTIFY_SENT); - } - - /** - * Private API - */ - - _terminateDialog(termination_code) - { - if (this._state === C.STATE_TERMINATED) - { - return; - } - this._state = C.STATE_TERMINATED; - - clearTimeout(this._expires_timer); - if (this._dialog) - { - this._dialog.terminate(); - this._dialog = null; - } - logger.debug(`emit "terminated" code=${termination_code}`); - - this.emit('terminated', termination_code); - } - - _setExpires(request) - { - if (request.hasHeader('expires')) - { - this._expires = parseInt(request.getHeader('expires')); - } - else - { - this._expires = this._defaultExpires; - logger.debug(`missing Expires header field, default value set: ${this._expires}`); - } - } - - /** - * @param {Array} subsStateParams subscription state parameters. - * @param {String} body Notify body - * @param {Array} extraHeaders - */ - _sendNotify(subsStateParameters, body=null, extraHeaders=null, state=null) - { - // Prevent send notify after final notify. - if (this._state === C.STATE_TERMINATED) - { - logger.warn('final notify already sent'); - - return; - } - - // Build Subscription-State header with parameters. - let subsState = `Subscription-State: ${state || this._parseState()}`; - - for (const param of subsStateParameters) - { - subsState += param; - } - - let headers = Utils.cloneArray(this._headers); - - headers.push(subsState); - - if (extraHeaders) - { - headers = headers.concat(extraHeaders); - } - - if (body) - { - headers.push(`Content-Type: ${this._content_type}`); - } - - this._dialog.sendRequest(JsSIP_C.NOTIFY, { - body, - extraHeaders : headers, - eventHandlers : { - onRequestTimeout : () => - { - this._terminateDialog(C.NOTIFY_RESPONSE_TIMEOUT); - }, - onTransportError : () => - { - this._terminateDialog(C.NOTIFY_TRANSPORT_ERROR); - }, - onErrorResponse : (response) => - { - if (response.status_code === 401 || response.status_code === 407) - { - this._terminateDialog(C.NOTIFY_AUTHENTICATION_FAILED); - } - else - { - this._terminateDialog(C.NOTIFY_NON_OK_RESPONSE); - } - }, - onDialogError : () => - { - this._terminateDialog(C.NOTIFY_NON_OK_RESPONSE); - } - } - }); - } - - _setExpiresTimer() - { - this._expires_timestamp = new Date().getTime() + (this._expires * 1000); - - clearTimeout(this._expires_timer); - this._expires_timer = setTimeout(() => - { - if (this._state === C.STATE_TERMINATED) - { - return; - } - - logger.debug('emit "expired"'); - - // Client can hook into the 'expired' event and call terminate to send a custom notify. - this.emit('expired'); - - // This will be no-op if the client already called `terminate()`. - this.terminate(null, 'timeout'); - }, this._expires * 1000); - } - - _parseState() - { - switch (this._state) - { - case C.STATE_PENDING: return 'pending'; - case C.STATE_ACTIVE: return 'active'; - case C.STATE_TERMINATED: return 'terminated'; - default: throw new TypeError('wrong state value'); - } - } +module.exports = class Notifier extends EventEmitter { + /** + * Expose C object. + */ + static get C() { + return C; + } + + static init_incoming(request, callback) { + try { + Notifier.checkSubscribe(request); + } catch (error) { + logger.warn( + 'Notifier.init_incoming: invalid request. Error: ', + error.message + ); + + request.reply(405); + + return; + } + + callback(); + } + + static checkSubscribe(subscribe) { + if (!subscribe) { + throw new TypeError('Not enough arguments. Missing subscribe request'); + } + if (subscribe.method !== JsSIP_C.SUBSCRIBE) { + throw new TypeError('Invalid method for Subscribe request'); + } + if (!subscribe.hasHeader('contact')) { + throw new TypeError('Missing Contact header in subscribe request'); + } + if (!subscribe.hasHeader('event')) { + throw new TypeError('Missing Event header in subscribe request'); + } + const expires = subscribe.getHeader('expires'); + + if (expires) { + const parsed_expires = parseInt(expires); + + if (!Utils.isDecimal(parsed_expires) || parsed_expires < 0) { + throw new TypeError( + 'Invalid Expires header field in subscribe request' + ); + } + } + } + + /** + * @param {UA} ua - JsSIP User Agent instance. + * @param {IncomingRequest} subscribe - Subscribe request. + * @param {string} contentType - Content-Type header value. + * @param {NotifierOptions} options - Optional parameters. + * @param {Array} extraHeaders - Additional SIP headers. + * @param {string} allowEvents - Allow-Events header value. + * @param {boolean} pending - Set initial dialog state as "pending". + * @param {number} defaultExpires - Default expires value (seconds). + */ + constructor( + ua, + subscribe, + contentType, + { extraHeaders, allowEvents, pending, defaultExpires } + ) { + logger.debug('new'); + + super(); + + if (!contentType) { + throw new TypeError('Not enough arguments. Missing contentType'); + } + + Notifier.checkSubscribe(subscribe); + + const eventName = subscribe.getHeader('event'); + + this._ua = ua; + this._initial_subscribe = subscribe; + this._expires_timestamp = null; + this._expires_timer = null; + this._defaultExpires = defaultExpires || C.DEFAULT_EXPIRES_SEC; + + // Notifier state: pending, active, terminated. + this._state = pending ? C.STATE_PENDING : C.STATE_ACTIVE; + + this._content_type = contentType; + this._headers = Utils.cloneArray(extraHeaders); + this._headers.push(`Event: ${eventName}`); + + // Use contact from extraHeaders or create it. + this._contact = this._headers.find(header => header.startsWith('Contact')); + if (!this._contact) { + this._contact = `Contact: ${this._ua._contact.toString()}`; + + this._headers.push(this._contact); + } + + if (allowEvents) { + this._headers.push(`Allow-Events: ${allowEvents}`); + } + + this._target = subscribe.from.uri.user; + subscribe.to_tag = Utils.newTag(); + + // Custom session empty object for high level use. + this._data = {}; + } + + // Expose Notifier constants as a property of the Notifier instance. + get C() { + return C; + } + + /** + * Get dialog state. + */ + get state() { + return this._state; + } + + /** + * Get dialog id. + */ + get id() { + return this._dialog ? this._dialog.id : null; + } + + get data() { + return this._data; + } + + set data(_data) { + this._data = _data; + } + + /** + * Dialog callback. + * Called also for initial subscribe. + * Supported RFC 6665 4.4.3: initial fetch subscribe (with expires: 0). + */ + receiveRequest(request) { + if (request.method !== JsSIP_C.SUBSCRIBE) { + request.reply(405); + + return; + } + + this._setExpires(request); + + // Create dialog for normal and fetch-subscribe. + if (!this._dialog) { + this._dialog = new Dialog(this, request, 'UAS'); + } + + request.reply(200, null, [`Expires: ${this._expires}`, `${this._contact}`]); + + const body = request.body; + const content_type = request.getHeader('content-type'); + const is_unsubscribe = this._expires === 0; + + if (!is_unsubscribe) { + this._setExpiresTimer(); + } + + logger.debug('emit "subscribe"'); + + this.emit('subscribe', is_unsubscribe, request, body, content_type); + + if (is_unsubscribe) { + this._terminateDialog(C.UNSUBSCRIBE_RECEIVED); + } + } + + /** + * User API + */ + + /** + * Call this method after creating the Notifier instance and setting the event handlers. + */ + start() { + logger.debug('start()'); + + if (this._state === C.STATE_TERMINATED) { + throw new Exceptions.InvalidStateError(this._state); + } + + this.receiveRequest(this._initial_subscribe); + } + + /** + * Switch pending dialog state to active. + */ + setActiveState() { + logger.debug('setActiveState()'); + + if (this._state === C.STATE_TERMINATED) { + throw new Exceptions.InvalidStateError(this._state); + } + + if (this._state === C.STATE_PENDING) { + this._state = C.STATE_ACTIVE; + } + } + + /** + * Send the initial and subsequent notify request. + * @param {string} body - notify request body. + */ + notify(body = null) { + logger.debug('notify()'); + + if (this._state === C.STATE_TERMINATED) { + throw new Exceptions.InvalidStateError(this._state); + } + + const expires = Math.floor( + (this._expires_timestamp - new Date().getTime()) / 1000 + ); + + // expires_timer is about to trigger. Clean up the timer and terminate. + if (expires <= 0) { + if (!this._expires_timer) { + logger.error('expires timer is not set'); + } + + clearTimeout(this._expires_timer); + + this.terminate(body, 'timeout'); + } else { + this._sendNotify([`;expires=${expires}`], body); + } + } + + /** + * Terminate. (Send the final NOTIFY request). + * + * @param {string} body - Notify message body. + * @param {string} reason - Set Subscription-State reason parameter. + * @param {number} retryAfter - Set Subscription-State retry-after parameter. + */ + terminate(body = null, reason = null, retryAfter = null) { + logger.debug('terminate()'); + + if (this._state === C.STATE_TERMINATED) { + return; + } + + const subsStateParameters = []; + + if (reason) { + subsStateParameters.push(`;reason=${reason}`); + } + + if (retryAfter !== null) { + subsStateParameters.push(`;retry-after=${retryAfter}`); + } + + this._sendNotify(subsStateParameters, body, null, 'terminated'); + + this._terminateDialog( + reason === 'timeout' ? C.SUBSCRIPTION_EXPIRED : C.FINAL_NOTIFY_SENT + ); + } + + /** + * Private API + */ + + _terminateDialog(termination_code) { + if (this._state === C.STATE_TERMINATED) { + return; + } + this._state = C.STATE_TERMINATED; + + clearTimeout(this._expires_timer); + if (this._dialog) { + this._dialog.terminate(); + this._dialog = null; + } + logger.debug(`emit "terminated" code=${termination_code}`); + + this.emit('terminated', termination_code); + } + + _setExpires(request) { + if (request.hasHeader('expires')) { + this._expires = parseInt(request.getHeader('expires')); + } else { + this._expires = this._defaultExpires; + logger.debug( + `missing Expires header field, default value set: ${this._expires}` + ); + } + } + + /** + * @param {Array} subsStateParams subscription state parameters. + * @param {String} body Notify body + * @param {Array} extraHeaders + */ + _sendNotify( + subsStateParameters, + body = null, + extraHeaders = null, + state = null + ) { + // Prevent send notify after final notify. + if (this._state === C.STATE_TERMINATED) { + logger.warn('final notify already sent'); + + return; + } + + // Build Subscription-State header with parameters. + let subsState = `Subscription-State: ${state || this._parseState()}`; + + for (const param of subsStateParameters) { + subsState += param; + } + + let headers = Utils.cloneArray(this._headers); + + headers.push(subsState); + + if (extraHeaders) { + headers = headers.concat(extraHeaders); + } + + if (body) { + headers.push(`Content-Type: ${this._content_type}`); + } + + this._dialog.sendRequest(JsSIP_C.NOTIFY, { + body, + extraHeaders: headers, + eventHandlers: { + onRequestTimeout: () => { + this._terminateDialog(C.NOTIFY_RESPONSE_TIMEOUT); + }, + onTransportError: () => { + this._terminateDialog(C.NOTIFY_TRANSPORT_ERROR); + }, + onErrorResponse: response => { + if (response.status_code === 401 || response.status_code === 407) { + this._terminateDialog(C.NOTIFY_AUTHENTICATION_FAILED); + } else { + this._terminateDialog(C.NOTIFY_NON_OK_RESPONSE); + } + }, + onDialogError: () => { + this._terminateDialog(C.NOTIFY_NON_OK_RESPONSE); + }, + }, + }); + } + + _setExpiresTimer() { + this._expires_timestamp = new Date().getTime() + this._expires * 1000; + + clearTimeout(this._expires_timer); + this._expires_timer = setTimeout(() => { + if (this._state === C.STATE_TERMINATED) { + return; + } + + logger.debug('emit "expired"'); + + // Client can hook into the 'expired' event and call terminate to send a custom notify. + this.emit('expired'); + + // This will be no-op if the client already called `terminate()`. + this.terminate(null, 'timeout'); + }, this._expires * 1000); + } + + _parseState() { + switch (this._state) { + case C.STATE_PENDING: { + return 'pending'; + } + case C.STATE_ACTIVE: { + return 'active'; + } + case C.STATE_TERMINATED: { + return 'terminated'; + } + default: { + throw new TypeError('wrong state value'); + } + } + } }; diff --git a/src/Options.js b/src/Options.js index 86b28882..f01c35d9 100644 --- a/src/Options.js +++ b/src/Options.js @@ -8,273 +8,246 @@ const Exceptions = require('./Exceptions'); const logger = new Logger('Options'); -module.exports = class Options extends EventEmitter -{ - constructor(ua) - { - super(); - - this._ua = ua; - this._request = null; - this._closed = false; - - this._direction = null; - this._local_identity = null; - this._remote_identity = null; - - // Whether an incoming message has been replied. - this._is_replied = false; - - // Custom message empty object for high level use. - this._data = {}; - } - - get direction() - { - return this._direction; - } - - get local_identity() - { - return this._local_identity; - } - - get remote_identity() - { - return this._remote_identity; - } - - send(target, body, options = {}) - { - const originalTarget = target; - - if (target === undefined) - { - throw new TypeError('A target is required for OPTIONS'); - } - - // Check target validity. - target = this._ua.normalizeTarget(target); - if (!target) - { - throw new TypeError(`Invalid target: ${originalTarget}`); - } - - // Get call options. - const extraHeaders = Utils.cloneArray(options.extraHeaders); - const eventHandlers = Utils.cloneObject(options.eventHandlers); - const contentType = options.contentType || 'application/sdp'; - - // Set event handlers. - for (const event in eventHandlers) - { - if (Object.prototype.hasOwnProperty.call(eventHandlers, event)) - { - this.on(event, eventHandlers[event]); - } - } - - extraHeaders.push(`Content-Type: ${contentType}`); - - this._request = new SIPMessage.OutgoingRequest( - JsSIP_C.OPTIONS, target, this._ua, null, extraHeaders); - - if (body) - { - this._request.body = body; - } - - const request_sender = new RequestSender(this._ua, this._request, { - onRequestTimeout : () => - { - this._onRequestTimeout(); - }, - onTransportError : () => - { - this._onTransportError(); - }, - onReceiveResponse : (response) => - { - this._receiveResponse(response); - } - }); - - this._newOptions('local', this._request); - - request_sender.send(); - } - - init_incoming(request) - { - this._request = request; - - this._newOptions('remote', request); - - // Reply with a 200 OK if the user didn't reply. - if (!this._is_replied) - { - this._is_replied = true; - request.reply(200); - } - - this._close(); - } - - /** - * Accept the incoming Options - * Only valid for incoming Options - */ - accept(options = {}) - { - const extraHeaders = Utils.cloneArray(options.extraHeaders); - const body = options.body; - - if (this._direction !== 'incoming') - { - throw new Exceptions.NotSupportedError('"accept" not supported for outgoing Options'); - } - - if (this._is_replied) - { - throw new Error('incoming Options already replied'); - } - - this._is_replied = true; - this._request.reply(200, null, extraHeaders, body); - } - - /** - * Reject the incoming Options - * Only valid for incoming Options - */ - reject(options = {}) - { - const status_code = options.status_code || 480; - const reason_phrase = options.reason_phrase; - const extraHeaders = Utils.cloneArray(options.extraHeaders); - const body = options.body; - - if (this._direction !== 'incoming') - { - throw new Exceptions.NotSupportedError('"reject" not supported for outgoing Options'); - } - - if (this._is_replied) - { - throw new Error('incoming Options already replied'); - } - - if (status_code < 300 || status_code >= 700) - { - throw new TypeError(`Invalid status_code: ${status_code}`); - } - - this._is_replied = true; - this._request.reply(status_code, reason_phrase, extraHeaders, body); - } - - _receiveResponse(response) - { - if (this._closed) - { - return; - } - switch (true) - { - case /^1[0-9]{2}$/.test(response.status_code): - // Ignore provisional responses. - break; - - case /^2[0-9]{2}$/.test(response.status_code): - this._succeeded('remote', response); - break; - - default: - { - const cause = Utils.sipErrorCause(response.status_code); - - this._failed('remote', response, cause); - break; - } - } - } - - _onRequestTimeout() - { - if (this._closed) - { - return; - } - this._failed('system', null, JsSIP_C.causes.REQUEST_TIMEOUT); - } - - _onTransportError() - { - if (this._closed) - { - return; - } - this._failed('system', null, JsSIP_C.causes.CONNECTION_ERROR); - } - - _close() - { - this._closed = true; - this._ua.destroyMessage(this); - } - - /** - * Internal Callbacks - */ - - _newOptions(originator, request) - { - if (originator === 'remote') - { - this._direction = 'incoming'; - this._local_identity = request.to; - this._remote_identity = request.from; - } - else if (originator === 'local') - { - this._direction = 'outgoing'; - this._local_identity = request.from; - this._remote_identity = request.to; - } - - this._ua.newOptions(this, { - originator, - message : this, - request - }); - } - - _failed(originator, response, cause) - { - logger.debug('OPTIONS failed'); - - this._close(); - - logger.debug('emit "failed"'); - - this.emit('failed', { - originator, - response : response || null, - cause - }); - } - - _succeeded(originator, response) - { - logger.debug('OPTIONS succeeded'); - - this._close(); - - logger.debug('emit "succeeded"'); - - this.emit('succeeded', { - originator, - response - }); - } +module.exports = class Options extends EventEmitter { + constructor(ua) { + super(); + + this._ua = ua; + this._request = null; + this._closed = false; + + this._direction = null; + this._local_identity = null; + this._remote_identity = null; + + // Whether an incoming message has been replied. + this._is_replied = false; + + // Custom message empty object for high level use. + this._data = {}; + } + + get direction() { + return this._direction; + } + + get local_identity() { + return this._local_identity; + } + + get remote_identity() { + return this._remote_identity; + } + + send(target, body, options = {}) { + const originalTarget = target; + + if (target === undefined) { + throw new TypeError('A target is required for OPTIONS'); + } + + // Check target validity. + target = this._ua.normalizeTarget(target); + if (!target) { + throw new TypeError(`Invalid target: ${originalTarget}`); + } + + // Get call options. + const extraHeaders = Utils.cloneArray(options.extraHeaders); + const eventHandlers = Utils.cloneObject(options.eventHandlers); + const contentType = options.contentType || 'application/sdp'; + + // Set event handlers. + for (const event in eventHandlers) { + if (Object.prototype.hasOwnProperty.call(eventHandlers, event)) { + this.on(event, eventHandlers[event]); + } + } + + extraHeaders.push(`Content-Type: ${contentType}`); + + this._request = new SIPMessage.OutgoingRequest( + JsSIP_C.OPTIONS, + target, + this._ua, + null, + extraHeaders + ); + + if (body) { + this._request.body = body; + } + + const request_sender = new RequestSender(this._ua, this._request, { + onRequestTimeout: () => { + this._onRequestTimeout(); + }, + onTransportError: () => { + this._onTransportError(); + }, + onReceiveResponse: response => { + this._receiveResponse(response); + }, + }); + + this._newOptions('local', this._request); + + request_sender.send(); + } + + init_incoming(request) { + this._request = request; + + this._newOptions('remote', request); + + // Reply with a 200 OK if the user didn't reply. + if (!this._is_replied) { + this._is_replied = true; + request.reply(200); + } + + this._close(); + } + + /** + * Accept the incoming Options + * Only valid for incoming Options + */ + accept(options = {}) { + const extraHeaders = Utils.cloneArray(options.extraHeaders); + const body = options.body; + + if (this._direction !== 'incoming') { + throw new Exceptions.NotSupportedError( + '"accept" not supported for outgoing Options' + ); + } + + if (this._is_replied) { + throw new Error('incoming Options already replied'); + } + + this._is_replied = true; + this._request.reply(200, null, extraHeaders, body); + } + + /** + * Reject the incoming Options + * Only valid for incoming Options + */ + reject(options = {}) { + const status_code = options.status_code || 480; + const reason_phrase = options.reason_phrase; + const extraHeaders = Utils.cloneArray(options.extraHeaders); + const body = options.body; + + if (this._direction !== 'incoming') { + throw new Exceptions.NotSupportedError( + '"reject" not supported for outgoing Options' + ); + } + + if (this._is_replied) { + throw new Error('incoming Options already replied'); + } + + if (status_code < 300 || status_code >= 700) { + throw new TypeError(`Invalid status_code: ${status_code}`); + } + + this._is_replied = true; + this._request.reply(status_code, reason_phrase, extraHeaders, body); + } + + _receiveResponse(response) { + if (this._closed) { + return; + } + switch (true) { + case /^1[0-9]{2}$/.test(response.status_code): { + // Ignore provisional responses. + break; + } + + case /^2[0-9]{2}$/.test(response.status_code): { + this._succeeded('remote', response); + break; + } + + default: { + const cause = Utils.sipErrorCause(response.status_code); + + this._failed('remote', response, cause); + break; + } + } + } + + _onRequestTimeout() { + if (this._closed) { + return; + } + this._failed('system', null, JsSIP_C.causes.REQUEST_TIMEOUT); + } + + _onTransportError() { + if (this._closed) { + return; + } + this._failed('system', null, JsSIP_C.causes.CONNECTION_ERROR); + } + + _close() { + this._closed = true; + this._ua.destroyMessage(this); + } + + /** + * Internal Callbacks + */ + + _newOptions(originator, request) { + if (originator === 'remote') { + this._direction = 'incoming'; + this._local_identity = request.to; + this._remote_identity = request.from; + } else if (originator === 'local') { + this._direction = 'outgoing'; + this._local_identity = request.from; + this._remote_identity = request.to; + } + + this._ua.newOptions(this, { + originator, + message: this, + request, + }); + } + + _failed(originator, response, cause) { + logger.debug('OPTIONS failed'); + + this._close(); + + logger.debug('emit "failed"'); + + this.emit('failed', { + originator, + response: response || null, + cause, + }); + } + + _succeeded(originator, response) { + logger.debug('OPTIONS succeeded'); + + this._close(); + + logger.debug('emit "succeeded"'); + + this.emit('succeeded', { + originator, + response, + }); + } }; diff --git a/src/Parser.js b/src/Parser.js index e7a5090d..79013d9a 100644 --- a/src/Parser.js +++ b/src/Parser.js @@ -7,312 +7,297 @@ const logger = new Logger('Parser'); /** * Parse SIP Message */ -exports.parseMessage = (data, ua) => -{ - let message; - let bodyStart; - let headerEnd = data.indexOf('\r\n'); +exports.parseMessage = (data, ua) => { + let message; + let bodyStart; + let headerEnd = data.indexOf('\r\n'); - if (headerEnd === -1) - { - logger.warn('parseMessage() | no CRLF found, not a SIP message'); + if (headerEnd === -1) { + logger.warn('parseMessage() | no CRLF found, not a SIP message'); - return; - } + return; + } - // Parse first line. Check if it is a Request or a Reply. - const firstLine = data.substring(0, headerEnd); - let parsed = Grammar.parse(firstLine, 'Request_Response'); + // Parse first line. Check if it is a Request or a Reply. + const firstLine = data.substring(0, headerEnd); + let parsed = Grammar.parse(firstLine, 'Request_Response'); - if (parsed === -1) - { - logger.warn(`parseMessage() | error parsing first line of SIP message: "${firstLine}"`); + if (parsed === -1) { + logger.warn( + `parseMessage() | error parsing first line of SIP message: "${firstLine}"` + ); - return; - } - else if (!parsed.status_code) - { - message = new SIPMessage.IncomingRequest(ua); - message.method = parsed.method; - message.ruri = parsed.uri; - } - else - { - message = new SIPMessage.IncomingResponse(); - message.status_code = parsed.status_code; - message.reason_phrase = parsed.reason_phrase; - } + return; + } else if (!parsed.status_code) { + message = new SIPMessage.IncomingRequest(ua); + message.method = parsed.method; + message.ruri = parsed.uri; + } else { + message = new SIPMessage.IncomingResponse(); + message.status_code = parsed.status_code; + message.reason_phrase = parsed.reason_phrase; + } - message.data = data; - let headerStart = headerEnd + 2; + message.data = data; + let headerStart = headerEnd + 2; - /* Loop over every line in data. Detect the end of each header and parse - * it or simply add to the headers collection. - */ - while (true) - { - headerEnd = getHeader(data, headerStart); + /* Loop over every line in data. Detect the end of each header and parse + * it or simply add to the headers collection. + */ + while (true) { + headerEnd = getHeader(data, headerStart); - // The SIP message has normally finished. - if (headerEnd === -2) - { - bodyStart = headerStart + 2; - break; - } - // Data.indexOf returned -1 due to a malformed message. - else if (headerEnd === -1) - { - logger.warn('parseMessage() | malformed message'); + // The SIP message has normally finished. + if (headerEnd === -2) { + bodyStart = headerStart + 2; + break; + } + // Data.indexOf returned -1 due to a malformed message. + else if (headerEnd === -1) { + logger.warn('parseMessage() | malformed message'); - return; - } + return; + } - parsed = parseHeader(message, data, headerStart, headerEnd); + parsed = parseHeader(message, data, headerStart, headerEnd); - if (parsed !== true) - { - logger.warn('parseMessage() |', parsed.error); + if (parsed !== true) { + logger.warn('parseMessage() |', parsed.error); - return; - } + return; + } - headerStart = headerEnd + 2; - } + headerStart = headerEnd + 2; + } - /* RFC3261 18.3. - * If there are additional bytes in the transport packet - * beyond the end of the body, they MUST be discarded. - */ - if (message.hasHeader('content-length')) - { - const contentLength = message.getHeader('content-length'); + /* RFC3261 18.3. + * If there are additional bytes in the transport packet + * beyond the end of the body, they MUST be discarded. + */ + if (message.hasHeader('content-length')) { + const contentLength = message.getHeader('content-length'); - message.body = data.substr(bodyStart, contentLength); - } - else - { - message.body = data.substring(bodyStart); - } + message.body = data.substr(bodyStart, contentLength); + } else { + message.body = data.substring(bodyStart); + } - return message; + return message; }; /** * Extract and parse every header of a SIP message. */ -function getHeader(data, headerStart) -{ - // 'start' position of the header. - let start = headerStart; - // 'end' position of the header. - let end = 0; - // 'partial end' position of the header. - let partialEnd = 0; +function getHeader(data, headerStart) { + // 'start' position of the header. + let start = headerStart; + // 'end' position of the header. + let end = 0; + // 'partial end' position of the header. + let partialEnd = 0; - // End of message. - if (data.substring(start, start + 2).match(/(^\r\n)/)) - { - return -2; - } + // End of message. + if (data.substring(start, start + 2).match(/(^\r\n)/)) { + return -2; + } - while (end === 0) - { - // Partial End of Header. - partialEnd = data.indexOf('\r\n', start); + while (end === 0) { + // Partial End of Header. + partialEnd = data.indexOf('\r\n', start); - // 'indexOf' returns -1 if the value to be found never occurs. - if (partialEnd === -1) - { - return partialEnd; - } + // 'indexOf' returns -1 if the value to be found never occurs. + if (partialEnd === -1) { + return partialEnd; + } - if (!data.substring(partialEnd + 2, partialEnd + 4).match(/(^\r\n)/) && data.charAt(partialEnd + 2).match(/(^\s+)/)) - { - // Not the end of the message. Continue from the next position. - start = partialEnd + 2; - } - else - { - end = partialEnd; - } - } + if ( + !data.substring(partialEnd + 2, partialEnd + 4).match(/(^\r\n)/) && + data.charAt(partialEnd + 2).match(/(^\s+)/) + ) { + // Not the end of the message. Continue from the next position. + start = partialEnd + 2; + } else { + end = partialEnd; + } + } - return end; + return end; } -function parseHeader(message, data, headerStart, headerEnd) -{ - let parsed; - const hcolonIndex = data.indexOf(':', headerStart); - const headerName = data.substring(headerStart, hcolonIndex).trim(); - const headerValue = data.substring(hcolonIndex + 1, headerEnd).trim(); +function parseHeader(message, data, headerStart, headerEnd) { + let parsed; + const hcolonIndex = data.indexOf(':', headerStart); + const headerName = data.substring(headerStart, hcolonIndex).trim(); + const headerValue = data.substring(hcolonIndex + 1, headerEnd).trim(); - // If header-field is well-known, parse it. - switch (headerName.toLowerCase()) - { - case 'via': - case 'v': - message.addHeader('via', headerValue); - if (message.getHeaders('via').length === 1) - { - parsed = message.parseHeader('Via'); - if (parsed) - { - message.via = parsed; - message.via_branch = parsed.branch; - } - } - else - { - parsed = 0; - } - break; - case 'from': - case 'f': - message.setHeader('from', headerValue); - parsed = message.parseHeader('from'); - if (parsed) - { - message.from = parsed; - message.from_tag = parsed.getParam('tag'); - } - break; - case 'to': - case 't': - message.setHeader('to', headerValue); - parsed = message.parseHeader('to'); - if (parsed) - { - message.to = parsed; - message.to_tag = parsed.getParam('tag'); - } - break; - case 'record-route': - parsed = Grammar.parse(headerValue, 'Record_Route'); + // If header-field is well-known, parse it. + switch (headerName.toLowerCase()) { + case 'via': + case 'v': { + message.addHeader('via', headerValue); + if (message.getHeaders('via').length === 1) { + parsed = message.parseHeader('Via'); + if (parsed) { + message.via = parsed; + message.via_branch = parsed.branch; + } + } else { + parsed = 0; + } + break; + } + case 'from': + case 'f': { + message.setHeader('from', headerValue); + parsed = message.parseHeader('from'); + if (parsed) { + message.from = parsed; + message.from_tag = parsed.getParam('tag'); + } + break; + } + case 'to': + case 't': { + message.setHeader('to', headerValue); + parsed = message.parseHeader('to'); + if (parsed) { + message.to = parsed; + message.to_tag = parsed.getParam('tag'); + } + break; + } + case 'record-route': { + parsed = Grammar.parse(headerValue, 'Record_Route'); - if (parsed === -1) - { - parsed = undefined; - } - else - { - for (const header of parsed) - { - message.addHeader('record-route', headerValue.substring(header.possition, header.offset)); - message.headers['Record-Route'][message.getHeaders('record-route').length - 1].parsed = header.parsed; - } - } - break; - case 'call-id': - case 'i': - message.setHeader('call-id', headerValue); - parsed = message.parseHeader('call-id'); - if (parsed) - { - message.call_id = headerValue; - } - break; - case 'contact': - case 'm': - parsed = Grammar.parse(headerValue, 'Contact'); + if (parsed === -1) { + parsed = undefined; + } else { + for (const header of parsed) { + message.addHeader( + 'record-route', + headerValue.substring(header.possition, header.offset) + ); + message.headers['Record-Route'][ + message.getHeaders('record-route').length - 1 + ].parsed = header.parsed; + } + } + break; + } + case 'call-id': + case 'i': { + message.setHeader('call-id', headerValue); + parsed = message.parseHeader('call-id'); + if (parsed) { + message.call_id = headerValue; + } + break; + } + case 'contact': + case 'm': { + parsed = Grammar.parse(headerValue, 'Contact'); - if (parsed === -1) - { - parsed = undefined; - } - else - { - for (const header of parsed) - { - message.addHeader('contact', headerValue.substring(header.possition, header.offset)); - message.headers.Contact[message.getHeaders('contact').length - 1].parsed = header.parsed; - } - } - break; - case 'content-length': - case 'l': - message.setHeader('content-length', headerValue); - parsed = message.parseHeader('content-length'); - break; - case 'content-type': - case 'c': - message.setHeader('content-type', headerValue); - parsed = message.parseHeader('content-type'); - break; - case 'cseq': - message.setHeader('cseq', headerValue); - parsed = message.parseHeader('cseq'); - if (parsed) - { - message.cseq = parsed.value; - } - if (message instanceof SIPMessage.IncomingResponse) - { - message.method = parsed.method; - } - break; - case 'max-forwards': - message.setHeader('max-forwards', headerValue); - parsed = message.parseHeader('max-forwards'); - break; - case 'www-authenticate': - message.setHeader('www-authenticate', headerValue); - parsed = message.parseHeader('www-authenticate'); - break; - case 'proxy-authenticate': - message.setHeader('proxy-authenticate', headerValue); - parsed = message.parseHeader('proxy-authenticate'); - break; - case 'session-expires': - case 'x': - message.setHeader('session-expires', headerValue); - parsed = message.parseHeader('session-expires'); - if (parsed) - { - message.session_expires = parsed.expires; - message.session_expires_refresher = parsed.refresher; - } - break; - case 'refer-to': - case 'r': - message.setHeader('refer-to', headerValue); - parsed = message.parseHeader('refer-to'); - if (parsed) - { - message.refer_to = parsed; - } - break; - case 'replaces': - message.setHeader('replaces', headerValue); - parsed = message.parseHeader('replaces'); - if (parsed) - { - message.replaces = parsed; - } - break; - case 'event': - case 'o': - message.setHeader('event', headerValue); - parsed = message.parseHeader('event'); - if (parsed) - { - message.event = parsed; - } - break; - default: - // Do not parse this header. - message.addHeader(headerName, headerValue); - parsed = 0; - } + if (parsed === -1) { + parsed = undefined; + } else { + for (const header of parsed) { + message.addHeader( + 'contact', + headerValue.substring(header.possition, header.offset) + ); + message.headers.Contact[ + message.getHeaders('contact').length - 1 + ].parsed = header.parsed; + } + } + break; + } + case 'content-length': + case 'l': { + message.setHeader('content-length', headerValue); + parsed = message.parseHeader('content-length'); + break; + } + case 'content-type': + case 'c': { + message.setHeader('content-type', headerValue); + parsed = message.parseHeader('content-type'); + break; + } + case 'cseq': { + message.setHeader('cseq', headerValue); + parsed = message.parseHeader('cseq'); + if (parsed) { + message.cseq = parsed.value; + } + if (message instanceof SIPMessage.IncomingResponse) { + message.method = parsed.method; + } + break; + } + case 'max-forwards': { + message.setHeader('max-forwards', headerValue); + parsed = message.parseHeader('max-forwards'); + break; + } + case 'www-authenticate': { + message.setHeader('www-authenticate', headerValue); + parsed = message.parseHeader('www-authenticate'); + break; + } + case 'proxy-authenticate': { + message.setHeader('proxy-authenticate', headerValue); + parsed = message.parseHeader('proxy-authenticate'); + break; + } + case 'session-expires': + case 'x': { + message.setHeader('session-expires', headerValue); + parsed = message.parseHeader('session-expires'); + if (parsed) { + message.session_expires = parsed.expires; + message.session_expires_refresher = parsed.refresher; + } + break; + } + case 'refer-to': + case 'r': { + message.setHeader('refer-to', headerValue); + parsed = message.parseHeader('refer-to'); + if (parsed) { + message.refer_to = parsed; + } + break; + } + case 'replaces': { + message.setHeader('replaces', headerValue); + parsed = message.parseHeader('replaces'); + if (parsed) { + message.replaces = parsed; + } + break; + } + case 'event': + case 'o': { + message.setHeader('event', headerValue); + parsed = message.parseHeader('event'); + if (parsed) { + message.event = parsed; + } + break; + } + default: { + // Do not parse this header. + message.addHeader(headerName, headerValue); + parsed = 0; + } + } - if (parsed === undefined) - { - return { - error : `error parsing header "${headerName}"` - }; - } - else - { - return true; - } + if (parsed === undefined) { + return { + error: `error parsing header "${headerName}"`, + }; + } else { + return true; + } } diff --git a/src/RTCSession.d.ts b/src/RTCSession.d.ts index fb68910a..4e03d925 100644 --- a/src/RTCSession.d.ts +++ b/src/RTCSession.d.ts @@ -1,183 +1,190 @@ -import {EventEmitter} from 'events' - -import {IncomingRequest, IncomingResponse, OutgoingRequest} from './SIPMessage' -import {NameAddrHeader} from './NameAddrHeader' -import {URI} from './URI' -import {UA} from './UA' -import {causes, DTMF_TRANSPORT} from './Constants' +import { EventEmitter } from 'events'; + +import { + IncomingRequest, + IncomingResponse, + OutgoingRequest, +} from './SIPMessage'; +import { NameAddrHeader } from './NameAddrHeader'; +import { URI } from './URI'; +import { UA } from './UA'; +import { causes, DTMF_TRANSPORT } from './Constants'; interface RTCPeerConnectionDeprecated extends RTCPeerConnection { - /** - * @deprecated - * @see https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/getRemoteStreams - */ - getRemoteStreams(): MediaStream[]; + /** + * @deprecated + * @see https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/getRemoteStreams + */ + getRemoteStreams(): MediaStream[]; } export declare enum SessionDirection { - INCOMING = 'incoming', - OUTGOING = 'outgoing', + INCOMING = 'incoming', + OUTGOING = 'outgoing', } export declare enum Originator { - LOCAL = 'local', - REMOTE = 'remote', - SYSTEM = 'system', + LOCAL = 'local', + REMOTE = 'remote', + SYSTEM = 'system', } // options export interface ExtraHeaders { - extraHeaders?: string[]; + extraHeaders?: string[]; } export interface AnswerOptions extends ExtraHeaders { - mediaConstraints?: MediaStreamConstraints; - mediaStream?: MediaStream; - pcConfig?: RTCConfiguration; - rtcConstraints?: object; - rtcAnswerConstraints?: RTCOfferOptions; - rtcOfferConstraints?: RTCOfferOptions; - sessionTimersExpires?: number; + mediaConstraints?: MediaStreamConstraints; + mediaStream?: MediaStream; + pcConfig?: RTCConfiguration; + rtcConstraints?: object; + rtcAnswerConstraints?: RTCOfferOptions; + rtcOfferConstraints?: RTCOfferOptions; + sessionTimersExpires?: number; } export interface RejectOptions extends ExtraHeaders { - status_code?: number; - reason_phrase?: string; + status_code?: number; + reason_phrase?: string; } export interface TerminateOptions extends RejectOptions { - body?: string; - cause?: causes | string; + body?: string; + cause?: causes | string; } export interface ReferOptions extends ExtraHeaders { - eventHandlers?: any; - replaces?: RTCSession; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + eventHandlers?: any; + replaces?: RTCSession; } export interface OnHoldResult { - local: boolean; - remote: boolean; + local: boolean; + remote: boolean; } export interface DTMFOptions extends ExtraHeaders { - duration?: number; - interToneGap?: number; - transportType?: DTMF_TRANSPORT; + duration?: number; + interToneGap?: number; + transportType?: DTMF_TRANSPORT; } export interface HoldOptions extends ExtraHeaders { - useUpdate?: boolean; + useUpdate?: boolean; } export interface RenegotiateOptions extends HoldOptions { - rtcOfferConstraints?: RTCOfferOptions; + rtcOfferConstraints?: RTCOfferOptions; } // events export interface DTMF extends EventEmitter { - tone: string; - duration: number; + tone: string; + duration: number; } export interface Info extends EventEmitter { - contentType: string; - body: string; + contentType: string; + body: string; } export interface PeerConnectionEvent { - peerconnection: RTCPeerConnectionDeprecated; + peerconnection: RTCPeerConnectionDeprecated; } export interface ConnectingEvent { - request: IncomingRequest | OutgoingRequest; + request: IncomingRequest | OutgoingRequest; } export interface SendingEvent { - request: OutgoingRequest + request: OutgoingRequest; } export interface IncomingEvent { - originator: Originator.LOCAL; + originator: Originator.LOCAL; } export interface EndEvent { - originator: Originator; - message: IncomingRequest | IncomingResponse; - cause: string; + originator: Originator; + message: IncomingRequest | IncomingResponse; + cause: string; } export interface IncomingDTMFEvent { - originator: Originator.REMOTE; - dtmf: DTMF; - request: IncomingRequest; + originator: Originator.REMOTE; + dtmf: DTMF; + request: IncomingRequest; } export interface OutgoingDTMFEvent { - originator: Originator.LOCAL; - dtmf: DTMF; - request: OutgoingRequest; + originator: Originator.LOCAL; + dtmf: DTMF; + request: OutgoingRequest; } export interface IncomingInfoEvent { - originator: Originator.REMOTE; - info: Info; - request: IncomingRequest; + originator: Originator.REMOTE; + info: Info; + request: IncomingRequest; } export interface OutgoingInfoEvent { - originator: Originator.LOCAL; - info: Info; - request: OutgoingRequest; + originator: Originator.LOCAL; + info: Info; + request: OutgoingRequest; } export interface HoldEvent { - originator: Originator + originator: Originator; } export interface ReInviteEvent { - request: IncomingRequest; - callback?: VoidFunction; - reject: (options?: RejectOptions) => void; + request: IncomingRequest; + callback?: VoidFunction; + reject: (options?: RejectOptions) => void; } export interface ReferEvent { - request: IncomingRequest; - accept: Function; - reject: VoidFunction; + request: IncomingRequest; + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + accept: Function; + reject: VoidFunction; } export interface SDPEvent { - originator: Originator; - type: string; - sdp: string; + originator: Originator; + type: string; + sdp: string; } export interface IceCandidateEvent { - candidate: RTCIceCandidate; - ready: VoidFunction; + candidate: RTCIceCandidate; + ready: VoidFunction; } export interface OutgoingEvent { - originator: Originator.REMOTE; - response: IncomingResponse; + originator: Originator.REMOTE; + response: IncomingResponse; } export interface OutgoingAckEvent { - originator: Originator.LOCAL; + originator: Originator.LOCAL; } export interface IncomingAckEvent { - originator: Originator.REMOTE; - ack: IncomingRequest; + originator: Originator.REMOTE; + ack: IncomingRequest; } export interface MediaStreamTypes { - audio?: boolean; - video?: boolean; + audio?: boolean; + video?: boolean; } // listener +// eslint-disable-next-line @typescript-eslint/no-explicit-any export type GenericErrorListener = (error: any) => void; export type PeerConnectionListener = (event: PeerConnectionEvent) => void; export type ConnectingListener = (event: ConnectingEvent) => void; @@ -187,7 +194,9 @@ export type OutgoingListener = (event: OutgoingEvent) => void; export type IncomingConfirmedListener = (event: IncomingAckEvent) => void; export type OutgoingConfirmedListener = (event: OutgoingAckEvent) => void; export type CallListener = IncomingListener | OutgoingListener; -export type ConfirmedListener = IncomingConfirmedListener | OutgoingConfirmedListener; +export type ConfirmedListener = + | IncomingConfirmedListener + | OutgoingConfirmedListener; export type EndListener = (event: EndEvent) => void; export type IncomingDTMFListener = (event: IncomingDTMFEvent) => void; export type OutgoingDTMFListener = (event: OutgoingDTMFEvent) => void; @@ -204,107 +213,112 @@ export type SDPListener = (event: SDPEvent) => void; export type IceCandidateListener = (event: IceCandidateEvent) => void; export interface RTCSessionEventMap { - 'peerconnection': PeerConnectionListener; - 'connecting': ConnectingListener; - 'sending': SendingListener; - 'progress': CallListener; - 'accepted': CallListener; - 'confirmed': ConfirmedListener; - 'ended': EndListener; - 'failed': EndListener; - 'newDTMF': DTMFListener; - 'newInfo': InfoListener; - 'hold': HoldListener; - 'unhold': HoldListener; - 'muted': MuteListener; - 'unmuted': MuteListener; - 'reinvite': ReInviteListener; - 'update': UpdateListener; - 'refer': ReferListener; - 'replaces': ReferListener; - 'sdp': SDPListener; - 'icecandidate': IceCandidateListener; - 'getusermediafailed': GenericErrorListener; - 'peerconnection:createofferfailed': GenericErrorListener; - 'peerconnection:createanswerfailed': GenericErrorListener; - 'peerconnection:setlocaldescriptionfailed': GenericErrorListener; - 'peerconnection:setremotedescriptionfailed': GenericErrorListener; + peerconnection: PeerConnectionListener; + connecting: ConnectingListener; + sending: SendingListener; + progress: CallListener; + accepted: CallListener; + confirmed: ConfirmedListener; + ended: EndListener; + failed: EndListener; + newDTMF: DTMFListener; + newInfo: InfoListener; + hold: HoldListener; + unhold: HoldListener; + muted: MuteListener; + unmuted: MuteListener; + reinvite: ReInviteListener; + update: UpdateListener; + refer: ReferListener; + replaces: ReferListener; + sdp: SDPListener; + icecandidate: IceCandidateListener; + getusermediafailed: GenericErrorListener; + 'peerconnection:createofferfailed': GenericErrorListener; + 'peerconnection:createanswerfailed': GenericErrorListener; + 'peerconnection:setlocaldescriptionfailed': GenericErrorListener; + 'peerconnection:setremotedescriptionfailed': GenericErrorListener; } declare enum SessionStatus { - STATUS_NULL = 0, - STATUS_INVITE_SENT = 1, - STATUS_1XX_RECEIVED = 2, - STATUS_INVITE_RECEIVED = 3, - STATUS_WAITING_FOR_ANSWER = 4, - STATUS_ANSWERED = 5, - STATUS_WAITING_FOR_ACK = 6, - STATUS_CANCELED = 7, - STATUS_TERMINATED = 8, - STATUS_CONFIRMED = 9 + STATUS_NULL = 0, + STATUS_INVITE_SENT = 1, + STATUS_1XX_RECEIVED = 2, + STATUS_INVITE_RECEIVED = 3, + STATUS_WAITING_FOR_ANSWER = 4, + STATUS_ANSWERED = 5, + STATUS_WAITING_FOR_ACK = 6, + STATUS_CANCELED = 7, + STATUS_TERMINATED = 8, + STATUS_CONFIRMED = 9, } export class RTCSession extends EventEmitter { - constructor (ua: UA); + constructor(ua: UA); - static get C(): typeof SessionStatus; + static get C(): typeof SessionStatus; - get C(): typeof SessionStatus; + get C(): typeof SessionStatus; - get causes(): typeof causes; + get causes(): typeof causes; - get id(): string; + get id(): string; - set data(_data: any); - get data(): any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + set data(_data: any); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + get data(): any; - get connection(): RTCPeerConnectionDeprecated; + get connection(): RTCPeerConnectionDeprecated; - get contact(): string; + get contact(): string; - get direction(): SessionDirection; + get direction(): SessionDirection; - get local_identity(): NameAddrHeader; + get local_identity(): NameAddrHeader; - get remote_identity(): NameAddrHeader; + get remote_identity(): NameAddrHeader; - get start_time(): Date; + get start_time(): Date; - get end_time(): Date; + get end_time(): Date; - get status(): SessionStatus; + get status(): SessionStatus; - isInProgress(): boolean; + isInProgress(): boolean; - isEstablished(): boolean; + isEstablished(): boolean; - isEnded(): boolean; + isEnded(): boolean; - isReadyToReOffer(): boolean; + isReadyToReOffer(): boolean; - answer(options?: AnswerOptions): void; + answer(options?: AnswerOptions): void; - terminate(options?: TerminateOptions): void; + terminate(options?: TerminateOptions): void; - sendDTMF(tones: string | number, options?: DTMFOptions): void; + sendDTMF(tones: string | number, options?: DTMFOptions): void; - sendInfo(contentType: string, body?: string, options?: ExtraHeaders): void; + sendInfo(contentType: string, body?: string, options?: ExtraHeaders): void; - hold(options?: HoldOptions, done?: VoidFunction): boolean; + hold(options?: HoldOptions, done?: VoidFunction): boolean; - unhold(options?: HoldOptions, done?: VoidFunction): boolean; + unhold(options?: HoldOptions, done?: VoidFunction): boolean; - renegotiate(options?: RenegotiateOptions, done?: VoidFunction): boolean; + renegotiate(options?: RenegotiateOptions, done?: VoidFunction): boolean; - isOnHold(): OnHoldResult; + isOnHold(): OnHoldResult; - mute(options?: MediaStreamTypes): void; + mute(options?: MediaStreamTypes): void; - unmute(options?: MediaStreamTypes): void; + unmute(options?: MediaStreamTypes): void; - isMuted(): MediaStreamTypes; + isMuted(): MediaStreamTypes; - refer(target: string | URI, options?: ReferOptions): void; + refer(target: string | URI, options?: ReferOptions): void; - on(type: T, listener: RTCSessionEventMap[T]): this; + on( + type: T, + listener: RTCSessionEventMap[T] + ): this; } diff --git a/src/RTCSession.js b/src/RTCSession.js index 1b9e6ebc..7ac7e76c 100644 --- a/src/RTCSession.js +++ b/src/RTCSession.js @@ -1,5 +1,5 @@ -// eslint-disable-next-line no-redeclare /* globals RTCPeerConnection: false, RTCSessionDescription: false */ +/* eslint-disable no-invalid-this */ const EventEmitter = require('events').EventEmitter; const sdp_transform = require('sdp-transform'); @@ -21,3630 +21,3346 @@ const URI = require('./URI'); const logger = new Logger('RTCSession'); const C = { - // RTCSession states. - STATUS_NULL : 0, - STATUS_INVITE_SENT : 1, - STATUS_1XX_RECEIVED : 2, - STATUS_INVITE_RECEIVED : 3, - STATUS_WAITING_FOR_ANSWER : 4, - STATUS_ANSWERED : 5, - STATUS_WAITING_FOR_ACK : 6, - STATUS_CANCELED : 7, - STATUS_TERMINATED : 8, - STATUS_CONFIRMED : 9 + // RTCSession states. + STATUS_NULL: 0, + STATUS_INVITE_SENT: 1, + STATUS_1XX_RECEIVED: 2, + STATUS_INVITE_RECEIVED: 3, + STATUS_WAITING_FOR_ANSWER: 4, + STATUS_ANSWERED: 5, + STATUS_WAITING_FOR_ACK: 6, + STATUS_CANCELED: 7, + STATUS_TERMINATED: 8, + STATUS_CONFIRMED: 9, }; /** * Local variables. */ -const holdMediaTypes = [ 'audio', 'video' ]; - -module.exports = class RTCSession extends EventEmitter -{ - /** - * Expose C object. - */ - static get C() - { - return C; - } - - constructor(ua) - { - logger.debug('new'); - - super(); - - this._id = null; - this._ua = ua; - this._status = C.STATUS_NULL; - this._dialog = null; - this._earlyDialogs = {}; - this._contact = null; - this._from_tag = null; - this._to_tag = null; - - // The RTCPeerConnection instance (public attribute). - this._connection = null; - - // Prevent races on serial PeerConnction operations. - this._connectionPromiseQueue = Promise.resolve(); - - // Incoming/Outgoing request being currently processed. - this._request = null; - - // Cancel state for initial outgoing request. - this._is_canceled = false; - this._cancel_reason = ''; - - // RTCSession confirmation flag. - this._is_confirmed = false; - - // Is late SDP being negotiated. - this._late_sdp = false; - - // Default rtcOfferConstraints and rtcAnswerConstrainsts (passed in connect() or answer()). - this._rtcOfferConstraints = null; - this._rtcAnswerConstraints = null; - - // Local MediaStream. - this._localMediaStream = null; - this._localMediaStreamLocallyGenerated = false; - - // Flag to indicate PeerConnection ready for new actions. - this._rtcReady = true; - - // Flag to indicate ICE candidate gathering is finished even if iceGatheringState is not yet 'complete'. - this._iceReady = false; - - // SIP Timers. - this._timers = { - ackTimer : null, - expiresTimer : null, - invite2xxTimer : null, - userNoAnswerTimer : null - }; - - // Session info. - this._direction = null; - this._local_identity = null; - this._remote_identity = null; - this._start_time = null; - this._end_time = null; - this._tones = null; - - // Mute/Hold state. - this._audioMuted = false; - this._videoMuted = false; - this._localHold = false; - this._remoteHold = false; - - // Session Timers (RFC 4028). - this._sessionTimers = { - enabled : this._ua.configuration.session_timers, - refreshMethod : this._ua.configuration.session_timers_refresh_method, - defaultExpires : JsSIP_C.SESSION_EXPIRES, - currentExpires : null, - running : false, - refresher : false, - timer : null // A setTimeout. - }; - - // Map of ReferSubscriber instances indexed by the REFER's CSeq number. - this._referSubscribers = {}; - - // Custom session empty object for high level use. - this._data = {}; - } - - /** - * User API - */ - - // Expose RTCSession constants as a property of the RTCSession instance. - get C() - { - return C; - } - - // Expose session failed/ended causes as a property of the RTCSession instance. - get causes() - { - return JsSIP_C.causes; - } - - get id() - { - return this._id; - } - - get connection() - { - return this._connection; - } - - get contact() - { - return this._contact; - } - - get direction() - { - return this._direction; - } - - get local_identity() - { - return this._local_identity; - } - - get remote_identity() - { - return this._remote_identity; - } - - get start_time() - { - return this._start_time; - } - - get end_time() - { - return this._end_time; - } - - get data() - { - return this._data; - } - - set data(_data) - { - this._data = _data; - } - - get status() - { - return this._status; - } - - isInProgress() - { - switch (this._status) - { - case C.STATUS_NULL: - case C.STATUS_INVITE_SENT: - case C.STATUS_1XX_RECEIVED: - case C.STATUS_INVITE_RECEIVED: - case C.STATUS_WAITING_FOR_ANSWER: - return true; - default: - return false; - } - } - - isEstablished() - { - switch (this._status) - { - case C.STATUS_ANSWERED: - case C.STATUS_WAITING_FOR_ACK: - case C.STATUS_CONFIRMED: - return true; - default: - return false; - } - } - - isEnded() - { - switch (this._status) - { - case C.STATUS_CANCELED: - case C.STATUS_TERMINATED: - return true; - default: - return false; - } - } - - isMuted() - { - return { - audio : this._audioMuted, - video : this._videoMuted - }; - } - - isOnHold() - { - return { - local : this._localHold, - remote : this._remoteHold - }; - } - - connect(target, options = {}, initCallback) - { - logger.debug('connect()'); - - const originalTarget = target; - const eventHandlers = Utils.cloneObject(options.eventHandlers); - const extraHeaders = Utils.cloneArray(options.extraHeaders); - const mediaConstraints = Utils.cloneObject(options.mediaConstraints, { - audio : true, - video : true - }); - const mediaStream = options.mediaStream || null; - const pcConfig = Utils.cloneObject(options.pcConfig, { iceServers: [] }); - const rtcConstraints = options.rtcConstraints || null; - const rtcOfferConstraints = options.rtcOfferConstraints || null; - - this._rtcOfferConstraints = rtcOfferConstraints; - this._rtcAnswerConstraints = options.rtcAnswerConstraints || null; - - this._data = options.data || this._data; - - // Check target. - if (target === undefined) - { - throw new TypeError('Not enough arguments'); - } - - // Check Session Status. - if (this._status !== C.STATUS_NULL) - { - throw new Exceptions.InvalidStateError(this._status); - } - - // Check WebRTC support. - if (!window.RTCPeerConnection) - { - throw new Exceptions.NotSupportedError('WebRTC not supported'); - } - - // Check target validity. - target = this._ua.normalizeTarget(target); - if (!target) - { - throw new TypeError(`Invalid target: ${originalTarget}`); - } - - // Session Timers. - if (this._sessionTimers.enabled) - { - if (Utils.isDecimal(options.sessionTimersExpires)) - { - if (options.sessionTimersExpires >= JsSIP_C.MIN_SESSION_EXPIRES) - { - this._sessionTimers.defaultExpires = options.sessionTimersExpires; - } - else - { - this._sessionTimers.defaultExpires = JsSIP_C.SESSION_EXPIRES; - } - } - } - - // Set event handlers. - for (const event in eventHandlers) - { - if (Object.prototype.hasOwnProperty.call(eventHandlers, event)) - { - this.on(event, eventHandlers[event]); - } - } - - // Session parameter initialization. - this._from_tag = Utils.newTag(); - - // Set anonymous property. - const anonymous = options.anonymous || false; - - const requestParams = { from_tag: this._from_tag }; - - this._contact = this._ua.contact.toString({ - anonymous, - outbound : true - }); - - if (anonymous) - { - requestParams.from_display_name = 'Anonymous'; - requestParams.from_uri = new URI('sip', 'anonymous', 'anonymous.invalid'); - - extraHeaders.push(`P-Preferred-Identity: ${this._ua.configuration.uri.toString()}`); - extraHeaders.push('Privacy: id'); - } - else if (options.fromUserName) - { - requestParams.from_uri = new URI('sip', options.fromUserName, this._ua.configuration.uri.host); - - extraHeaders.push(`P-Preferred-Identity: ${this._ua.configuration.uri.toString()}`); - } - - if (options.fromDisplayName) - { - requestParams.from_display_name = options.fromDisplayName; - } - - extraHeaders.push(`Contact: ${this._contact}`); - extraHeaders.push('Content-Type: application/sdp'); - if (this._sessionTimers.enabled) - { - extraHeaders.push(`Session-Expires: ${this._sessionTimers.defaultExpires}${this._ua.configuration.session_timers_force_refresher ? ';refresher=uac' : ''}`); - } - - this._request = new SIPMessage.InitialOutgoingInviteRequest( - target, this._ua, requestParams, extraHeaders); - - this._id = this._request.call_id + this._from_tag; - - // Create a new RTCPeerConnection instance. - this._createRTCConnection(pcConfig, rtcConstraints); - - // Set internal properties. - this._direction = 'outgoing'; - this._local_identity = this._request.from; - this._remote_identity = this._request.to; - - // User explicitly provided a newRTCSession callback for this session. - if (initCallback) - { - initCallback(this); - } - - this._newRTCSession('local', this._request); - - this._sendInitialRequest(mediaConstraints, rtcOfferConstraints, mediaStream); - } - - init_incoming(request, initCallback) - { - logger.debug('init_incoming()'); - - let expires; - const contentType = request.hasHeader('Content-Type') ? - request.getHeader('Content-Type').toLowerCase() : undefined; - - // Check body and content type. - if (request.body && (contentType !== 'application/sdp')) - { - request.reply(415); - - return; - } - - // Session parameter initialization. - this._status = C.STATUS_INVITE_RECEIVED; - this._from_tag = request.from_tag; - this._id = request.call_id + this._from_tag; - this._request = request; - this._contact = this._ua.contact.toString(); - - // Get the Expires header value if exists. - if (request.hasHeader('expires')) - { - expires = request.getHeader('expires') * 1000; - } - - /* Set the to_tag before - * replying a response code that will create a dialog. - */ - request.to_tag = Utils.newTag(); - - // An error on dialog creation will fire 'failed' event. - if (!this._createDialog(request, 'UAS', true)) - { - request.reply(500, 'Missing Contact header field'); - - return; - } - - if (request.body) - { - this._late_sdp = false; - } - else - { - this._late_sdp = true; - } - - this._status = C.STATUS_WAITING_FOR_ANSWER; - - // Set userNoAnswerTimer. - this._timers.userNoAnswerTimer = setTimeout(() => - { - request.reply(408); - this._failed('local', null, JsSIP_C.causes.NO_ANSWER); - }, this._ua.configuration.no_answer_timeout - ); - - /* Set expiresTimer - * RFC3261 13.3.1 - */ - if (expires) - { - this._timers.expiresTimer = setTimeout(() => - { - if (this._status === C.STATUS_WAITING_FOR_ANSWER) - { - request.reply(487); - this._failed('system', null, JsSIP_C.causes.EXPIRES); - } - }, expires - ); - } - - // Set internal properties. - this._direction = 'incoming'; - this._local_identity = request.to; - this._remote_identity = request.from; - - // A init callback was specifically defined. - if (initCallback) - { - initCallback(this); - } - - // Fire 'newRTCSession' event. - this._newRTCSession('remote', request); - - // The user may have rejected the call in the 'newRTCSession' event. - if (this._status === C.STATUS_TERMINATED) - { - return; - } - - // Reply 180. - request.reply(180, null, [ `Contact: ${this._contact}` ]); - - // Fire 'progress' event. - // TODO: Document that 'response' field in 'progress' event is null for incoming calls. - this._progress('local', null); - } - - /** - * Answer the call. - */ - answer(options = {}) - { - logger.debug('answer()'); - - const request = this._request; - const extraHeaders = Utils.cloneArray(options.extraHeaders); - const mediaConstraints = Utils.cloneObject(options.mediaConstraints); - const mediaStream = options.mediaStream || null; - const pcConfig = Utils.cloneObject(options.pcConfig, { iceServers: [] }); - const rtcConstraints = options.rtcConstraints || null; - const rtcAnswerConstraints = options.rtcAnswerConstraints || null; - const rtcOfferConstraints = Utils.cloneObject(options.rtcOfferConstraints); - - let tracks; - let peerHasAudioLine = false; - let peerHasVideoLine = false; - let peerOffersFullAudio = false; - let peerOffersFullVideo = false; - - this._rtcAnswerConstraints = rtcAnswerConstraints; - this._rtcOfferConstraints = options.rtcOfferConstraints || null; - - this._data = options.data || this._data; - - // Check Session Direction and Status. - if (this._direction !== 'incoming') - { - throw new Exceptions.NotSupportedError('"answer" not supported for outgoing RTCSession'); - } - - // Check Session status. - if (this._status !== C.STATUS_WAITING_FOR_ANSWER) - { - throw new Exceptions.InvalidStateError(this._status); - } - - // Session Timers. - if (this._sessionTimers.enabled) - { - if (Utils.isDecimal(options.sessionTimersExpires)) - { - if (options.sessionTimersExpires >= JsSIP_C.MIN_SESSION_EXPIRES) - { - this._sessionTimers.defaultExpires = options.sessionTimersExpires; - } - else - { - this._sessionTimers.defaultExpires = JsSIP_C.SESSION_EXPIRES; - } - } - } - - this._status = C.STATUS_ANSWERED; - - // An error on dialog creation will fire 'failed' event. - if (!this._createDialog(request, 'UAS')) - { - request.reply(500, 'Error creating dialog'); - - return; - } - - clearTimeout(this._timers.userNoAnswerTimer); - - extraHeaders.unshift(`Contact: ${this._contact}`); - - // Determine incoming media from incoming SDP offer (if any). - const sdp = request.parseSDP(); - - // Make sure sdp.media is an array, not the case if there is only one media. - if (!Array.isArray(sdp.media)) - { - sdp.media = [ sdp.media ]; - } - - // Go through all medias in SDP to find offered capabilities to answer with. - for (const m of sdp.media) - { - if (m.type === 'audio') - { - peerHasAudioLine = true; - if (!m.direction || m.direction === 'sendrecv') - { - peerOffersFullAudio = true; - } - } - if (m.type === 'video') - { - peerHasVideoLine = true; - if (!m.direction || m.direction === 'sendrecv') - { - peerOffersFullVideo = true; - } - } - } - - // Remove audio from mediaStream if suggested by mediaConstraints. - if (mediaStream && mediaConstraints.audio === false) - { - tracks = mediaStream.getAudioTracks(); - for (const track of tracks) - { - mediaStream.removeTrack(track); - } - } - - // Remove video from mediaStream if suggested by mediaConstraints. - if (mediaStream && mediaConstraints.video === false) - { - tracks = mediaStream.getVideoTracks(); - for (const track of tracks) - { - mediaStream.removeTrack(track); - } - } - - // Set audio constraints based on incoming stream if not supplied. - if (!mediaStream && mediaConstraints.audio === undefined) - { - mediaConstraints.audio = peerOffersFullAudio; - } - - // Set video constraints based on incoming stream if not supplied. - if (!mediaStream && mediaConstraints.video === undefined) - { - mediaConstraints.video = peerOffersFullVideo; - } - - // Don't ask for audio if the incoming offer has no audio section. - if (!mediaStream && !peerHasAudioLine && !rtcOfferConstraints.offerToReceiveAudio) - { - mediaConstraints.audio = false; - } - - // Don't ask for video if the incoming offer has no video section. - if (!mediaStream && !peerHasVideoLine && !rtcOfferConstraints.offerToReceiveVideo) - { - mediaConstraints.video = false; - } - - // Create a new RTCPeerConnection instance. - // TODO: This may throw an error, should react. - this._createRTCConnection(pcConfig, rtcConstraints); - - Promise.resolve() - // Handle local MediaStream. - .then(() => - { - // A local MediaStream is given, use it. - if (mediaStream) - { - return mediaStream; - } - - // Audio and/or video requested, prompt getUserMedia. - else if (mediaConstraints.audio || mediaConstraints.video) - { - this._localMediaStreamLocallyGenerated = true; - - return navigator.mediaDevices.getUserMedia(mediaConstraints) - .catch((error) => - { - if (this._status === C.STATUS_TERMINATED) - { - throw new Error('terminated'); - } - - request.reply(480); - this._failed('local', null, JsSIP_C.causes.USER_DENIED_MEDIA_ACCESS); - - logger.warn('emit "getusermediafailed" [error:%o]', error); - - this.emit('getusermediafailed', error); - - throw new Error('getUserMedia() failed'); - }); - } - }) - // Attach MediaStream to RTCPeerconnection. - .then((stream) => - { - if (this._status === C.STATUS_TERMINATED) - { - throw new Error('terminated'); - } - - this._localMediaStream = stream; - if (stream) - { - stream.getTracks().forEach((track) => - { - this._connection.addTrack(track, stream); - }); - } - }) - // Set remote description. - .then(() => - { - if (this._late_sdp) - { - return; - } - - const e = { originator: 'remote', type: 'offer', sdp: request.body }; - - logger.debug('emit "sdp"'); - this.emit('sdp', e); - - const offer = new RTCSessionDescription({ type: 'offer', sdp: e.sdp }); - - this._connectionPromiseQueue = this._connectionPromiseQueue - .then(() => this._connection.setRemoteDescription(offer)) - .catch((error) => - { - request.reply(488); - - this._failed('system', null, JsSIP_C.causes.WEBRTC_ERROR); - - logger.warn('emit "peerconnection:setremotedescriptionfailed" [error:%o]', error); - - this.emit('peerconnection:setremotedescriptionfailed', error); - - throw new Error('peerconnection.setRemoteDescription() failed'); - }); - - return this._connectionPromiseQueue; - }) - // Create local description. - .then(() => - { - if (this._status === C.STATUS_TERMINATED) - { - throw new Error('terminated'); - } - - // TODO: Is this event already useful? - this._connecting(request); - - if (!this._late_sdp) - { - return this._createLocalDescription('answer', rtcAnswerConstraints) - .catch(() => - { - request.reply(500); - - throw new Error('_createLocalDescription() failed'); - }); - } - else - { - return this._createLocalDescription('offer', this._rtcOfferConstraints) - .catch(() => - { - request.reply(500); - - throw new Error('_createLocalDescription() failed'); - }); - } - }) - // Send reply. - .then((desc) => - { - if (this._status === C.STATUS_TERMINATED) - { - throw new Error('terminated'); - } - - this._handleSessionTimersInIncomingRequest(request, extraHeaders); - - request.reply(200, null, extraHeaders, - desc, - () => - { - this._status = C.STATUS_WAITING_FOR_ACK; - - this._setInvite2xxTimer(request, desc); - this._setACKTimer(); - this._accepted('local'); - }, - () => - { - this._failed('system', null, JsSIP_C.causes.CONNECTION_ERROR); - } - ); - }) - .catch((error) => - { - if (this._status === C.STATUS_TERMINATED) - { - return; - } - - logger.warn(`answer() failed: ${error.message}`); - - this._failed('system', error.message, JsSIP_C.causes.INTERNAL_ERROR); - }); - } - - /** - * Terminate the call. - */ - terminate(options = {}) - { - logger.debug('terminate()'); - - const cause = options.cause || JsSIP_C.causes.BYE; - const extraHeaders = Utils.cloneArray(options.extraHeaders); - const body = options.body; - - let cancel_reason; - let status_code = options.status_code; - let reason_phrase = options.reason_phrase; - - // Check Session Status. - if (this._status === C.STATUS_TERMINATED) - { - throw new Exceptions.InvalidStateError(this._status); - } - - switch (this._status) - { - // - UAC - - case C.STATUS_NULL: - case C.STATUS_INVITE_SENT: - case C.STATUS_1XX_RECEIVED: - logger.debug('canceling session'); - - if (status_code && (status_code < 200 || status_code >= 700)) - { - throw new TypeError(`Invalid status_code: ${status_code}`); - } - else if (status_code) - { - reason_phrase = reason_phrase || JsSIP_C.REASON_PHRASE[status_code] || ''; - cancel_reason = `SIP ;cause=${status_code} ;text="${reason_phrase}"`; - } - - // Check Session Status. - if (this._status === C.STATUS_NULL || this._status === C.STATUS_INVITE_SENT) - { - this._is_canceled = true; - this._cancel_reason = cancel_reason; - } - else if (this._status === C.STATUS_1XX_RECEIVED) - { - this._request.cancel(cancel_reason); - } - - this._status = C.STATUS_CANCELED; - - this._failed('local', null, JsSIP_C.causes.CANCELED); - break; - - // - UAS - - case C.STATUS_WAITING_FOR_ANSWER: - case C.STATUS_ANSWERED: - logger.debug('rejecting session'); - - status_code = status_code || 480; - - if (status_code < 300 || status_code >= 700) - { - throw new TypeError(`Invalid status_code: ${status_code}`); - } - - this._request.reply(status_code, reason_phrase, extraHeaders, body); - this._failed('local', null, JsSIP_C.causes.REJECTED); - break; - - case C.STATUS_WAITING_FOR_ACK: - case C.STATUS_CONFIRMED: - logger.debug('terminating session'); - - reason_phrase = options.reason_phrase || JsSIP_C.REASON_PHRASE[status_code] || ''; - - if (status_code && (status_code < 200 || status_code >= 700)) - { - throw new TypeError(`Invalid status_code: ${status_code}`); - } - else if (status_code) - { - extraHeaders.push(`Reason: SIP ;cause=${status_code}; text="${reason_phrase}"`); - } - - /* RFC 3261 section 15 (Terminating a session): - * - * "...the callee's UA MUST NOT send a BYE on a confirmed dialog - * until it has received an ACK for its 2xx response or until the server - * transaction times out." - */ - if (this._status === C.STATUS_WAITING_FOR_ACK && - this._direction === 'incoming' && - this._request.server_transaction.state !== Transactions.C.STATUS_TERMINATED) - { - - // Save the dialog for later restoration. - const dialog = this._dialog; - - // Send the BYE as soon as the ACK is received... - this.receiveRequest = ({ method }) => - { - if (method === JsSIP_C.ACK) - { - this.sendRequest(JsSIP_C.BYE, { - extraHeaders, - body - }); - dialog.terminate(); - } - }; - - // .., or when the INVITE transaction times out - this._request.server_transaction.on('stateChanged', () => - { - if (this._request.server_transaction.state === - Transactions.C.STATUS_TERMINATED) - { - this.sendRequest(JsSIP_C.BYE, { - extraHeaders, - body - }); - dialog.terminate(); - } - }); - - this._ended('local', null, cause); - - // Restore the dialog into 'this' in order to be able to send the in-dialog BYE :-). - this._dialog = dialog; - - // Restore the dialog into 'ua' so the ACK can reach 'this' session. - this._ua.newDialog(dialog); - } - else - { - this.sendRequest(JsSIP_C.BYE, { - extraHeaders, - body - }); - - this._ended('local', null, cause); - } - } - } - - sendDTMF(tones, options = {}) - { - logger.debug('sendDTMF() | tones: %s', tones); - - let duration = options.duration || null; - let interToneGap = options.interToneGap || null; - const transportType = options.transportType || JsSIP_C.DTMF_TRANSPORT.INFO; - - if (tones === undefined) - { - throw new TypeError('Not enough arguments'); - } - - // Check Session Status. - if ( - this._status !== C.STATUS_CONFIRMED && - this._status !== C.STATUS_WAITING_FOR_ACK && - this._status !== C.STATUS_1XX_RECEIVED - ) - { - throw new Exceptions.InvalidStateError(this._status); - } - - // Check Transport type. - if ( - transportType !== JsSIP_C.DTMF_TRANSPORT.INFO && - transportType !== JsSIP_C.DTMF_TRANSPORT.RFC2833 - ) - { - throw new TypeError(`invalid transportType: ${transportType}`); - } - - // Convert to string. - if (typeof tones === 'number') - { - tones = tones.toString(); - } - - // Check tones. - if (!tones || typeof tones !== 'string' || !tones.match(/^[0-9A-DR#*,]+$/i)) - { - throw new TypeError(`Invalid tones: ${tones}`); - } - - // Check duration. - if (duration && !Utils.isDecimal(duration)) - { - throw new TypeError(`Invalid tone duration: ${duration}`); - } - else if (!duration) - { - duration = RTCSession_DTMF.C.DEFAULT_DURATION; - } - else if (duration < RTCSession_DTMF.C.MIN_DURATION) - { - logger.debug(`"duration" value is lower than the minimum allowed, setting it to ${RTCSession_DTMF.C.MIN_DURATION} milliseconds`); - duration = RTCSession_DTMF.C.MIN_DURATION; - } - else if (duration > RTCSession_DTMF.C.MAX_DURATION) - { - logger.debug(`"duration" value is greater than the maximum allowed, setting it to ${RTCSession_DTMF.C.MAX_DURATION} milliseconds`); - duration = RTCSession_DTMF.C.MAX_DURATION; - } - else - { - duration = Math.abs(duration); - } - options.duration = duration; - - // Check interToneGap. - if (interToneGap && !Utils.isDecimal(interToneGap)) - { - throw new TypeError(`Invalid interToneGap: ${interToneGap}`); - } - else if (!interToneGap) - { - interToneGap = RTCSession_DTMF.C.DEFAULT_INTER_TONE_GAP; - } - else if (interToneGap < RTCSession_DTMF.C.MIN_INTER_TONE_GAP) - { - logger.debug(`"interToneGap" value is lower than the minimum allowed, setting it to ${RTCSession_DTMF.C.MIN_INTER_TONE_GAP} milliseconds`); - interToneGap = RTCSession_DTMF.C.MIN_INTER_TONE_GAP; - } - else - { - interToneGap = Math.abs(interToneGap); - } - - // RFC2833. Let RTCDTMFSender enqueue the DTMFs. - if (transportType === JsSIP_C.DTMF_TRANSPORT.RFC2833) - { - // Send DTMF in current audio RTP stream. - const sender = this._getDTMFRTPSender(); - - if (sender) - { - // Add remaining buffered tones. - tones = sender.toneBuffer + tones; - // Insert tones. - sender.insertDTMF(tones, duration, interToneGap); - } - - return; - } - - if (this._tones) - { - // Tones are already queued, just add to the queue. - this._tones += tones; - - return; - } - - this._tones = tones; - - // Send the first tone. - _sendDTMF.call(this); - - function _sendDTMF() - { - let timeout; - - if (this._status === C.STATUS_TERMINATED || !this._tones) - { - // Stop sending DTMF. - this._tones = null; - - return; - } - - // Retrieve the next tone. - const tone = this._tones[0]; - - // Remove the tone from this._tones. - this._tones = this._tones.substring(1); - - if (tone === ',') - { - timeout = 2000; - } - else - { - // Send DTMF via SIP INFO messages. - const dtmf = new RTCSession_DTMF(this); - - options.eventHandlers = { - onFailed : () => { this._tones = null; } - }; - dtmf.send(tone, options); - timeout = duration + interToneGap; - } - - // Set timeout for the next tone. - setTimeout(_sendDTMF.bind(this), timeout); - } - } - - sendInfo(contentType, body, options = {}) - { - logger.debug('sendInfo()'); - - // Check Session Status. - if ( - this._status !== C.STATUS_CONFIRMED && - this._status !== C.STATUS_WAITING_FOR_ACK && - this._status !== C.STATUS_1XX_RECEIVED - ) - { - throw new Exceptions.InvalidStateError(this._status); - } - - const info = new RTCSession_Info(this); - - info.send(contentType, body, options); - } - - /** - * Mute - */ - mute(options = { audio: true, video: false }) - { - logger.debug('mute()'); - - let audioMuted = false, videoMuted = false; - - if (this._audioMuted === false && options.audio) - { - audioMuted = true; - this._audioMuted = true; - this._toggleMuteAudio(true); - } - - if (this._videoMuted === false && options.video) - { - videoMuted = true; - this._videoMuted = true; - this._toggleMuteVideo(true); - } - - if (audioMuted === true || videoMuted === true) - { - this._onmute({ - audio : audioMuted, - video : videoMuted - }); - } - } - - /** - * Unmute - */ - unmute(options = { audio: true, video: true }) - { - logger.debug('unmute()'); - - let audioUnMuted = false, videoUnMuted = false; - - if (this._audioMuted === true && options.audio) - { - audioUnMuted = true; - this._audioMuted = false; - - if (this._localHold === false) - { - this._toggleMuteAudio(false); - } - } - - if (this._videoMuted === true && options.video) - { - videoUnMuted = true; - this._videoMuted = false; - - if (this._localHold === false) - { - this._toggleMuteVideo(false); - } - } - - if (audioUnMuted === true || videoUnMuted === true) - { - this._onunmute({ - audio : audioUnMuted, - video : videoUnMuted - }); - } - } - - /** - * Hold - */ - hold(options = {}, done) - { - logger.debug('hold()'); - - if (this._status !== C.STATUS_WAITING_FOR_ACK && this._status !== C.STATUS_CONFIRMED) - { - return false; - } - - if (this._localHold === true) - { - return false; - } - - if (!this.isReadyToReOffer()) - { - return false; - } - - this._localHold = true; - this._onhold('local'); - - const eventHandlers = { - succeeded : () => - { - if (done) { done(); } - }, - failed : () => - { - this.terminate({ - cause : JsSIP_C.causes.WEBRTC_ERROR, - status_code : 500, - reason_phrase : 'Hold Failed' - }); - } - }; - - if (options.useUpdate) - { - this._sendUpdate({ - sdpOffer : true, - eventHandlers, - extraHeaders : options.extraHeaders - }); - } - else - { - this._sendReinvite({ - eventHandlers, - extraHeaders : options.extraHeaders - }); - } - - return true; - } - - unhold(options = {}, done) - { - logger.debug('unhold()'); - - if (this._status !== C.STATUS_WAITING_FOR_ACK && this._status !== C.STATUS_CONFIRMED) - { - return false; - } - - if (this._localHold === false) - { - return false; - } - - if (!this.isReadyToReOffer()) - { - return false; - } - - this._localHold = false; - this._onunhold('local'); - - const eventHandlers = { - succeeded : () => - { - if (done) { done(); } - }, - failed : () => - { - this.terminate({ - cause : JsSIP_C.causes.WEBRTC_ERROR, - status_code : 500, - reason_phrase : 'Unhold Failed' - }); - } - }; - - if (options.useUpdate) - { - this._sendUpdate({ - sdpOffer : true, - eventHandlers, - extraHeaders : options.extraHeaders - }); - } - else - { - this._sendReinvite({ - eventHandlers, - extraHeaders : options.extraHeaders - }); - } - - return true; - } - - renegotiate(options = {}, done) - { - logger.debug('renegotiate()'); - - const rtcOfferConstraints = options.rtcOfferConstraints || null; - - if (this._status !== C.STATUS_WAITING_FOR_ACK && this._status !== C.STATUS_CONFIRMED) - { - return false; - } - - if (!this.isReadyToReOffer()) - { - return false; - } - - const eventHandlers = { - succeeded : () => - { - if (done) { done(); } - }, - failed : () => - { - this.terminate({ - cause : JsSIP_C.causes.WEBRTC_ERROR, - status_code : 500, - reason_phrase : 'Media Renegotiation Failed' - }); - } - }; - - this._setLocalMediaStatus(); - - if (options.useUpdate) - { - this._sendUpdate({ - sdpOffer : true, - eventHandlers, - rtcOfferConstraints, - extraHeaders : options.extraHeaders - }); - } - else - { - this._sendReinvite({ - eventHandlers, - rtcOfferConstraints, - extraHeaders : options.extraHeaders - }); - } - - return true; - } - - /** - * Refer - */ - refer(target, options) - { - logger.debug('refer()'); - - const originalTarget = target; - - if (this._status !== C.STATUS_WAITING_FOR_ACK && this._status !== C.STATUS_CONFIRMED) - { - return false; - } - - // Check target validity. - target = this._ua.normalizeTarget(target); - if (!target) - { - throw new TypeError(`Invalid target: ${originalTarget}`); - } - - const referSubscriber = new RTCSession_ReferSubscriber(this); - - referSubscriber.sendRefer(target, options); - - // Store in the map. - const id = referSubscriber.id; - - this._referSubscribers[id] = referSubscriber; - - // Listen for ending events so we can remove it from the map. - referSubscriber.on('requestFailed', () => - { - delete this._referSubscribers[id]; - }); - referSubscriber.on('accepted', () => - { - delete this._referSubscribers[id]; - }); - referSubscriber.on('failed', () => - { - delete this._referSubscribers[id]; - }); - - return referSubscriber; - } - - /** - * Send a generic in-dialog Request - */ - sendRequest(method, options) - { - logger.debug('sendRequest()'); - - if (this._dialog) - { - return this._dialog.sendRequest(method, options); - } - else - { - const dialogsArray = Object.values(this._earlyDialogs); - - if (dialogsArray.length > 0) - { - return dialogsArray[0].sendRequest(method, options); - } - - logger.warn('sendRequest() | no valid early dialog found'); - - return; - } - } - - /** - * In dialog Request Reception - */ - receiveRequest(request) - { - logger.debug('receiveRequest()'); - - if (request.method === JsSIP_C.CANCEL) - { - /* RFC3261 15 States that a UAS may have accepted an invitation while a CANCEL - * was in progress and that the UAC MAY continue with the session established by - * any 2xx response, or MAY terminate with BYE. JsSIP does continue with the - * established session. So the CANCEL is processed only if the session is not yet - * established. - */ - - /* - * Terminate the whole session in case the user didn't accept (or yet send the answer) - * nor reject the request opening the session. - */ - if (this._status === C.STATUS_WAITING_FOR_ANSWER || - this._status === C.STATUS_ANSWERED) - { - this._status = C.STATUS_CANCELED; - this._request.reply(487); - this._failed('remote', request, JsSIP_C.causes.CANCELED); - } - } - else - { - // Requests arriving here are in-dialog requests. - switch (request.method) - { - case JsSIP_C.ACK: - if (this._status !== C.STATUS_WAITING_FOR_ACK) - { - return; - } - - // Update signaling status. - this._status = C.STATUS_CONFIRMED; - - clearTimeout(this._timers.ackTimer); - clearTimeout(this._timers.invite2xxTimer); - - if (this._late_sdp) - { - if (!request.body) - { - this.terminate({ - cause : JsSIP_C.causes.MISSING_SDP, - status_code : 400 - }); - break; - } - - const e = { originator: 'remote', type: 'answer', sdp: request.body }; - - logger.debug('emit "sdp"'); - this.emit('sdp', e); - - const answer = new RTCSessionDescription({ type: 'answer', sdp: e.sdp }); - - this._connectionPromiseQueue = this._connectionPromiseQueue - .then(() => this._connection.setRemoteDescription(answer)) - .then(() => - { - if (!this._is_confirmed) - { - this._confirmed('remote', request); - } - }) - .catch((error) => - { - this.terminate({ - cause : JsSIP_C.causes.BAD_MEDIA_DESCRIPTION, - status_code : 488 - }); - - logger.warn('emit "peerconnection:setremotedescriptionfailed" [error:%o]', error); - this.emit('peerconnection:setremotedescriptionfailed', error); - }); - } - else if (!this._is_confirmed) - { - this._confirmed('remote', request); - } - - break; - case JsSIP_C.BYE: - if (this._status === C.STATUS_CONFIRMED || - this._status === C.STATUS_WAITING_FOR_ACK) - { - request.reply(200); - this._ended('remote', request, JsSIP_C.causes.BYE); - } - else if (this._status === C.STATUS_INVITE_RECEIVED || - this._status === C.STATUS_WAITING_FOR_ANSWER) - { - request.reply(200); - this._request.reply(487, 'BYE Received'); - this._ended('remote', request, JsSIP_C.causes.BYE); - } - else - { - request.reply(403, 'Wrong Status'); - } - break; - case JsSIP_C.INVITE: - if (this._status === C.STATUS_CONFIRMED) - { - if (request.hasHeader('replaces')) - { - this._receiveReplaces(request); - } - else - { - this._receiveReinvite(request); - } - } - else - { - request.reply(403, 'Wrong Status'); - } - break; - case JsSIP_C.INFO: - if (this._status === C.STATUS_1XX_RECEIVED || - this._status === C.STATUS_WAITING_FOR_ANSWER || - this._status === C.STATUS_ANSWERED || - this._status === C.STATUS_WAITING_FOR_ACK || - this._status === C.STATUS_CONFIRMED) - { - const contentType = request.hasHeader('Content-Type') ? - request.getHeader('Content-Type').toLowerCase() : undefined; - - if (contentType && (contentType.match(/^application\/dtmf-relay/i))) - { - new RTCSession_DTMF(this).init_incoming(request); - } - else if (contentType !== undefined) - { - new RTCSession_Info(this).init_incoming(request); - } - else - { - request.reply(415); - } - } - else - { - request.reply(403, 'Wrong Status'); - } - break; - case JsSIP_C.UPDATE: - if (this._status === C.STATUS_CONFIRMED) - { - this._receiveUpdate(request); - } - else - { - request.reply(403, 'Wrong Status'); - } - break; - case JsSIP_C.REFER: - if (this._status === C.STATUS_CONFIRMED) - { - this._receiveRefer(request); - } - else - { - request.reply(403, 'Wrong Status'); - } - break; - case JsSIP_C.NOTIFY: - if (this._status === C.STATUS_CONFIRMED) - { - this._receiveNotify(request); - } - else - { - request.reply(403, 'Wrong Status'); - } - break; - default: - request.reply(501); - } - } - } - - /** - * Session Callbacks - */ - - onTransportError() - { - logger.warn('onTransportError()'); - - if (this._status !== C.STATUS_TERMINATED) - { - this.terminate({ - status_code : 500, - reason_phrase : JsSIP_C.causes.CONNECTION_ERROR, - cause : JsSIP_C.causes.CONNECTION_ERROR - }); - } - } - - onRequestTimeout() - { - logger.warn('onRequestTimeout()'); - - if (this._status !== C.STATUS_TERMINATED) - { - this.terminate({ - status_code : 408, - reason_phrase : JsSIP_C.causes.REQUEST_TIMEOUT, - cause : JsSIP_C.causes.REQUEST_TIMEOUT - }); - } - } - - onDialogError() - { - logger.warn('onDialogError()'); - - if (this._status !== C.STATUS_TERMINATED) - { - this.terminate({ - status_code : 500, - reason_phrase : JsSIP_C.causes.DIALOG_ERROR, - cause : JsSIP_C.causes.DIALOG_ERROR - }); - } - } - - // Called from DTMF handler. - newDTMF(data) - { - logger.debug('newDTMF()'); - - this.emit('newDTMF', data); - } - - // Called from Info handler. - newInfo(data) - { - logger.debug('newInfo()'); - - this.emit('newInfo', data); - } - - /** - * Check if RTCSession is ready for an outgoing re-INVITE or UPDATE with SDP. - */ - isReadyToReOffer() - { - if (!this._rtcReady) - { - logger.debug('isReadyToReOffer() | internal WebRTC status not ready'); - - return false; - } - - // No established yet. - if (!this._dialog) - { - logger.debug('isReadyToReOffer() | session not established yet'); - - return false; - } - - // Another INVITE transaction is in progress. - if (this._dialog.uac_pending_reply === true || - this._dialog.uas_pending_reply === true) - { - logger.debug('isReadyToReOffer() | there is another INVITE/UPDATE transaction in progress'); - - return false; - } - - return true; - } - - _close() - { - logger.debug('close()'); - - // Close local MediaStream if it was not given by the user. - if (this._localMediaStream && this._localMediaStreamLocallyGenerated) - { - logger.debug('close() | closing local MediaStream'); - - Utils.closeMediaStream(this._localMediaStream); - } - - if (this._status === C.STATUS_TERMINATED) - { - return; - } - - this._status = C.STATUS_TERMINATED; - - // Terminate RTC. - if (this._connection) - { - try - { - this._connection.close(); - } - catch (error) - { - logger.warn('close() | error closing the RTCPeerConnection: %o', error); - } - } - - // Terminate signaling. - - // Clear SIP timers. - for (const timer in this._timers) - { - if (Object.prototype.hasOwnProperty.call(this._timers, timer)) - { - clearTimeout(this._timers[timer]); - } - } - - // Clear Session Timers. - clearTimeout(this._sessionTimers.timer); - - // Terminate confirmed dialog. - if (this._dialog) - { - this._dialog.terminate(); - delete this._dialog; - } - - // Terminate early dialogs. - for (const dialog in this._earlyDialogs) - { - if (Object.prototype.hasOwnProperty.call(this._earlyDialogs, dialog)) - { - this._earlyDialogs[dialog].terminate(); - delete this._earlyDialogs[dialog]; - } - } - - // Terminate REFER subscribers. - for (const subscriber in this._referSubscribers) - { - if (Object.prototype.hasOwnProperty.call(this._referSubscribers, subscriber)) - { - delete this._referSubscribers[subscriber]; - } - } - - this._ua.destroyRTCSession(this); - } - - /** - * Private API. - */ - - /** - * RFC3261 13.3.1.4 - * Response retransmissions cannot be accomplished by transaction layer - * since it is destroyed when receiving the first 2xx answer - */ - _setInvite2xxTimer(request, body) - { - let timeout = Timers.T1; - - function invite2xxRetransmission() - { - if (this._status !== C.STATUS_WAITING_FOR_ACK) - { - return; - } - - request.reply(200, null, [ `Contact: ${this._contact}` ], body); - - if (timeout < Timers.T2) - { - timeout = timeout * 2; - if (timeout > Timers.T2) - { - timeout = Timers.T2; - } - } - - this._timers.invite2xxTimer = setTimeout( - invite2xxRetransmission.bind(this), timeout); - } - - this._timers.invite2xxTimer = setTimeout( - invite2xxRetransmission.bind(this), timeout); - } - - - /** - * RFC3261 14.2 - * If a UAS generates a 2xx response and never receives an ACK, - * it SHOULD generate a BYE to terminate the dialog. - */ - _setACKTimer() - { - this._timers.ackTimer = setTimeout(() => - { - if (this._status === C.STATUS_WAITING_FOR_ACK) - { - logger.debug('no ACK received, terminating the session'); - - clearTimeout(this._timers.invite2xxTimer); - this.sendRequest(JsSIP_C.BYE); - this._ended('remote', null, JsSIP_C.causes.NO_ACK); - } - }, Timers.TIMER_H); - } - - - _createRTCConnection(pcConfig, rtcConstraints) - { - this._connection = new RTCPeerConnection(pcConfig, rtcConstraints); - - this._connection.addEventListener('iceconnectionstatechange', () => - { - const state = this._connection.iceConnectionState; - - // TODO: Do more with different states. - if (state === 'failed') - { - this.terminate({ - cause : JsSIP_C.causes.RTP_TIMEOUT, - status_code : 408, - reason_phrase : JsSIP_C.causes.RTP_TIMEOUT - }); - } - }); - - logger.debug('emit "peerconnection"'); - - this.emit('peerconnection', { - peerconnection : this._connection - }); - } - - _createLocalDescription(type, constraints) - { - logger.debug('createLocalDescription()'); - - if (type !== 'offer' && type !== 'answer') - throw new Error(`createLocalDescription() | invalid type "${type}"`); - - const connection = this._connection; - - this._rtcReady = false; - - return Promise.resolve() - // Create Offer or Answer. - .then(() => - { - if (type === 'offer') - { - return connection.createOffer(constraints) - .catch((error) => - { - logger.warn('emit "peerconnection:createofferfailed" [error:%o]', error); - - this.emit('peerconnection:createofferfailed', error); - - return Promise.reject(error); - }); - } - else - { - return connection.createAnswer(constraints) - .catch((error) => - { - logger.warn('emit "peerconnection:createanswerfailed" [error:%o]', error); - - this.emit('peerconnection:createanswerfailed', error); - - return Promise.reject(error); - }); - } - }) - // Set local description. - .then((desc) => - { - return connection.setLocalDescription(desc) - .catch((error) => - { - this._rtcReady = true; - - logger.warn('emit "peerconnection:setlocaldescriptionfailed" [error:%o]', error); - - this.emit('peerconnection:setlocaldescriptionfailed', error); - - return Promise.reject(error); - }); - }) - .then(() => - { - // Resolve right away if 'pc.iceGatheringState' is 'complete'. - /** - * Resolve right away if: - * - 'connection.iceGatheringState' is 'complete' and no 'iceRestart' constraint is set. - * - 'connection.iceGatheringState' is 'gathering' and 'iceReady' is true. - */ - const iceRestart = constraints && constraints.iceRestart; - - if ((connection.iceGatheringState === 'complete' && !iceRestart) || - (connection.iceGatheringState === 'gathering' && this._iceReady)) - { - this._rtcReady = true; - - const e = { originator: 'local', type: type, sdp: connection.localDescription.sdp }; - - logger.debug('emit "sdp"'); - - this.emit('sdp', e); - - return Promise.resolve(e.sdp); - } - - // Add 'pc.onicencandidate' event handler to resolve on last candidate. - return new Promise((resolve) => - { - let finished = false; - let iceCandidateListener; - let iceGatheringStateListener; - - this._iceReady = false; - - const ready = () => - { - if (finished) - { - return; - } - - connection.removeEventListener('icecandidate', iceCandidateListener); - connection.removeEventListener('icegatheringstatechange', iceGatheringStateListener); - - finished = true; - this._rtcReady = true; - - // connection.iceGatheringState will still indicate 'gathering' and thus be blocking. - this._iceReady = true; - - const e = { originator: 'local', type: type, sdp: connection.localDescription.sdp }; - - logger.debug('emit "sdp"'); - - this.emit('sdp', e); - - resolve(e.sdp); - }; - - connection.addEventListener('icecandidate', iceCandidateListener = (event) => - { - const candidate = event.candidate; - - if (candidate) - { - this.emit('icecandidate', { - candidate, - ready - }); - } - else - { - ready(); - } - }); - - connection.addEventListener('icegatheringstatechange', iceGatheringStateListener = () => - { - if (connection.iceGatheringState === 'complete') - { - ready(); - } - }); - }); - }); - } - - /** - * Dialog Management - */ - _createDialog(message, type, early) - { - const local_tag = (type === 'UAS') ? message.to_tag : message.from_tag; - const remote_tag = (type === 'UAS') ? message.from_tag : message.to_tag; - const id = message.call_id + local_tag + remote_tag; - - let early_dialog = this._earlyDialogs[id]; - - // Early Dialog. - if (early) - { - if (early_dialog) - { - return true; - } - else - { - early_dialog = new Dialog(this, message, type, Dialog.C.STATUS_EARLY); - - // Dialog has been successfully created. - if (early_dialog.error) - { - logger.debug(early_dialog.error); - this._failed('remote', message, JsSIP_C.causes.INTERNAL_ERROR); - - return false; - } - else - { - this._earlyDialogs[id] = early_dialog; - - return true; - } - } - } - - // Confirmed Dialog. - else - { - this._from_tag = message.from_tag; - this._to_tag = message.to_tag; - - // In case the dialog is in _early_ state, update it. - if (early_dialog) - { - early_dialog.update(message, type); - this._dialog = early_dialog; - delete this._earlyDialogs[id]; - - return true; - } - - // Otherwise, create a _confirmed_ dialog. - const dialog = new Dialog(this, message, type); - - if (dialog.error) - { - logger.debug(dialog.error); - this._failed('remote', message, JsSIP_C.causes.INTERNAL_ERROR); - - return false; - } - else - { - this._dialog = dialog; - - return true; - } - } - } - - /** - * In dialog INVITE Reception - */ - - _receiveReinvite(request) - { - logger.debug('receiveReinvite()'); - - const contentType = request.hasHeader('Content-Type') ? - request.getHeader('Content-Type').toLowerCase() : undefined; - const data = { - request, - callback : undefined, - reject : reject.bind(this) - }; - - let rejected = false; - - function reject(options = {}) - { - rejected = true; - - const status_code = options.status_code || 403; - const reason_phrase = options.reason_phrase || ''; - const extraHeaders = Utils.cloneArray(options.extraHeaders); - - if (this._status !== C.STATUS_CONFIRMED) - { - return false; - } - - if (status_code < 300 || status_code >= 700) - { - throw new TypeError(`Invalid status_code: ${status_code}`); - } - - request.reply(status_code, reason_phrase, extraHeaders); - } - - // Emit 'reinvite'. - this.emit('reinvite', data); - - if (rejected) - { - return; - } - - this._late_sdp = false; - - // Request without SDP. - if (!request.body) - { - this._late_sdp = true; - if (this._remoteHold) - { - this._remoteHold = false; - this._onunhold('remote'); - } - this._connectionPromiseQueue = this._connectionPromiseQueue - .then(() => this._createLocalDescription('offer', this._rtcOfferConstraints)) - .then((sdp) => - { - sendAnswer.call(this, sdp); - }) - .catch(() => - { - request.reply(500); - }); - - return; - } - - // Request with SDP. - if (contentType !== 'application/sdp') - { - logger.debug('invalid Content-Type'); - request.reply(415); - - return; - } - - this._processInDialogSdpOffer(request) - // Send answer. - .then((desc) => - { - if (this._status === C.STATUS_TERMINATED) - { - return; - } - - sendAnswer.call(this, desc); - }) - .catch((error) => - { - logger.warn(error); - }); - - function sendAnswer(desc) - { - const extraHeaders = [ `Contact: ${this._contact}` ]; - - this._handleSessionTimersInIncomingRequest(request, extraHeaders); - - if (this._late_sdp) - { - desc = this._mangleOffer(desc); - } - - request.reply(200, null, extraHeaders, desc, - () => - { - this._status = C.STATUS_WAITING_FOR_ACK; - this._setInvite2xxTimer(request, desc); - this._setACKTimer(); - } - ); - - // If callback is given execute it. - if (typeof data.callback === 'function') - { - data.callback(); - } - } - } - - /** - * In dialog UPDATE Reception - */ - _receiveUpdate(request) - { - logger.debug('receiveUpdate()'); - - const contentType = request.hasHeader('Content-Type') ? - request.getHeader('Content-Type').toLowerCase() : undefined; - const data = { - request, - callback : undefined, - reject : reject.bind(this) - }; - - let rejected = false; - - function reject(options = {}) - { - rejected = true; - - const status_code = options.status_code || 403; - const reason_phrase = options.reason_phrase || ''; - const extraHeaders = Utils.cloneArray(options.extraHeaders); - - if (this._status !== C.STATUS_CONFIRMED) - { - return false; - } - - if (status_code < 300 || status_code >= 700) - { - throw new TypeError(`Invalid status_code: ${status_code}`); - } - - request.reply(status_code, reason_phrase, extraHeaders); - } - - // Emit 'update'. - this.emit('update', data); - - if (rejected) - { - return; - } - - if (!request.body) - { - sendAnswer.call(this, null); - - return; - } - - if (contentType !== 'application/sdp') - { - logger.debug('invalid Content-Type'); - - request.reply(415); - - return; - } - - this._processInDialogSdpOffer(request) - // Send answer. - .then((desc) => - { - if (this._status === C.STATUS_TERMINATED) - { - return; - } - - sendAnswer.call(this, desc); - }) - .catch((error) => - { - logger.warn(error); - }); - - function sendAnswer(desc) - { - const extraHeaders = [ `Contact: ${this._contact}` ]; - - this._handleSessionTimersInIncomingRequest(request, extraHeaders); - - request.reply(200, null, extraHeaders, desc); - - // If callback is given execute it. - if (typeof data.callback === 'function') - { - data.callback(); - } - } - } - - _processInDialogSdpOffer(request) - { - logger.debug('_processInDialogSdpOffer()'); - - const sdp = request.parseSDP(); - - let hold = false; - - for (const m of sdp.media) - { - if (holdMediaTypes.indexOf(m.type) === -1) - { - continue; - } - - const direction = m.direction || sdp.direction || 'sendrecv'; - - if (direction === 'sendonly' || direction === 'inactive') - { - hold = true; - } - // If at least one of the streams is active don't emit 'hold'. - else - { - hold = false; - break; - } - } - - const e = { originator: 'remote', type: 'offer', sdp: request.body }; - - logger.debug('emit "sdp"'); - this.emit('sdp', e); - - const offer = new RTCSessionDescription({ type: 'offer', sdp: e.sdp }); - - this._connectionPromiseQueue = this._connectionPromiseQueue - // Set remote description. - .then(() => - { - if (this._status === C.STATUS_TERMINATED) - { - throw new Error('terminated'); - } - - return this._connection.setRemoteDescription(offer) - .catch((error) => - { - request.reply(488); - logger.warn('emit "peerconnection:setremotedescriptionfailed" [error:%o]', error); - - this.emit('peerconnection:setremotedescriptionfailed', error); - - throw error; - }); - }) - .then(() => - { - if (this._status === C.STATUS_TERMINATED) - { - throw new Error('terminated'); - } - - if (this._remoteHold === true && hold === false) - { - this._remoteHold = false; - this._onunhold('remote'); - } - else if (this._remoteHold === false && hold === true) - { - this._remoteHold = true; - this._onhold('remote'); - } - }) - // Create local description. - .then(() => - { - if (this._status === C.STATUS_TERMINATED) - { - throw new Error('terminated'); - } - - return this._createLocalDescription('answer', this._rtcAnswerConstraints) - .catch((error) => - { - request.reply(500); - logger.warn('emit "peerconnection:createtelocaldescriptionfailed" [error:%o]', error); - - throw error; - }); - }) - .catch((error) => - { - logger.warn('_processInDialogSdpOffer() failed [error: %o]', error); - }); - - return this._connectionPromiseQueue; - } - - /** - * In dialog Refer Reception - */ - _receiveRefer(request) - { - logger.debug('receiveRefer()'); - - if (!request.refer_to) - { - logger.debug('no Refer-To header field present in REFER'); - request.reply(400); - - return; - } - - if (request.refer_to.uri.scheme !== JsSIP_C.SIP) - { - logger.debug('Refer-To header field points to a non-SIP URI scheme'); - request.reply(416); - - return; - } - - // Reply before the transaction timer expires. - request.reply(202); - - const notifier = new RTCSession_ReferNotifier(this, request.cseq); - - logger.debug('emit "refer"'); - - // Emit 'refer'. - this.emit('refer', { - request, - accept : (initCallback, options) => - { - accept.call(this, initCallback, options); - }, - reject : () => - { - reject.call(this); - } - }); - - function accept(initCallback, options = {}) - { - initCallback = (typeof initCallback === 'function')? initCallback : null; - - if (this._status !== C.STATUS_WAITING_FOR_ACK && - this._status !== C.STATUS_CONFIRMED) - { - return false; - } - - const session = new RTCSession(this._ua); - - session.on('progress', ({ response }) => - { - notifier.notify(response.status_code, response.reason_phrase); - }); - - session.on('accepted', ({ response }) => - { - notifier.notify(response.status_code, response.reason_phrase); - }); - - session.on('_failed', ({ message, cause }) => - { - if (message) - { - notifier.notify(message.status_code, message.reason_phrase); - } - else - { - notifier.notify(487, cause); - } - }); - - // Consider the Replaces header present in the Refer-To URI. - if (request.refer_to.uri.hasHeader('replaces')) - { - const replaces = decodeURIComponent(request.refer_to.uri.getHeader('replaces')); - - options.extraHeaders = Utils.cloneArray(options.extraHeaders); - options.extraHeaders.push(`Replaces: ${replaces}`); - } - - session.connect(request.refer_to.uri.toAor(), options, initCallback); - } - - function reject() - { - notifier.notify(603); - } - } - - /** - * In dialog Notify Reception - */ - _receiveNotify(request) - { - logger.debug('receiveNotify()'); - - if (!request.event) - { - request.reply(400); - } - - switch (request.event.event) - { - case 'refer': { - let id; - let referSubscriber; - - if (request.event.params && request.event.params.id) - { - id = request.event.params.id; - referSubscriber = this._referSubscribers[id]; - } - else if (Object.keys(this._referSubscribers).length === 1) - { - referSubscriber = this._referSubscribers[ - Object.keys(this._referSubscribers)[0]]; - } - else - { - request.reply(400, 'Missing event id parameter'); - - return; - } - - if (!referSubscriber) - { - request.reply(481, 'Subscription does not exist'); - - return; - } - - referSubscriber.receiveNotify(request); - request.reply(200); - - break; - } - - default: { - request.reply(489); - } - } - } - - /** - * INVITE with Replaces Reception - */ - _receiveReplaces(request) - { - logger.debug('receiveReplaces()'); - - function accept(initCallback) - { - if (this._status !== C.STATUS_WAITING_FOR_ACK && - this._status !== C.STATUS_CONFIRMED) - { - return false; - } - - const session = new RTCSession(this._ua); - - // Terminate the current session when the new one is confirmed. - session.on('confirmed', () => - { - this.terminate(); - }); - - session.init_incoming(request, initCallback); - } - - function reject() - { - logger.debug('Replaced INVITE rejected by the user'); - request.reply(486); - } - - // Emit 'replace'. - this.emit('replaces', { - request, - accept : (initCallback) => { accept.call(this, initCallback); }, - reject : () => { reject.call(this); } - }); - } - - /** - * Initial Request Sender - */ - _sendInitialRequest(mediaConstraints, rtcOfferConstraints, mediaStream) - { - const request_sender = new RequestSender(this._ua, this._request, { - onRequestTimeout : () => - { - this.onRequestTimeout(); - }, - onTransportError : () => - { - this.onTransportError(); - }, - // Update the request on authentication. - onAuthenticated : (request) => - { - this._request = request; - }, - onReceiveResponse : (response) => - { - this._receiveInviteResponse(response); - } - }); - - // This Promise is resolved within the next iteration, so the app has now - // a chance to set events such as 'peerconnection' and 'connecting'. - Promise.resolve() - // Get a stream if required. - .then(() => - { - // A stream is given, let the app set events such as 'peerconnection' and 'connecting'. - if (mediaStream) - { - return mediaStream; - } - // Request for user media access. - else if (mediaConstraints.audio || mediaConstraints.video) - { - this._localMediaStreamLocallyGenerated = true; - - return navigator.mediaDevices.getUserMedia(mediaConstraints) - .catch((error) => - { - if (this._status === C.STATUS_TERMINATED) - { - throw new Error('terminated'); - } - - this._failed('local', null, JsSIP_C.causes.USER_DENIED_MEDIA_ACCESS); - - logger.warn('emit "getusermediafailed" [error:%o]', error); - - this.emit('getusermediafailed', error); - - throw error; - }); - } - }) - .then((stream) => - { - if (this._status === C.STATUS_TERMINATED) - { - throw new Error('terminated'); - } - - this._localMediaStream = stream; - - if (stream) - { - stream.getTracks().forEach((track) => - { - this._connection.addTrack(track, stream); - }); - } - - // TODO: should this be triggered here? - this._connecting(this._request); - - return this._createLocalDescription('offer', rtcOfferConstraints) - .catch((error) => - { - this._failed('local', null, JsSIP_C.causes.WEBRTC_ERROR); - - throw error; - }); - }) - .then((desc) => - { - if (this._is_canceled || this._status === C.STATUS_TERMINATED) - { - throw new Error('terminated'); - } - - this._request.body = desc; - this._status = C.STATUS_INVITE_SENT; - - logger.debug('emit "sending" [request:%o]', this._request); - - // Emit 'sending' so the app can mangle the body before the request is sent. - this.emit('sending', { - request : this._request - }); - - request_sender.send(); - }) - .catch((error) => - { - if (this._status === C.STATUS_TERMINATED) - { - return; - } - - logger.warn(error); - }); - } - - /** - * Get DTMF RTCRtpSender. - */ - _getDTMFRTPSender() - { - const sender = this._connection.getSenders().find((rtpSender) => - { - return rtpSender.track && rtpSender.track.kind === 'audio'; - }); - - if (!(sender && sender.dtmf)) - { - logger.warn('sendDTMF() | no local audio track to send DTMF with'); - - return; - } - - return sender.dtmf; - } - - /** - * Reception of Response for Initial INVITE - */ - _receiveInviteResponse(response) - { - logger.debug('receiveInviteResponse()'); - - // Handle 2XX retransmissions and responses from forked requests. - if (this._dialog && (response.status_code >=200 && response.status_code <=299)) - { - - /* - * If it is a retransmission from the endpoint that established - * the dialog, send an ACK - */ - if (this._dialog.id.call_id === response.call_id && - this._dialog.id.local_tag === response.from_tag && - this._dialog.id.remote_tag === response.to_tag) - { - this.sendRequest(JsSIP_C.ACK); - - return; - } - - // If not, send an ACK and terminate. - else - { - const dialog = new Dialog(this, response, 'UAC'); - - if (dialog.error !== undefined) - { - logger.debug(dialog.error); - - return; - } - - this.sendRequest(JsSIP_C.ACK); - this.sendRequest(JsSIP_C.BYE); - - return; - } - - } - - // Proceed to cancellation if the user requested. - if (this._is_canceled) - { - if (response.status_code >= 100 && response.status_code < 200) - { - this._request.cancel(this._cancel_reason); - } - else if (response.status_code >= 200 && response.status_code < 299) - { - this._acceptAndTerminate(response); - } - - return; - } - - if (this._status !== C.STATUS_INVITE_SENT && this._status !== C.STATUS_1XX_RECEIVED) - { - return; - } - - switch (true) - { - case /^100$/.test(response.status_code): - this._status = C.STATUS_1XX_RECEIVED; - break; - - case /^1[0-9]{2}$/.test(response.status_code): - { - // Do nothing with 1xx responses without To tag. - if (!response.to_tag) - { - logger.debug('1xx response received without to tag'); - break; - } - - // Create Early Dialog if 1XX comes with contact. - if (response.hasHeader('contact')) - { - // An error on dialog creation will fire 'failed' event. - if (!this._createDialog(response, 'UAC', true)) - { - break; - } - } - - this._status = C.STATUS_1XX_RECEIVED; - - if (!response.body) - { - this._progress('remote', response); - break; - } - - const e = { originator: 'remote', type: 'answer', sdp: response.body }; - - logger.debug('emit "sdp"'); - this.emit('sdp', e); - - const answer = new RTCSessionDescription({ type: 'answer', sdp: e.sdp }); - - this._connectionPromiseQueue = this._connectionPromiseQueue - .then(() => this._connection.setRemoteDescription(answer)) - .then(() => this._progress('remote', response)) - .catch((error) => - { - logger.warn('emit "peerconnection:setremotedescriptionfailed" [error:%o]', error); - - this.emit('peerconnection:setremotedescriptionfailed', error); - }); - break; - } - - case /^2[0-9]{2}$/.test(response.status_code): - { - this._status = C.STATUS_CONFIRMED; - - if (!response.body) - { - this._acceptAndTerminate(response, 400, JsSIP_C.causes.MISSING_SDP); - this._failed('remote', response, JsSIP_C.causes.BAD_MEDIA_DESCRIPTION); - break; - } - - // An error on dialog creation will fire 'failed' event. - if (!this._createDialog(response, 'UAC')) - { - break; - } - - const e = { originator: 'remote', type: 'answer', sdp: response.body }; - - logger.debug('emit "sdp"'); - this.emit('sdp', e); - - const answer = new RTCSessionDescription({ type: 'answer', sdp: e.sdp }); - - this._connectionPromiseQueue = this._connectionPromiseQueue - .then(() => - { - // Be ready for 200 with SDP after a 180/183 with SDP. - // We created a SDP 'answer' for it, so check the current signaling state. - if (this._connection.signalingState === 'stable') - { - return this._connection.createOffer(this._rtcOfferConstraints) - .then((offer) => this._connection.setLocalDescription(offer)) - .catch((error) => - { - this._acceptAndTerminate(response, 500, error.toString()); - this._failed('local', response, JsSIP_C.causes.WEBRTC_ERROR); - }); - } - }) - .then(() => - { - this._connection.setRemoteDescription(answer) - .then(() => - { - // Handle Session Timers. - this._handleSessionTimersInIncomingResponse(response); - - this._accepted('remote', response); - this.sendRequest(JsSIP_C.ACK); - this._confirmed('local', null); - }) - .catch((error) => - { - this._acceptAndTerminate(response, 488, 'Not Acceptable Here'); - this._failed('remote', response, JsSIP_C.causes.BAD_MEDIA_DESCRIPTION); - - logger.warn('emit "peerconnection:setremotedescriptionfailed" [error:%o]', error); - - this.emit('peerconnection:setremotedescriptionfailed', error); - }); - }); - break; - } - - default: - { - const cause = Utils.sipErrorCause(response.status_code); - - this._failed('remote', response, cause); - } - } - } - - /** - * Send Re-INVITE - */ - _sendReinvite(options = {}) - { - logger.debug('sendReinvite()'); - - const extraHeaders = Utils.cloneArray(options.extraHeaders); - const eventHandlers = Utils.cloneObject(options.eventHandlers); - const rtcOfferConstraints = options.rtcOfferConstraints || - this._rtcOfferConstraints || null; - - let succeeded = false; - - extraHeaders.push(`Contact: ${this._contact}`); - extraHeaders.push('Content-Type: application/sdp'); - - // Session Timers. - if (this._sessionTimers.running) - { - extraHeaders.push(`Session-Expires: ${this._sessionTimers.currentExpires};refresher=${this._sessionTimers.refresher ? 'uac' : 'uas'}`); - } - - this._connectionPromiseQueue = this._connectionPromiseQueue - .then(() => this._createLocalDescription('offer', rtcOfferConstraints)) - .then((sdp) => - { - sdp = this._mangleOffer(sdp); - - const e = { originator: 'local', type: 'offer', sdp }; - - logger.debug('emit "sdp"'); - this.emit('sdp', e); - - this.sendRequest(JsSIP_C.INVITE, { - extraHeaders, - body : sdp, - eventHandlers : { - onSuccessResponse : (response) => - { - onSucceeded.call(this, response); - succeeded = true; - }, - onErrorResponse : (response) => - { - onFailed.call(this, response); - }, - onTransportError : () => - { - this.onTransportError(); // Do nothing because session ends. - }, - onRequestTimeout : () => - { - this.onRequestTimeout(); // Do nothing because session ends. - }, - onDialogError : () => - { - this.onDialogError(); // Do nothing because session ends. - } - } - }); - }) - .catch(() => - { - onFailed(); - }); - - function onSucceeded(response) - { - if (this._status === C.STATUS_TERMINATED) - { - return; - } - - this.sendRequest(JsSIP_C.ACK); - - // If it is a 2XX retransmission exit now. - if (succeeded) { return; } - - // Handle Session Timers. - this._handleSessionTimersInIncomingResponse(response); - - // Must have SDP answer. - if (!response.body) - { - onFailed.call(this); - - return; - } - else if (!response.hasHeader('Content-Type') || response.getHeader('Content-Type').toLowerCase() !== 'application/sdp') - { - onFailed.call(this); - - return; - } - - const e = { originator: 'remote', type: 'answer', sdp: response.body }; - - logger.debug('emit "sdp"'); - this.emit('sdp', e); - - const answer = new RTCSessionDescription({ type: 'answer', sdp: e.sdp }); - - this._connectionPromiseQueue = this._connectionPromiseQueue - .then(() => this._connection.setRemoteDescription(answer)) - .then(() => - { - if (eventHandlers.succeeded) - { - eventHandlers.succeeded(response); - } - }) - .catch((error) => - { - onFailed.call(this); - - logger.warn('emit "peerconnection:setremotedescriptionfailed" [error:%o]', error); - - this.emit('peerconnection:setremotedescriptionfailed', error); - }); - } - - function onFailed(response) - { - if (eventHandlers.failed) - { - eventHandlers.failed(response); - } - } - } - - /** - * Send UPDATE - */ - _sendUpdate(options = {}) - { - logger.debug('sendUpdate()'); - - const extraHeaders = Utils.cloneArray(options.extraHeaders); - const eventHandlers = Utils.cloneObject(options.eventHandlers); - const rtcOfferConstraints = options.rtcOfferConstraints || - this._rtcOfferConstraints || null; - const sdpOffer = options.sdpOffer || false; - - let succeeded = false; - - extraHeaders.push(`Contact: ${this._contact}`); - - // Session Timers. - if (this._sessionTimers.running) - { - extraHeaders.push(`Session-Expires: ${this._sessionTimers.currentExpires};refresher=${this._sessionTimers.refresher ? 'uac' : 'uas'}`); - } - - if (sdpOffer) - { - extraHeaders.push('Content-Type: application/sdp'); - - this._connectionPromiseQueue = this._connectionPromiseQueue - .then(() => this._createLocalDescription('offer', rtcOfferConstraints)) - .then((sdp) => - { - sdp = this._mangleOffer(sdp); - - const e = { originator: 'local', type: 'offer', sdp }; - - logger.debug('emit "sdp"'); - this.emit('sdp', e); - - this.sendRequest(JsSIP_C.UPDATE, { - extraHeaders, - body : sdp, - eventHandlers : { - onSuccessResponse : (response) => - { - onSucceeded.call(this, response); - succeeded = true; - }, - onErrorResponse : (response) => - { - onFailed.call(this, response); - }, - onTransportError : () => - { - this.onTransportError(); // Do nothing because session ends. - }, - onRequestTimeout : () => - { - this.onRequestTimeout(); // Do nothing because session ends. - }, - onDialogError : () => - { - this.onDialogError(); // Do nothing because session ends. - } - } - }); - }) - .catch(() => - { - onFailed.call(this); - }); - } - - // No SDP. - else - { - this.sendRequest(JsSIP_C.UPDATE, { - extraHeaders, - eventHandlers : { - onSuccessResponse : (response) => - { - onSucceeded.call(this, response); - }, - onErrorResponse : (response) => - { - onFailed.call(this, response); - }, - onTransportError : () => - { - this.onTransportError(); // Do nothing because session ends. - }, - onRequestTimeout : () => - { - this.onRequestTimeout(); // Do nothing because session ends. - }, - onDialogError : () => - { - this.onDialogError(); // Do nothing because session ends. - } - } - }); - } - - function onSucceeded(response) - { - if (this._status === C.STATUS_TERMINATED) - { - return; - } - - // If it is a 2XX retransmission exit now. - if (succeeded) { return; } - - // Handle Session Timers. - this._handleSessionTimersInIncomingResponse(response); - - // Must have SDP answer. - if (sdpOffer) - { - if (!response.body) - { - onFailed.call(this); - - return; - } - else if (!response.hasHeader('Content-Type') || response.getHeader('Content-Type').toLowerCase() !== 'application/sdp') - { - onFailed.call(this); - - return; - } - - const e = { originator: 'remote', type: 'answer', sdp: response.body }; - - logger.debug('emit "sdp"'); - this.emit('sdp', e); - - const answer = new RTCSessionDescription({ type: 'answer', sdp: e.sdp }); - - this._connectionPromiseQueue = this._connectionPromiseQueue - .then(() => this._connection.setRemoteDescription(answer)) - .then(() => - { - if (eventHandlers.succeeded) - { - eventHandlers.succeeded(response); - } - }) - .catch((error) => - { - onFailed.call(this); - - logger.warn('emit "peerconnection:setremotedescriptionfailed" [error:%o]', error); - - this.emit('peerconnection:setremotedescriptionfailed', error); - }); - } - // No SDP answer. - else if (eventHandlers.succeeded) - { - eventHandlers.succeeded(response); - } - } - - function onFailed(response) - { - if (eventHandlers.failed) { eventHandlers.failed(response); } - } - } - - _acceptAndTerminate(response, status_code, reason_phrase) - { - logger.debug('acceptAndTerminate()'); - - const extraHeaders = []; - - if (status_code) - { - reason_phrase = reason_phrase || JsSIP_C.REASON_PHRASE[status_code] || ''; - extraHeaders.push(`Reason: SIP ;cause=${status_code}; text="${reason_phrase}"`); - } - - // An error on dialog creation will fire 'failed' event. - if (this._dialog || this._createDialog(response, 'UAC')) - { - this.sendRequest(JsSIP_C.ACK); - this.sendRequest(JsSIP_C.BYE, { - extraHeaders - }); - } - - // Update session status. - this._status = C.STATUS_TERMINATED; - } - - /** - * Correctly set the SDP direction attributes if the call is on local hold - */ - _mangleOffer(sdp) - { - - if (!this._localHold && !this._remoteHold) - { - return sdp; - } - - sdp = sdp_transform.parse(sdp); - - // Local hold. - if (this._localHold && !this._remoteHold) - { - logger.debug('mangleOffer() | me on hold, mangling offer'); - for (const m of sdp.media) - { - if (holdMediaTypes.indexOf(m.type) === -1) - { - continue; - } - if (!m.direction) - { - m.direction = 'sendonly'; - } - else if (m.direction === 'sendrecv') - { - m.direction = 'sendonly'; - } - else if (m.direction === 'recvonly') - { - m.direction = 'inactive'; - } - } - } - // Local and remote hold. - else if (this._localHold && this._remoteHold) - { - logger.debug('mangleOffer() | both on hold, mangling offer'); - for (const m of sdp.media) - { - if (holdMediaTypes.indexOf(m.type) === -1) - { - continue; - } - m.direction = 'inactive'; - } - } - // Remote hold. - else if (this._remoteHold) - { - logger.debug('mangleOffer() | remote on hold, mangling offer'); - for (const m of sdp.media) - { - if (holdMediaTypes.indexOf(m.type) === -1) - { - continue; - } - if (!m.direction) - { - m.direction = 'recvonly'; - } - else if (m.direction === 'sendrecv') - { - m.direction = 'recvonly'; - } - else if (m.direction === 'recvonly') - { - m.direction = 'inactive'; - } - } - } - - return sdp_transform.write(sdp); - } - - _setLocalMediaStatus() - { - let enableAudio = true, enableVideo = true; - - if (this._localHold || this._remoteHold) - { - enableAudio = false; - enableVideo = false; - } - - if (this._audioMuted) - { - enableAudio = false; - } - - if (this._videoMuted) - { - enableVideo = false; - } - - this._toggleMuteAudio(!enableAudio); - this._toggleMuteVideo(!enableVideo); - } - - /** - * Handle SessionTimers for an incoming INVITE or UPDATE. - * @param {IncomingRequest} request - * @param {Array} responseExtraHeaders Extra headers for the 200 response. - */ - _handleSessionTimersInIncomingRequest(request, responseExtraHeaders) - { - if (!this._sessionTimers.enabled) { return; } - - let session_expires_refresher; - - if (request.session_expires && request.session_expires >= JsSIP_C.MIN_SESSION_EXPIRES) - { - this._sessionTimers.currentExpires = request.session_expires; - session_expires_refresher = request.session_expires_refresher || 'uas'; - } - else - { - this._sessionTimers.currentExpires = this._sessionTimers.defaultExpires; - session_expires_refresher = 'uas'; - } - - responseExtraHeaders.push(`Session-Expires: ${this._sessionTimers.currentExpires};refresher=${session_expires_refresher}`); - - this._sessionTimers.refresher = (session_expires_refresher === 'uas'); - this._runSessionTimer(); - } - - /** - * Handle SessionTimers for an incoming response to INVITE or UPDATE. - * @param {IncomingResponse} response - */ - _handleSessionTimersInIncomingResponse(response) - { - if (!this._sessionTimers.enabled) { return; } - - let session_expires_refresher; - - if (response.session_expires && - response.session_expires >= JsSIP_C.MIN_SESSION_EXPIRES) - { - this._sessionTimers.currentExpires = response.session_expires; - session_expires_refresher = response.session_expires_refresher || 'uac'; - } - else - { - this._sessionTimers.currentExpires = this._sessionTimers.defaultExpires; - session_expires_refresher = 'uac'; - } - - this._sessionTimers.refresher = (session_expires_refresher === 'uac'); - this._runSessionTimer(); - } - - _runSessionTimer() - { - const expires = this._sessionTimers.currentExpires; - - this._sessionTimers.running = true; - - clearTimeout(this._sessionTimers.timer); - - // I'm the refresher. - if (this._sessionTimers.refresher) - { - this._sessionTimers.timer = setTimeout(() => - { - if (this._status === C.STATUS_TERMINATED) { return; } - - if (!this.isReadyToReOffer()) { return; } - - logger.debug('runSessionTimer() | sending session refresh request'); - - if (this._sessionTimers.refreshMethod === JsSIP_C.UPDATE) - { - this._sendUpdate(); - } - else - { - this._sendReinvite(); - } - }, expires * 500); // Half the given interval (as the RFC states). - } - - // I'm not the refresher. - else - { - this._sessionTimers.timer = setTimeout(() => - { - if (this._status === C.STATUS_TERMINATED) { return; } - - logger.warn('runSessionTimer() | timer expired, terminating the session'); - - this.terminate({ - cause : JsSIP_C.causes.REQUEST_TIMEOUT, - status_code : 408, - reason_phrase : 'Session Timer Expired' - }); - }, expires * 1100); - } - } - - _toggleMuteAudio(mute) - { - const senders = this._connection.getSenders().filter((sender) => - { - return sender.track && sender.track.kind === 'audio'; - }); - - for (const sender of senders) - { - sender.track.enabled = !mute; - } - } - - _toggleMuteVideo(mute) - { - const senders = this._connection.getSenders().filter((sender) => - { - return sender.track && sender.track.kind === 'video'; - }); - - for (const sender of senders) - { - sender.track.enabled = !mute; - } - } - - _newRTCSession(originator, request) - { - logger.debug('newRTCSession()'); - - this._ua.newRTCSession(this, { - originator, - session : this, - request - }); - } - - _connecting(request) - { - logger.debug('session connecting'); - - logger.debug('emit "connecting"'); - - this.emit('connecting', { - request - }); - } - - _progress(originator, response) - { - logger.debug('session progress'); - - logger.debug('emit "progress"'); - - this.emit('progress', { - originator, - response : response || null - }); - } - - _accepted(originator, message) - { - logger.debug('session accepted'); - - this._start_time = new Date(); - - logger.debug('emit "accepted"'); - - this.emit('accepted', { - originator, - response : message || null - }); - } - - _confirmed(originator, ack) - { - logger.debug('session confirmed'); +const holdMediaTypes = ['audio', 'video']; + +module.exports = class RTCSession extends EventEmitter { + /** + * Expose C object. + */ + static get C() { + return C; + } + + constructor(ua) { + logger.debug('new'); + + super(); + + this._id = null; + this._ua = ua; + this._status = C.STATUS_NULL; + this._dialog = null; + this._earlyDialogs = {}; + this._contact = null; + this._from_tag = null; + this._to_tag = null; + + // The RTCPeerConnection instance (public attribute). + this._connection = null; + + // Prevent races on serial PeerConnction operations. + this._connectionPromiseQueue = Promise.resolve(); + + // Incoming/Outgoing request being currently processed. + this._request = null; + + // Cancel state for initial outgoing request. + this._is_canceled = false; + this._cancel_reason = ''; + + // RTCSession confirmation flag. + this._is_confirmed = false; + + // Is late SDP being negotiated. + this._late_sdp = false; + + // Default rtcOfferConstraints and rtcAnswerConstrainsts (passed in connect() or answer()). + this._rtcOfferConstraints = null; + this._rtcAnswerConstraints = null; + + // Local MediaStream. + this._localMediaStream = null; + this._localMediaStreamLocallyGenerated = false; + + // Flag to indicate PeerConnection ready for new actions. + this._rtcReady = true; + + // Flag to indicate ICE candidate gathering is finished even if iceGatheringState is not yet 'complete'. + this._iceReady = false; + + // SIP Timers. + this._timers = { + ackTimer: null, + expiresTimer: null, + invite2xxTimer: null, + userNoAnswerTimer: null, + }; + + // Session info. + this._direction = null; + this._local_identity = null; + this._remote_identity = null; + this._start_time = null; + this._end_time = null; + this._tones = null; + + // Mute/Hold state. + this._audioMuted = false; + this._videoMuted = false; + this._localHold = false; + this._remoteHold = false; + + // Session Timers (RFC 4028). + this._sessionTimers = { + enabled: this._ua.configuration.session_timers, + refreshMethod: this._ua.configuration.session_timers_refresh_method, + defaultExpires: JsSIP_C.SESSION_EXPIRES, + currentExpires: null, + running: false, + refresher: false, + timer: null, // A setTimeout. + }; + + // Map of ReferSubscriber instances indexed by the REFER's CSeq number. + this._referSubscribers = {}; + + // Custom session empty object for high level use. + this._data = {}; + } + + /** + * User API + */ + + // Expose RTCSession constants as a property of the RTCSession instance. + get C() { + return C; + } + + // Expose session failed/ended causes as a property of the RTCSession instance. + get causes() { + return JsSIP_C.causes; + } + + get id() { + return this._id; + } + + get connection() { + return this._connection; + } + + get contact() { + return this._contact; + } + + get direction() { + return this._direction; + } + + get local_identity() { + return this._local_identity; + } + + get remote_identity() { + return this._remote_identity; + } + + get start_time() { + return this._start_time; + } + + get end_time() { + return this._end_time; + } + + get data() { + return this._data; + } + + set data(_data) { + this._data = _data; + } + + get status() { + return this._status; + } + + isInProgress() { + switch (this._status) { + case C.STATUS_NULL: + case C.STATUS_INVITE_SENT: + case C.STATUS_1XX_RECEIVED: + case C.STATUS_INVITE_RECEIVED: + case C.STATUS_WAITING_FOR_ANSWER: { + return true; + } + default: { + return false; + } + } + } + + isEstablished() { + switch (this._status) { + case C.STATUS_ANSWERED: + case C.STATUS_WAITING_FOR_ACK: + case C.STATUS_CONFIRMED: { + return true; + } + default: { + return false; + } + } + } + + isEnded() { + switch (this._status) { + case C.STATUS_CANCELED: + case C.STATUS_TERMINATED: { + return true; + } + default: { + return false; + } + } + } + + isMuted() { + return { + audio: this._audioMuted, + video: this._videoMuted, + }; + } + + isOnHold() { + return { + local: this._localHold, + remote: this._remoteHold, + }; + } + + connect(target, options = {}, initCallback) { + logger.debug('connect()'); + + const originalTarget = target; + const eventHandlers = Utils.cloneObject(options.eventHandlers); + const extraHeaders = Utils.cloneArray(options.extraHeaders); + const mediaConstraints = Utils.cloneObject(options.mediaConstraints, { + audio: true, + video: true, + }); + const mediaStream = options.mediaStream || null; + const pcConfig = Utils.cloneObject(options.pcConfig, { iceServers: [] }); + const rtcConstraints = options.rtcConstraints || null; + const rtcOfferConstraints = options.rtcOfferConstraints || null; + + this._rtcOfferConstraints = rtcOfferConstraints; + this._rtcAnswerConstraints = options.rtcAnswerConstraints || null; + + this._data = options.data || this._data; + + // Check target. + if (target === undefined) { + throw new TypeError('Not enough arguments'); + } + + // Check Session Status. + if (this._status !== C.STATUS_NULL) { + throw new Exceptions.InvalidStateError(this._status); + } + + // Check WebRTC support. + // eslint-disable-next-line no-undef + if (!window.RTCPeerConnection) { + throw new Exceptions.NotSupportedError('WebRTC not supported'); + } + + // Check target validity. + target = this._ua.normalizeTarget(target); + if (!target) { + throw new TypeError(`Invalid target: ${originalTarget}`); + } + + // Session Timers. + if (this._sessionTimers.enabled) { + if (Utils.isDecimal(options.sessionTimersExpires)) { + if (options.sessionTimersExpires >= JsSIP_C.MIN_SESSION_EXPIRES) { + this._sessionTimers.defaultExpires = options.sessionTimersExpires; + } else { + this._sessionTimers.defaultExpires = JsSIP_C.SESSION_EXPIRES; + } + } + } + + // Set event handlers. + for (const event in eventHandlers) { + if (Object.prototype.hasOwnProperty.call(eventHandlers, event)) { + this.on(event, eventHandlers[event]); + } + } + + // Session parameter initialization. + this._from_tag = Utils.newTag(); + + // Set anonymous property. + const anonymous = options.anonymous || false; + + const requestParams = { from_tag: this._from_tag }; + + this._contact = this._ua.contact.toString({ + anonymous, + outbound: true, + }); + + if (anonymous) { + requestParams.from_display_name = 'Anonymous'; + requestParams.from_uri = new URI('sip', 'anonymous', 'anonymous.invalid'); + + extraHeaders.push( + `P-Preferred-Identity: ${this._ua.configuration.uri.toString()}` + ); + extraHeaders.push('Privacy: id'); + } else if (options.fromUserName) { + requestParams.from_uri = new URI( + 'sip', + options.fromUserName, + this._ua.configuration.uri.host + ); + + extraHeaders.push( + `P-Preferred-Identity: ${this._ua.configuration.uri.toString()}` + ); + } + + if (options.fromDisplayName) { + requestParams.from_display_name = options.fromDisplayName; + } + + extraHeaders.push(`Contact: ${this._contact}`); + extraHeaders.push('Content-Type: application/sdp'); + if (this._sessionTimers.enabled) { + extraHeaders.push( + `Session-Expires: ${this._sessionTimers.defaultExpires}${this._ua.configuration.session_timers_force_refresher ? ';refresher=uac' : ''}` + ); + } + + this._request = new SIPMessage.InitialOutgoingInviteRequest( + target, + this._ua, + requestParams, + extraHeaders + ); + + this._id = this._request.call_id + this._from_tag; + + // Create a new RTCPeerConnection instance. + this._createRTCConnection(pcConfig, rtcConstraints); + + // Set internal properties. + this._direction = 'outgoing'; + this._local_identity = this._request.from; + this._remote_identity = this._request.to; + + // User explicitly provided a newRTCSession callback for this session. + if (initCallback) { + initCallback(this); + } + + this._newRTCSession('local', this._request); + + this._sendInitialRequest( + mediaConstraints, + rtcOfferConstraints, + mediaStream + ); + } + + init_incoming(request, initCallback) { + logger.debug('init_incoming()'); + + let expires; + const contentType = request.hasHeader('Content-Type') + ? request.getHeader('Content-Type').toLowerCase() + : undefined; + + // Check body and content type. + if (request.body && contentType !== 'application/sdp') { + request.reply(415); + + return; + } + + // Session parameter initialization. + this._status = C.STATUS_INVITE_RECEIVED; + this._from_tag = request.from_tag; + this._id = request.call_id + this._from_tag; + this._request = request; + this._contact = this._ua.contact.toString(); + + // Get the Expires header value if exists. + if (request.hasHeader('expires')) { + expires = request.getHeader('expires') * 1000; + } + + /* Set the to_tag before + * replying a response code that will create a dialog. + */ + request.to_tag = Utils.newTag(); + + // An error on dialog creation will fire 'failed' event. + if (!this._createDialog(request, 'UAS', true)) { + request.reply(500, 'Missing Contact header field'); + + return; + } + + if (request.body) { + this._late_sdp = false; + } else { + this._late_sdp = true; + } + + this._status = C.STATUS_WAITING_FOR_ANSWER; + + // Set userNoAnswerTimer. + this._timers.userNoAnswerTimer = setTimeout(() => { + request.reply(408); + this._failed('local', null, JsSIP_C.causes.NO_ANSWER); + }, this._ua.configuration.no_answer_timeout); + + /* Set expiresTimer + * RFC3261 13.3.1 + */ + if (expires) { + this._timers.expiresTimer = setTimeout(() => { + if (this._status === C.STATUS_WAITING_FOR_ANSWER) { + request.reply(487); + this._failed('system', null, JsSIP_C.causes.EXPIRES); + } + }, expires); + } + + // Set internal properties. + this._direction = 'incoming'; + this._local_identity = request.to; + this._remote_identity = request.from; + + // A init callback was specifically defined. + if (initCallback) { + initCallback(this); + } + + // Fire 'newRTCSession' event. + this._newRTCSession('remote', request); + + // The user may have rejected the call in the 'newRTCSession' event. + if (this._status === C.STATUS_TERMINATED) { + return; + } + + // Reply 180. + request.reply(180, null, [`Contact: ${this._contact}`]); + + // Fire 'progress' event. + // TODO: Document that 'response' field in 'progress' event is null for incoming calls. + this._progress('local', null); + } + + /** + * Answer the call. + */ + answer(options = {}) { + logger.debug('answer()'); + + const request = this._request; + const extraHeaders = Utils.cloneArray(options.extraHeaders); + const mediaConstraints = Utils.cloneObject(options.mediaConstraints); + const mediaStream = options.mediaStream || null; + const pcConfig = Utils.cloneObject(options.pcConfig, { iceServers: [] }); + const rtcConstraints = options.rtcConstraints || null; + const rtcAnswerConstraints = options.rtcAnswerConstraints || null; + const rtcOfferConstraints = Utils.cloneObject(options.rtcOfferConstraints); + + let tracks; + let peerHasAudioLine = false; + let peerHasVideoLine = false; + let peerOffersFullAudio = false; + let peerOffersFullVideo = false; + + this._rtcAnswerConstraints = rtcAnswerConstraints; + this._rtcOfferConstraints = options.rtcOfferConstraints || null; + + this._data = options.data || this._data; + + // Check Session Direction and Status. + if (this._direction !== 'incoming') { + throw new Exceptions.NotSupportedError( + '"answer" not supported for outgoing RTCSession' + ); + } + + // Check Session status. + if (this._status !== C.STATUS_WAITING_FOR_ANSWER) { + throw new Exceptions.InvalidStateError(this._status); + } + + // Session Timers. + if (this._sessionTimers.enabled) { + if (Utils.isDecimal(options.sessionTimersExpires)) { + if (options.sessionTimersExpires >= JsSIP_C.MIN_SESSION_EXPIRES) { + this._sessionTimers.defaultExpires = options.sessionTimersExpires; + } else { + this._sessionTimers.defaultExpires = JsSIP_C.SESSION_EXPIRES; + } + } + } + + this._status = C.STATUS_ANSWERED; + + // An error on dialog creation will fire 'failed' event. + if (!this._createDialog(request, 'UAS')) { + request.reply(500, 'Error creating dialog'); + + return; + } + + clearTimeout(this._timers.userNoAnswerTimer); + + extraHeaders.unshift(`Contact: ${this._contact}`); + + // Determine incoming media from incoming SDP offer (if any). + const sdp = request.parseSDP(); + + // Make sure sdp.media is an array, not the case if there is only one media. + if (!Array.isArray(sdp.media)) { + sdp.media = [sdp.media]; + } + + // Go through all medias in SDP to find offered capabilities to answer with. + for (const m of sdp.media) { + if (m.type === 'audio') { + peerHasAudioLine = true; + if (!m.direction || m.direction === 'sendrecv') { + peerOffersFullAudio = true; + } + } + if (m.type === 'video') { + peerHasVideoLine = true; + if (!m.direction || m.direction === 'sendrecv') { + peerOffersFullVideo = true; + } + } + } + + // Remove audio from mediaStream if suggested by mediaConstraints. + if (mediaStream && mediaConstraints.audio === false) { + tracks = mediaStream.getAudioTracks(); + for (const track of tracks) { + mediaStream.removeTrack(track); + } + } + + // Remove video from mediaStream if suggested by mediaConstraints. + if (mediaStream && mediaConstraints.video === false) { + tracks = mediaStream.getVideoTracks(); + for (const track of tracks) { + mediaStream.removeTrack(track); + } + } + + // Set audio constraints based on incoming stream if not supplied. + if (!mediaStream && mediaConstraints.audio === undefined) { + mediaConstraints.audio = peerOffersFullAudio; + } + + // Set video constraints based on incoming stream if not supplied. + if (!mediaStream && mediaConstraints.video === undefined) { + mediaConstraints.video = peerOffersFullVideo; + } + + // Don't ask for audio if the incoming offer has no audio section. + if ( + !mediaStream && + !peerHasAudioLine && + !rtcOfferConstraints.offerToReceiveAudio + ) { + mediaConstraints.audio = false; + } + + // Don't ask for video if the incoming offer has no video section. + if ( + !mediaStream && + !peerHasVideoLine && + !rtcOfferConstraints.offerToReceiveVideo + ) { + mediaConstraints.video = false; + } + + // Create a new RTCPeerConnection instance. + // TODO: This may throw an error, should react. + this._createRTCConnection(pcConfig, rtcConstraints); + + Promise.resolve() + // Handle local MediaStream. + .then(() => { + // A local MediaStream is given, use it. + if (mediaStream) { + return mediaStream; + } + + // Audio and/or video requested, prompt getUserMedia. + else if (mediaConstraints.audio || mediaConstraints.video) { + this._localMediaStreamLocallyGenerated = true; + + return navigator.mediaDevices + .getUserMedia(mediaConstraints) + .catch(error => { + if (this._status === C.STATUS_TERMINATED) { + throw new Error('terminated'); + } + + request.reply(480); + this._failed( + 'local', + null, + JsSIP_C.causes.USER_DENIED_MEDIA_ACCESS + ); + + logger.warn('emit "getusermediafailed" [error:%o]', error); + + this.emit('getusermediafailed', error); + + throw new Error('getUserMedia() failed'); + }); + } + }) + // Attach MediaStream to RTCPeerconnection. + .then(stream => { + if (this._status === C.STATUS_TERMINATED) { + throw new Error('terminated'); + } + + this._localMediaStream = stream; + if (stream) { + stream.getTracks().forEach(track => { + this._connection.addTrack(track, stream); + }); + } + }) + // Set remote description. + .then(() => { + if (this._late_sdp) { + return; + } + + const e = { originator: 'remote', type: 'offer', sdp: request.body }; + + logger.debug('emit "sdp"'); + this.emit('sdp', e); + + const offer = new RTCSessionDescription({ type: 'offer', sdp: e.sdp }); + + this._connectionPromiseQueue = this._connectionPromiseQueue + .then(() => this._connection.setRemoteDescription(offer)) + .catch(error => { + request.reply(488); + + this._failed('system', null, JsSIP_C.causes.WEBRTC_ERROR); + + logger.warn( + 'emit "peerconnection:setremotedescriptionfailed" [error:%o]', + error + ); + + this.emit('peerconnection:setremotedescriptionfailed', error); + + throw new Error('peerconnection.setRemoteDescription() failed'); + }); + + return this._connectionPromiseQueue; + }) + // Create local description. + .then(() => { + if (this._status === C.STATUS_TERMINATED) { + throw new Error('terminated'); + } + + // TODO: Is this event already useful? + this._connecting(request); + + if (!this._late_sdp) { + return this._createLocalDescription( + 'answer', + rtcAnswerConstraints + ).catch(() => { + request.reply(500); + + throw new Error('_createLocalDescription() failed'); + }); + } else { + return this._createLocalDescription( + 'offer', + this._rtcOfferConstraints + ).catch(() => { + request.reply(500); + + throw new Error('_createLocalDescription() failed'); + }); + } + }) + // Send reply. + .then(desc => { + if (this._status === C.STATUS_TERMINATED) { + throw new Error('terminated'); + } + + this._handleSessionTimersInIncomingRequest(request, extraHeaders); + + request.reply( + 200, + null, + extraHeaders, + desc, + () => { + this._status = C.STATUS_WAITING_FOR_ACK; + + this._setInvite2xxTimer(request, desc); + this._setACKTimer(); + this._accepted('local'); + }, + () => { + this._failed('system', null, JsSIP_C.causes.CONNECTION_ERROR); + } + ); + }) + .catch(error => { + if (this._status === C.STATUS_TERMINATED) { + return; + } + + logger.warn(`answer() failed: ${error.message}`); + + this._failed('system', error.message, JsSIP_C.causes.INTERNAL_ERROR); + }); + } + + /** + * Terminate the call. + */ + terminate(options = {}) { + logger.debug('terminate()'); + + const cause = options.cause || JsSIP_C.causes.BYE; + const extraHeaders = Utils.cloneArray(options.extraHeaders); + const body = options.body; + + let cancel_reason; + let status_code = options.status_code; + let reason_phrase = options.reason_phrase; + + // Check Session Status. + if (this._status === C.STATUS_TERMINATED) { + throw new Exceptions.InvalidStateError(this._status); + } + + switch (this._status) { + // - UAC - + case C.STATUS_NULL: + case C.STATUS_INVITE_SENT: + case C.STATUS_1XX_RECEIVED: { + logger.debug('canceling session'); + + if (status_code && (status_code < 200 || status_code >= 700)) { + throw new TypeError(`Invalid status_code: ${status_code}`); + } else if (status_code) { + reason_phrase = + reason_phrase || JsSIP_C.REASON_PHRASE[status_code] || ''; + cancel_reason = `SIP ;cause=${status_code} ;text="${reason_phrase}"`; + } + + // Check Session Status. + if ( + this._status === C.STATUS_NULL || + this._status === C.STATUS_INVITE_SENT + ) { + this._is_canceled = true; + this._cancel_reason = cancel_reason; + } else if (this._status === C.STATUS_1XX_RECEIVED) { + this._request.cancel(cancel_reason); + } + + this._status = C.STATUS_CANCELED; + + this._failed('local', null, JsSIP_C.causes.CANCELED); + break; + } + + // - UAS - + case C.STATUS_WAITING_FOR_ANSWER: + case C.STATUS_ANSWERED: { + logger.debug('rejecting session'); + + status_code = status_code || 480; + + if (status_code < 300 || status_code >= 700) { + throw new TypeError(`Invalid status_code: ${status_code}`); + } + + this._request.reply(status_code, reason_phrase, extraHeaders, body); + this._failed('local', null, JsSIP_C.causes.REJECTED); + break; + } + + case C.STATUS_WAITING_FOR_ACK: + case C.STATUS_CONFIRMED: { + logger.debug('terminating session'); + + reason_phrase = + options.reason_phrase || JsSIP_C.REASON_PHRASE[status_code] || ''; + + if (status_code && (status_code < 200 || status_code >= 700)) { + throw new TypeError(`Invalid status_code: ${status_code}`); + } else if (status_code) { + extraHeaders.push( + `Reason: SIP ;cause=${status_code}; text="${reason_phrase}"` + ); + } + + /* RFC 3261 section 15 (Terminating a session): + * + * "...the callee's UA MUST NOT send a BYE on a confirmed dialog + * until it has received an ACK for its 2xx response or until the server + * transaction times out." + */ + if ( + this._status === C.STATUS_WAITING_FOR_ACK && + this._direction === 'incoming' && + this._request.server_transaction.state !== + Transactions.C.STATUS_TERMINATED + ) { + // Save the dialog for later restoration. + const dialog = this._dialog; + + // Send the BYE as soon as the ACK is received... + this.receiveRequest = ({ method }) => { + if (method === JsSIP_C.ACK) { + this.sendRequest(JsSIP_C.BYE, { + extraHeaders, + body, + }); + dialog.terminate(); + } + }; + + // .., or when the INVITE transaction times out + this._request.server_transaction.on('stateChanged', () => { + if ( + this._request.server_transaction.state === + Transactions.C.STATUS_TERMINATED + ) { + this.sendRequest(JsSIP_C.BYE, { + extraHeaders, + body, + }); + dialog.terminate(); + } + }); + + this._ended('local', null, cause); + + // Restore the dialog into 'this' in order to be able to send the in-dialog BYE :-). + this._dialog = dialog; + + // Restore the dialog into 'ua' so the ACK can reach 'this' session. + this._ua.newDialog(dialog); + } else { + this.sendRequest(JsSIP_C.BYE, { + extraHeaders, + body, + }); + + this._ended('local', null, cause); + } + } + } + } + + sendDTMF(tones, options = {}) { + logger.debug('sendDTMF() | tones: %s', tones); + + let duration = options.duration || null; + let interToneGap = options.interToneGap || null; + const transportType = options.transportType || JsSIP_C.DTMF_TRANSPORT.INFO; + + if (tones === undefined) { + throw new TypeError('Not enough arguments'); + } + + // Check Session Status. + if ( + this._status !== C.STATUS_CONFIRMED && + this._status !== C.STATUS_WAITING_FOR_ACK && + this._status !== C.STATUS_1XX_RECEIVED + ) { + throw new Exceptions.InvalidStateError(this._status); + } + + // Check Transport type. + if ( + transportType !== JsSIP_C.DTMF_TRANSPORT.INFO && + transportType !== JsSIP_C.DTMF_TRANSPORT.RFC2833 + ) { + throw new TypeError(`invalid transportType: ${transportType}`); + } + + // Convert to string. + if (typeof tones === 'number') { + tones = tones.toString(); + } + + // Check tones. + if ( + !tones || + typeof tones !== 'string' || + !tones.match(/^[0-9A-DR#*,]+$/i) + ) { + throw new TypeError(`Invalid tones: ${tones}`); + } + + // Check duration. + if (duration && !Utils.isDecimal(duration)) { + throw new TypeError(`Invalid tone duration: ${duration}`); + } else if (!duration) { + duration = RTCSession_DTMF.C.DEFAULT_DURATION; + } else if (duration < RTCSession_DTMF.C.MIN_DURATION) { + logger.debug( + `"duration" value is lower than the minimum allowed, setting it to ${RTCSession_DTMF.C.MIN_DURATION} milliseconds` + ); + duration = RTCSession_DTMF.C.MIN_DURATION; + } else if (duration > RTCSession_DTMF.C.MAX_DURATION) { + logger.debug( + `"duration" value is greater than the maximum allowed, setting it to ${RTCSession_DTMF.C.MAX_DURATION} milliseconds` + ); + duration = RTCSession_DTMF.C.MAX_DURATION; + } else { + duration = Math.abs(duration); + } + options.duration = duration; + + // Check interToneGap. + if (interToneGap && !Utils.isDecimal(interToneGap)) { + throw new TypeError(`Invalid interToneGap: ${interToneGap}`); + } else if (!interToneGap) { + interToneGap = RTCSession_DTMF.C.DEFAULT_INTER_TONE_GAP; + } else if (interToneGap < RTCSession_DTMF.C.MIN_INTER_TONE_GAP) { + logger.debug( + `"interToneGap" value is lower than the minimum allowed, setting it to ${RTCSession_DTMF.C.MIN_INTER_TONE_GAP} milliseconds` + ); + interToneGap = RTCSession_DTMF.C.MIN_INTER_TONE_GAP; + } else { + interToneGap = Math.abs(interToneGap); + } + + // RFC2833. Let RTCDTMFSender enqueue the DTMFs. + if (transportType === JsSIP_C.DTMF_TRANSPORT.RFC2833) { + // Send DTMF in current audio RTP stream. + const sender = this._getDTMFRTPSender(); + + if (sender) { + // Add remaining buffered tones. + tones = sender.toneBuffer + tones; + // Insert tones. + sender.insertDTMF(tones, duration, interToneGap); + } + + return; + } + + if (this._tones) { + // Tones are already queued, just add to the queue. + this._tones += tones; + + return; + } + + this._tones = tones; + + // Send the first tone. + _sendDTMF.call(this); + + function _sendDTMF() { + let timeout; + + if (this._status === C.STATUS_TERMINATED || !this._tones) { + // Stop sending DTMF. + this._tones = null; + + return; + } + + // Retrieve the next tone. + const tone = this._tones[0]; + + // Remove the tone from this._tones. + this._tones = this._tones.substring(1); + + if (tone === ',') { + timeout = 2000; + } else { + // Send DTMF via SIP INFO messages. + const dtmf = new RTCSession_DTMF(this); + + options.eventHandlers = { + onFailed: () => { + this._tones = null; + }, + }; + dtmf.send(tone, options); + timeout = duration + interToneGap; + } + + // Set timeout for the next tone. + setTimeout(_sendDTMF.bind(this), timeout); + } + } + + sendInfo(contentType, body, options = {}) { + logger.debug('sendInfo()'); + + // Check Session Status. + if ( + this._status !== C.STATUS_CONFIRMED && + this._status !== C.STATUS_WAITING_FOR_ACK && + this._status !== C.STATUS_1XX_RECEIVED + ) { + throw new Exceptions.InvalidStateError(this._status); + } + + const info = new RTCSession_Info(this); + + info.send(contentType, body, options); + } + + /** + * Mute + */ + mute(options = { audio: true, video: false }) { + logger.debug('mute()'); + + let audioMuted = false, + videoMuted = false; + + if (this._audioMuted === false && options.audio) { + audioMuted = true; + this._audioMuted = true; + this._toggleMuteAudio(true); + } + + if (this._videoMuted === false && options.video) { + videoMuted = true; + this._videoMuted = true; + this._toggleMuteVideo(true); + } + + if (audioMuted === true || videoMuted === true) { + this._onmute({ + audio: audioMuted, + video: videoMuted, + }); + } + } + + /** + * Unmute + */ + unmute(options = { audio: true, video: true }) { + logger.debug('unmute()'); + + let audioUnMuted = false, + videoUnMuted = false; + + if (this._audioMuted === true && options.audio) { + audioUnMuted = true; + this._audioMuted = false; + + if (this._localHold === false) { + this._toggleMuteAudio(false); + } + } + + if (this._videoMuted === true && options.video) { + videoUnMuted = true; + this._videoMuted = false; + + if (this._localHold === false) { + this._toggleMuteVideo(false); + } + } + + if (audioUnMuted === true || videoUnMuted === true) { + this._onunmute({ + audio: audioUnMuted, + video: videoUnMuted, + }); + } + } + + /** + * Hold + */ + hold(options = {}, done) { + logger.debug('hold()'); + + if ( + this._status !== C.STATUS_WAITING_FOR_ACK && + this._status !== C.STATUS_CONFIRMED + ) { + return false; + } + + if (this._localHold === true) { + return false; + } + + if (!this.isReadyToReOffer()) { + return false; + } + + this._localHold = true; + this._onhold('local'); + + const eventHandlers = { + succeeded: () => { + if (done) { + done(); + } + }, + failed: () => { + this.terminate({ + cause: JsSIP_C.causes.WEBRTC_ERROR, + status_code: 500, + reason_phrase: 'Hold Failed', + }); + }, + }; + + if (options.useUpdate) { + this._sendUpdate({ + sdpOffer: true, + eventHandlers, + extraHeaders: options.extraHeaders, + }); + } else { + this._sendReinvite({ + eventHandlers, + extraHeaders: options.extraHeaders, + }); + } + + return true; + } + + unhold(options = {}, done) { + logger.debug('unhold()'); + + if ( + this._status !== C.STATUS_WAITING_FOR_ACK && + this._status !== C.STATUS_CONFIRMED + ) { + return false; + } + + if (this._localHold === false) { + return false; + } + + if (!this.isReadyToReOffer()) { + return false; + } + + this._localHold = false; + this._onunhold('local'); + + const eventHandlers = { + succeeded: () => { + if (done) { + done(); + } + }, + failed: () => { + this.terminate({ + cause: JsSIP_C.causes.WEBRTC_ERROR, + status_code: 500, + reason_phrase: 'Unhold Failed', + }); + }, + }; + + if (options.useUpdate) { + this._sendUpdate({ + sdpOffer: true, + eventHandlers, + extraHeaders: options.extraHeaders, + }); + } else { + this._sendReinvite({ + eventHandlers, + extraHeaders: options.extraHeaders, + }); + } + + return true; + } + + renegotiate(options = {}, done) { + logger.debug('renegotiate()'); + + const rtcOfferConstraints = options.rtcOfferConstraints || null; + + if ( + this._status !== C.STATUS_WAITING_FOR_ACK && + this._status !== C.STATUS_CONFIRMED + ) { + return false; + } + + if (!this.isReadyToReOffer()) { + return false; + } + + const eventHandlers = { + succeeded: () => { + if (done) { + done(); + } + }, + failed: () => { + this.terminate({ + cause: JsSIP_C.causes.WEBRTC_ERROR, + status_code: 500, + reason_phrase: 'Media Renegotiation Failed', + }); + }, + }; + + this._setLocalMediaStatus(); + + if (options.useUpdate) { + this._sendUpdate({ + sdpOffer: true, + eventHandlers, + rtcOfferConstraints, + extraHeaders: options.extraHeaders, + }); + } else { + this._sendReinvite({ + eventHandlers, + rtcOfferConstraints, + extraHeaders: options.extraHeaders, + }); + } + + return true; + } + + /** + * Refer + */ + refer(target, options) { + logger.debug('refer()'); + + const originalTarget = target; + + if ( + this._status !== C.STATUS_WAITING_FOR_ACK && + this._status !== C.STATUS_CONFIRMED + ) { + return false; + } + + // Check target validity. + target = this._ua.normalizeTarget(target); + if (!target) { + throw new TypeError(`Invalid target: ${originalTarget}`); + } + + const referSubscriber = new RTCSession_ReferSubscriber(this); + + referSubscriber.sendRefer(target, options); + + // Store in the map. + const id = referSubscriber.id; + + this._referSubscribers[id] = referSubscriber; + + // Listen for ending events so we can remove it from the map. + referSubscriber.on('requestFailed', () => { + delete this._referSubscribers[id]; + }); + referSubscriber.on('accepted', () => { + delete this._referSubscribers[id]; + }); + referSubscriber.on('failed', () => { + delete this._referSubscribers[id]; + }); + + return referSubscriber; + } + + /** + * Send a generic in-dialog Request + */ + sendRequest(method, options) { + logger.debug('sendRequest()'); + + if (this._dialog) { + return this._dialog.sendRequest(method, options); + } else { + const dialogsArray = Object.values(this._earlyDialogs); + + if (dialogsArray.length > 0) { + return dialogsArray[0].sendRequest(method, options); + } + + logger.warn('sendRequest() | no valid early dialog found'); + + return; + } + } + + /** + * In dialog Request Reception + */ + receiveRequest(request) { + logger.debug('receiveRequest()'); + + if (request.method === JsSIP_C.CANCEL) { + /* RFC3261 15 States that a UAS may have accepted an invitation while a CANCEL + * was in progress and that the UAC MAY continue with the session established by + * any 2xx response, or MAY terminate with BYE. JsSIP does continue with the + * established session. So the CANCEL is processed only if the session is not yet + * established. + */ + + /* + * Terminate the whole session in case the user didn't accept (or yet send the answer) + * nor reject the request opening the session. + */ + if ( + this._status === C.STATUS_WAITING_FOR_ANSWER || + this._status === C.STATUS_ANSWERED + ) { + this._status = C.STATUS_CANCELED; + this._request.reply(487); + this._failed('remote', request, JsSIP_C.causes.CANCELED); + } + } else { + // Requests arriving here are in-dialog requests. + switch (request.method) { + case JsSIP_C.ACK: { + if (this._status !== C.STATUS_WAITING_FOR_ACK) { + return; + } + + // Update signaling status. + this._status = C.STATUS_CONFIRMED; + + clearTimeout(this._timers.ackTimer); + clearTimeout(this._timers.invite2xxTimer); + + if (this._late_sdp) { + if (!request.body) { + this.terminate({ + cause: JsSIP_C.causes.MISSING_SDP, + status_code: 400, + }); + break; + } + + const e = { + originator: 'remote', + type: 'answer', + sdp: request.body, + }; + + logger.debug('emit "sdp"'); + this.emit('sdp', e); + + const answer = new RTCSessionDescription({ + type: 'answer', + sdp: e.sdp, + }); + + this._connectionPromiseQueue = this._connectionPromiseQueue + .then(() => this._connection.setRemoteDescription(answer)) + .then(() => { + if (!this._is_confirmed) { + this._confirmed('remote', request); + } + }) + .catch(error => { + this.terminate({ + cause: JsSIP_C.causes.BAD_MEDIA_DESCRIPTION, + status_code: 488, + }); + + logger.warn( + 'emit "peerconnection:setremotedescriptionfailed" [error:%o]', + error + ); + this.emit('peerconnection:setremotedescriptionfailed', error); + }); + } else if (!this._is_confirmed) { + this._confirmed('remote', request); + } + + break; + } + case JsSIP_C.BYE: { + if ( + this._status === C.STATUS_CONFIRMED || + this._status === C.STATUS_WAITING_FOR_ACK + ) { + request.reply(200); + this._ended('remote', request, JsSIP_C.causes.BYE); + } else if ( + this._status === C.STATUS_INVITE_RECEIVED || + this._status === C.STATUS_WAITING_FOR_ANSWER + ) { + request.reply(200); + this._request.reply(487, 'BYE Received'); + this._ended('remote', request, JsSIP_C.causes.BYE); + } else { + request.reply(403, 'Wrong Status'); + } + break; + } + case JsSIP_C.INVITE: { + if (this._status === C.STATUS_CONFIRMED) { + if (request.hasHeader('replaces')) { + this._receiveReplaces(request); + } else { + this._receiveReinvite(request); + } + } else { + request.reply(403, 'Wrong Status'); + } + break; + } + case JsSIP_C.INFO: { + if ( + this._status === C.STATUS_1XX_RECEIVED || + this._status === C.STATUS_WAITING_FOR_ANSWER || + this._status === C.STATUS_ANSWERED || + this._status === C.STATUS_WAITING_FOR_ACK || + this._status === C.STATUS_CONFIRMED + ) { + const contentType = request.hasHeader('Content-Type') + ? request.getHeader('Content-Type').toLowerCase() + : undefined; + + if (contentType && contentType.match(/^application\/dtmf-relay/i)) { + new RTCSession_DTMF(this).init_incoming(request); + } else if (contentType !== undefined) { + new RTCSession_Info(this).init_incoming(request); + } else { + request.reply(415); + } + } else { + request.reply(403, 'Wrong Status'); + } + break; + } + case JsSIP_C.UPDATE: { + if (this._status === C.STATUS_CONFIRMED) { + this._receiveUpdate(request); + } else { + request.reply(403, 'Wrong Status'); + } + break; + } + case JsSIP_C.REFER: { + if (this._status === C.STATUS_CONFIRMED) { + this._receiveRefer(request); + } else { + request.reply(403, 'Wrong Status'); + } + break; + } + case JsSIP_C.NOTIFY: { + if (this._status === C.STATUS_CONFIRMED) { + this._receiveNotify(request); + } else { + request.reply(403, 'Wrong Status'); + } + break; + } + default: { + request.reply(501); + } + } + } + } + + /** + * Session Callbacks + */ + + onTransportError() { + logger.warn('onTransportError()'); + + if (this._status !== C.STATUS_TERMINATED) { + this.terminate({ + status_code: 500, + reason_phrase: JsSIP_C.causes.CONNECTION_ERROR, + cause: JsSIP_C.causes.CONNECTION_ERROR, + }); + } + } + + onRequestTimeout() { + logger.warn('onRequestTimeout()'); + + if (this._status !== C.STATUS_TERMINATED) { + this.terminate({ + status_code: 408, + reason_phrase: JsSIP_C.causes.REQUEST_TIMEOUT, + cause: JsSIP_C.causes.REQUEST_TIMEOUT, + }); + } + } + + onDialogError() { + logger.warn('onDialogError()'); + + if (this._status !== C.STATUS_TERMINATED) { + this.terminate({ + status_code: 500, + reason_phrase: JsSIP_C.causes.DIALOG_ERROR, + cause: JsSIP_C.causes.DIALOG_ERROR, + }); + } + } + + // Called from DTMF handler. + newDTMF(data) { + logger.debug('newDTMF()'); + + this.emit('newDTMF', data); + } + + // Called from Info handler. + newInfo(data) { + logger.debug('newInfo()'); + + this.emit('newInfo', data); + } + + /** + * Check if RTCSession is ready for an outgoing re-INVITE or UPDATE with SDP. + */ + isReadyToReOffer() { + if (!this._rtcReady) { + logger.debug('isReadyToReOffer() | internal WebRTC status not ready'); + + return false; + } + + // No established yet. + if (!this._dialog) { + logger.debug('isReadyToReOffer() | session not established yet'); + + return false; + } + + // Another INVITE transaction is in progress. + if ( + this._dialog.uac_pending_reply === true || + this._dialog.uas_pending_reply === true + ) { + logger.debug( + 'isReadyToReOffer() | there is another INVITE/UPDATE transaction in progress' + ); + + return false; + } + + return true; + } + + _close() { + logger.debug('close()'); + + // Close local MediaStream if it was not given by the user. + if (this._localMediaStream && this._localMediaStreamLocallyGenerated) { + logger.debug('close() | closing local MediaStream'); + + Utils.closeMediaStream(this._localMediaStream); + } + + if (this._status === C.STATUS_TERMINATED) { + return; + } + + this._status = C.STATUS_TERMINATED; + + // Terminate RTC. + if (this._connection) { + try { + this._connection.close(); + } catch (error) { + logger.warn('close() | error closing the RTCPeerConnection: %o', error); + } + } + + // Terminate signaling. + + // Clear SIP timers. + for (const timer in this._timers) { + if (Object.prototype.hasOwnProperty.call(this._timers, timer)) { + clearTimeout(this._timers[timer]); + } + } + + // Clear Session Timers. + clearTimeout(this._sessionTimers.timer); + + // Terminate confirmed dialog. + if (this._dialog) { + this._dialog.terminate(); + delete this._dialog; + } + + // Terminate early dialogs. + for (const dialog in this._earlyDialogs) { + if (Object.prototype.hasOwnProperty.call(this._earlyDialogs, dialog)) { + this._earlyDialogs[dialog].terminate(); + delete this._earlyDialogs[dialog]; + } + } + + // Terminate REFER subscribers. + for (const subscriber in this._referSubscribers) { + if ( + Object.prototype.hasOwnProperty.call(this._referSubscribers, subscriber) + ) { + delete this._referSubscribers[subscriber]; + } + } + + this._ua.destroyRTCSession(this); + } + + /** + * Private API. + */ + + /** + * RFC3261 13.3.1.4 + * Response retransmissions cannot be accomplished by transaction layer + * since it is destroyed when receiving the first 2xx answer + */ + _setInvite2xxTimer(request, body) { + let timeout = Timers.T1; + + function invite2xxRetransmission() { + if (this._status !== C.STATUS_WAITING_FOR_ACK) { + return; + } + + request.reply(200, null, [`Contact: ${this._contact}`], body); + + if (timeout < Timers.T2) { + timeout = timeout * 2; + if (timeout > Timers.T2) { + timeout = Timers.T2; + } + } + + this._timers.invite2xxTimer = setTimeout( + invite2xxRetransmission.bind(this), + timeout + ); + } + + this._timers.invite2xxTimer = setTimeout( + invite2xxRetransmission.bind(this), + timeout + ); + } + + /** + * RFC3261 14.2 + * If a UAS generates a 2xx response and never receives an ACK, + * it SHOULD generate a BYE to terminate the dialog. + */ + _setACKTimer() { + this._timers.ackTimer = setTimeout(() => { + if (this._status === C.STATUS_WAITING_FOR_ACK) { + logger.debug('no ACK received, terminating the session'); + + clearTimeout(this._timers.invite2xxTimer); + this.sendRequest(JsSIP_C.BYE); + this._ended('remote', null, JsSIP_C.causes.NO_ACK); + } + }, Timers.TIMER_H); + } + + _createRTCConnection(pcConfig, rtcConstraints) { + this._connection = new RTCPeerConnection(pcConfig, rtcConstraints); + + this._connection.addEventListener('iceconnectionstatechange', () => { + const state = this._connection.iceConnectionState; + + // TODO: Do more with different states. + if (state === 'failed') { + this.terminate({ + cause: JsSIP_C.causes.RTP_TIMEOUT, + status_code: 408, + reason_phrase: JsSIP_C.causes.RTP_TIMEOUT, + }); + } + }); + + logger.debug('emit "peerconnection"'); + + this.emit('peerconnection', { + peerconnection: this._connection, + }); + } + + _createLocalDescription(type, constraints) { + logger.debug('createLocalDescription()'); + + if (type !== 'offer' && type !== 'answer') { + throw new Error(`createLocalDescription() | invalid type "${type}"`); + } + + const connection = this._connection; + + this._rtcReady = false; + + return ( + Promise.resolve() + // Create Offer or Answer. + .then(() => { + if (type === 'offer') { + return connection.createOffer(constraints).catch(error => { + logger.warn( + 'emit "peerconnection:createofferfailed" [error:%o]', + error + ); + + this.emit('peerconnection:createofferfailed', error); + + return Promise.reject(error); + }); + } else { + return connection.createAnswer(constraints).catch(error => { + logger.warn( + 'emit "peerconnection:createanswerfailed" [error:%o]', + error + ); + + this.emit('peerconnection:createanswerfailed', error); + + return Promise.reject(error); + }); + } + }) + // Set local description. + .then(desc => { + return connection.setLocalDescription(desc).catch(error => { + this._rtcReady = true; + + logger.warn( + 'emit "peerconnection:setlocaldescriptionfailed" [error:%o]', + error + ); + + this.emit('peerconnection:setlocaldescriptionfailed', error); + + return Promise.reject(error); + }); + }) + .then(() => { + // Resolve right away if 'pc.iceGatheringState' is 'complete'. + /** + * Resolve right away if: + * - 'connection.iceGatheringState' is 'complete' and no 'iceRestart' constraint is set. + * - 'connection.iceGatheringState' is 'gathering' and 'iceReady' is true. + */ + const iceRestart = constraints && constraints.iceRestart; + + if ( + (connection.iceGatheringState === 'complete' && !iceRestart) || + (connection.iceGatheringState === 'gathering' && this._iceReady) + ) { + this._rtcReady = true; + + const e = { + originator: 'local', + type: type, + sdp: connection.localDescription.sdp, + }; + + logger.debug('emit "sdp"'); + + this.emit('sdp', e); + + return Promise.resolve(e.sdp); + } + + // Add 'pc.onicencandidate' event handler to resolve on last candidate. + return new Promise(resolve => { + let finished = false; + let iceCandidateListener; + let iceGatheringStateListener; + + this._iceReady = false; + + const ready = () => { + if (finished) { + return; + } + + connection.removeEventListener( + 'icecandidate', + iceCandidateListener + ); + connection.removeEventListener( + 'icegatheringstatechange', + iceGatheringStateListener + ); + + finished = true; + this._rtcReady = true; + + // connection.iceGatheringState will still indicate 'gathering' and thus be blocking. + this._iceReady = true; + + const e = { + originator: 'local', + type: type, + sdp: connection.localDescription.sdp, + }; + + logger.debug('emit "sdp"'); + + this.emit('sdp', e); + + resolve(e.sdp); + }; + + connection.addEventListener( + 'icecandidate', + (iceCandidateListener = event => { + const candidate = event.candidate; + + if (candidate) { + this.emit('icecandidate', { + candidate, + ready, + }); + } else { + ready(); + } + }) + ); + + connection.addEventListener( + 'icegatheringstatechange', + (iceGatheringStateListener = () => { + if (connection.iceGatheringState === 'complete') { + ready(); + } + }) + ); + }); + }) + ); + } + + /** + * Dialog Management + */ + _createDialog(message, type, early) { + const local_tag = type === 'UAS' ? message.to_tag : message.from_tag; + const remote_tag = type === 'UAS' ? message.from_tag : message.to_tag; + const id = message.call_id + local_tag + remote_tag; + + let early_dialog = this._earlyDialogs[id]; + + // Early Dialog. + if (early) { + if (early_dialog) { + return true; + } else { + early_dialog = new Dialog(this, message, type, Dialog.C.STATUS_EARLY); + + // Dialog has been successfully created. + if (early_dialog.error) { + logger.debug(early_dialog.error); + this._failed('remote', message, JsSIP_C.causes.INTERNAL_ERROR); + + return false; + } else { + this._earlyDialogs[id] = early_dialog; + + return true; + } + } + } + + // Confirmed Dialog. + else { + this._from_tag = message.from_tag; + this._to_tag = message.to_tag; + + // In case the dialog is in _early_ state, update it. + if (early_dialog) { + early_dialog.update(message, type); + this._dialog = early_dialog; + delete this._earlyDialogs[id]; + + return true; + } + + // Otherwise, create a _confirmed_ dialog. + const dialog = new Dialog(this, message, type); + + if (dialog.error) { + logger.debug(dialog.error); + this._failed('remote', message, JsSIP_C.causes.INTERNAL_ERROR); + + return false; + } else { + this._dialog = dialog; + + return true; + } + } + } + + /** + * In dialog INVITE Reception + */ + + _receiveReinvite(request) { + logger.debug('receiveReinvite()'); + + const contentType = request.hasHeader('Content-Type') + ? request.getHeader('Content-Type').toLowerCase() + : undefined; + const data = { + request, + callback: undefined, + reject: reject.bind(this), + }; + + let rejected = false; + + function reject(options = {}) { + rejected = true; + + const status_code = options.status_code || 403; + const reason_phrase = options.reason_phrase || ''; + const extraHeaders = Utils.cloneArray(options.extraHeaders); + + if (this._status !== C.STATUS_CONFIRMED) { + return false; + } + + if (status_code < 300 || status_code >= 700) { + throw new TypeError(`Invalid status_code: ${status_code}`); + } + + request.reply(status_code, reason_phrase, extraHeaders); + } + + // Emit 'reinvite'. + this.emit('reinvite', data); + + if (rejected) { + return; + } + + this._late_sdp = false; + + // Request without SDP. + if (!request.body) { + this._late_sdp = true; + if (this._remoteHold) { + this._remoteHold = false; + this._onunhold('remote'); + } + this._connectionPromiseQueue = this._connectionPromiseQueue + .then(() => + this._createLocalDescription('offer', this._rtcOfferConstraints) + ) + .then(sdp => { + sendAnswer.call(this, sdp); + }) + .catch(() => { + request.reply(500); + }); + + return; + } + + // Request with SDP. + if (contentType !== 'application/sdp') { + logger.debug('invalid Content-Type'); + request.reply(415); + + return; + } + + this._processInDialogSdpOffer(request) + // Send answer. + .then(desc => { + if (this._status === C.STATUS_TERMINATED) { + return; + } + + sendAnswer.call(this, desc); + }) + .catch(error => { + logger.warn(error); + }); + + function sendAnswer(desc) { + const extraHeaders = [`Contact: ${this._contact}`]; + + this._handleSessionTimersInIncomingRequest(request, extraHeaders); + + if (this._late_sdp) { + desc = this._mangleOffer(desc); + } + + request.reply(200, null, extraHeaders, desc, () => { + this._status = C.STATUS_WAITING_FOR_ACK; + this._setInvite2xxTimer(request, desc); + this._setACKTimer(); + }); + + // If callback is given execute it. + if (typeof data.callback === 'function') { + data.callback(); + } + } + } + + /** + * In dialog UPDATE Reception + */ + _receiveUpdate(request) { + logger.debug('receiveUpdate()'); + + const contentType = request.hasHeader('Content-Type') + ? request.getHeader('Content-Type').toLowerCase() + : undefined; + const data = { + request, + callback: undefined, + reject: reject.bind(this), + }; + + let rejected = false; + + function reject(options = {}) { + rejected = true; + + const status_code = options.status_code || 403; + const reason_phrase = options.reason_phrase || ''; + const extraHeaders = Utils.cloneArray(options.extraHeaders); + + if (this._status !== C.STATUS_CONFIRMED) { + return false; + } + + if (status_code < 300 || status_code >= 700) { + throw new TypeError(`Invalid status_code: ${status_code}`); + } + + request.reply(status_code, reason_phrase, extraHeaders); + } + + // Emit 'update'. + this.emit('update', data); + + if (rejected) { + return; + } + + if (!request.body) { + sendAnswer.call(this, null); + + return; + } + + if (contentType !== 'application/sdp') { + logger.debug('invalid Content-Type'); + + request.reply(415); + + return; + } + + this._processInDialogSdpOffer(request) + // Send answer. + .then(desc => { + if (this._status === C.STATUS_TERMINATED) { + return; + } + + sendAnswer.call(this, desc); + }) + .catch(error => { + logger.warn(error); + }); + + function sendAnswer(desc) { + const extraHeaders = [`Contact: ${this._contact}`]; + + this._handleSessionTimersInIncomingRequest(request, extraHeaders); + + request.reply(200, null, extraHeaders, desc); + + // If callback is given execute it. + if (typeof data.callback === 'function') { + data.callback(); + } + } + } + + _processInDialogSdpOffer(request) { + logger.debug('_processInDialogSdpOffer()'); + + const sdp = request.parseSDP(); + + let hold = false; + + for (const m of sdp.media) { + if (holdMediaTypes.indexOf(m.type) === -1) { + continue; + } + + const direction = m.direction || sdp.direction || 'sendrecv'; + + if (direction === 'sendonly' || direction === 'inactive') { + hold = true; + } + // If at least one of the streams is active don't emit 'hold'. + else { + hold = false; + break; + } + } + + const e = { originator: 'remote', type: 'offer', sdp: request.body }; + + logger.debug('emit "sdp"'); + this.emit('sdp', e); + + const offer = new RTCSessionDescription({ type: 'offer', sdp: e.sdp }); + + this._connectionPromiseQueue = this._connectionPromiseQueue + // Set remote description. + .then(() => { + if (this._status === C.STATUS_TERMINATED) { + throw new Error('terminated'); + } + + return this._connection.setRemoteDescription(offer).catch(error => { + request.reply(488); + logger.warn( + 'emit "peerconnection:setremotedescriptionfailed" [error:%o]', + error + ); + + this.emit('peerconnection:setremotedescriptionfailed', error); + + throw error; + }); + }) + .then(() => { + if (this._status === C.STATUS_TERMINATED) { + throw new Error('terminated'); + } + + if (this._remoteHold === true && hold === false) { + this._remoteHold = false; + this._onunhold('remote'); + } else if (this._remoteHold === false && hold === true) { + this._remoteHold = true; + this._onhold('remote'); + } + }) + // Create local description. + .then(() => { + if (this._status === C.STATUS_TERMINATED) { + throw new Error('terminated'); + } + + return this._createLocalDescription( + 'answer', + this._rtcAnswerConstraints + ).catch(error => { + request.reply(500); + logger.warn( + 'emit "peerconnection:createtelocaldescriptionfailed" [error:%o]', + error + ); + + throw error; + }); + }) + .catch(error => { + logger.warn('_processInDialogSdpOffer() failed [error: %o]', error); + }); + + return this._connectionPromiseQueue; + } + + /** + * In dialog Refer Reception + */ + _receiveRefer(request) { + logger.debug('receiveRefer()'); + + if (!request.refer_to) { + logger.debug('no Refer-To header field present in REFER'); + request.reply(400); + + return; + } + + if (request.refer_to.uri.scheme !== JsSIP_C.SIP) { + logger.debug('Refer-To header field points to a non-SIP URI scheme'); + request.reply(416); + + return; + } + + // Reply before the transaction timer expires. + request.reply(202); + + const notifier = new RTCSession_ReferNotifier(this, request.cseq); + + logger.debug('emit "refer"'); + + // Emit 'refer'. + this.emit('refer', { + request, + accept: (initCallback, options) => { + accept.call(this, initCallback, options); + }, + reject: () => { + reject.call(this); + }, + }); + + function accept(initCallback, options = {}) { + initCallback = typeof initCallback === 'function' ? initCallback : null; + + if ( + this._status !== C.STATUS_WAITING_FOR_ACK && + this._status !== C.STATUS_CONFIRMED + ) { + return false; + } + + const session = new RTCSession(this._ua); + + session.on('progress', ({ response }) => { + notifier.notify(response.status_code, response.reason_phrase); + }); + + session.on('accepted', ({ response }) => { + notifier.notify(response.status_code, response.reason_phrase); + }); + + session.on('_failed', ({ message, cause }) => { + if (message) { + notifier.notify(message.status_code, message.reason_phrase); + } else { + notifier.notify(487, cause); + } + }); + + // Consider the Replaces header present in the Refer-To URI. + if (request.refer_to.uri.hasHeader('replaces')) { + const replaces = decodeURIComponent( + request.refer_to.uri.getHeader('replaces') + ); + + options.extraHeaders = Utils.cloneArray(options.extraHeaders); + options.extraHeaders.push(`Replaces: ${replaces}`); + } + + session.connect(request.refer_to.uri.toAor(), options, initCallback); + } + + function reject() { + notifier.notify(603); + } + } + + /** + * In dialog Notify Reception + */ + _receiveNotify(request) { + logger.debug('receiveNotify()'); + + if (!request.event) { + request.reply(400); + } + + switch (request.event.event) { + case 'refer': { + let id; + let referSubscriber; + + if (request.event.params && request.event.params.id) { + id = request.event.params.id; + referSubscriber = this._referSubscribers[id]; + } else if (Object.keys(this._referSubscribers).length === 1) { + referSubscriber = + this._referSubscribers[Object.keys(this._referSubscribers)[0]]; + } else { + request.reply(400, 'Missing event id parameter'); + + return; + } + + if (!referSubscriber) { + request.reply(481, 'Subscription does not exist'); + + return; + } + + referSubscriber.receiveNotify(request); + request.reply(200); + + break; + } + + default: { + request.reply(489); + } + } + } + + /** + * INVITE with Replaces Reception + */ + _receiveReplaces(request) { + logger.debug('receiveReplaces()'); + + function accept(initCallback) { + if ( + this._status !== C.STATUS_WAITING_FOR_ACK && + this._status !== C.STATUS_CONFIRMED + ) { + return false; + } + + const session = new RTCSession(this._ua); + + // Terminate the current session when the new one is confirmed. + session.on('confirmed', () => { + this.terminate(); + }); + + session.init_incoming(request, initCallback); + } + + function reject() { + logger.debug('Replaced INVITE rejected by the user'); + request.reply(486); + } + + // Emit 'replace'. + this.emit('replaces', { + request, + accept: initCallback => { + accept.call(this, initCallback); + }, + reject: () => { + reject.call(this); + }, + }); + } + + /** + * Initial Request Sender + */ + _sendInitialRequest(mediaConstraints, rtcOfferConstraints, mediaStream) { + const request_sender = new RequestSender(this._ua, this._request, { + onRequestTimeout: () => { + this.onRequestTimeout(); + }, + onTransportError: () => { + this.onTransportError(); + }, + // Update the request on authentication. + onAuthenticated: request => { + this._request = request; + }, + onReceiveResponse: response => { + this._receiveInviteResponse(response); + }, + }); + + // This Promise is resolved within the next iteration, so the app has now + // a chance to set events such as 'peerconnection' and 'connecting'. + Promise.resolve() + // Get a stream if required. + .then(() => { + // A stream is given, let the app set events such as 'peerconnection' and 'connecting'. + if (mediaStream) { + return mediaStream; + } + // Request for user media access. + else if (mediaConstraints.audio || mediaConstraints.video) { + this._localMediaStreamLocallyGenerated = true; + + return navigator.mediaDevices + .getUserMedia(mediaConstraints) + .catch(error => { + if (this._status === C.STATUS_TERMINATED) { + throw new Error('terminated'); + } + + this._failed( + 'local', + null, + JsSIP_C.causes.USER_DENIED_MEDIA_ACCESS + ); + + logger.warn('emit "getusermediafailed" [error:%o]', error); + + this.emit('getusermediafailed', error); + + throw error; + }); + } + }) + .then(stream => { + if (this._status === C.STATUS_TERMINATED) { + throw new Error('terminated'); + } + + this._localMediaStream = stream; + + if (stream) { + stream.getTracks().forEach(track => { + this._connection.addTrack(track, stream); + }); + } + + // TODO: should this be triggered here? + this._connecting(this._request); + + return this._createLocalDescription('offer', rtcOfferConstraints).catch( + error => { + this._failed('local', null, JsSIP_C.causes.WEBRTC_ERROR); + + throw error; + } + ); + }) + .then(desc => { + if (this._is_canceled || this._status === C.STATUS_TERMINATED) { + throw new Error('terminated'); + } + + this._request.body = desc; + this._status = C.STATUS_INVITE_SENT; + + logger.debug('emit "sending" [request:%o]', this._request); + + // Emit 'sending' so the app can mangle the body before the request is sent. + this.emit('sending', { + request: this._request, + }); + + request_sender.send(); + }) + .catch(error => { + if (this._status === C.STATUS_TERMINATED) { + return; + } + + logger.warn(error); + }); + } + + /** + * Get DTMF RTCRtpSender. + */ + _getDTMFRTPSender() { + const sender = this._connection.getSenders().find(rtpSender => { + return rtpSender.track && rtpSender.track.kind === 'audio'; + }); + + if (!(sender && sender.dtmf)) { + logger.warn('sendDTMF() | no local audio track to send DTMF with'); + + return; + } + + return sender.dtmf; + } + + /** + * Reception of Response for Initial INVITE + */ + _receiveInviteResponse(response) { + logger.debug('receiveInviteResponse()'); + + // Handle 2XX retransmissions and responses from forked requests. + if ( + this._dialog && + response.status_code >= 200 && + response.status_code <= 299 + ) { + /* + * If it is a retransmission from the endpoint that established + * the dialog, send an ACK + */ + if ( + this._dialog.id.call_id === response.call_id && + this._dialog.id.local_tag === response.from_tag && + this._dialog.id.remote_tag === response.to_tag + ) { + this.sendRequest(JsSIP_C.ACK); + + return; + } + + // If not, send an ACK and terminate. + else { + const dialog = new Dialog(this, response, 'UAC'); + + if (dialog.error !== undefined) { + logger.debug(dialog.error); + + return; + } + + this.sendRequest(JsSIP_C.ACK); + this.sendRequest(JsSIP_C.BYE); + + return; + } + } + + // Proceed to cancellation if the user requested. + if (this._is_canceled) { + if (response.status_code >= 100 && response.status_code < 200) { + this._request.cancel(this._cancel_reason); + } else if (response.status_code >= 200 && response.status_code < 299) { + this._acceptAndTerminate(response); + } + + return; + } + + if ( + this._status !== C.STATUS_INVITE_SENT && + this._status !== C.STATUS_1XX_RECEIVED + ) { + return; + } + + switch (true) { + case /^100$/.test(response.status_code): { + this._status = C.STATUS_1XX_RECEIVED; + break; + } + case /^1[0-9]{2}$/.test(response.status_code): { + // Do nothing with 1xx responses without To tag. + if (!response.to_tag) { + logger.debug('1xx response received without to tag'); + break; + } + + // Create Early Dialog if 1XX comes with contact. + if (response.hasHeader('contact')) { + // An error on dialog creation will fire 'failed' event. + if (!this._createDialog(response, 'UAC', true)) { + break; + } + } + + this._status = C.STATUS_1XX_RECEIVED; + + if (!response.body) { + this._progress('remote', response); + break; + } + + const e = { originator: 'remote', type: 'answer', sdp: response.body }; + + logger.debug('emit "sdp"'); + this.emit('sdp', e); + + const answer = new RTCSessionDescription({ + type: 'answer', + sdp: e.sdp, + }); + + this._connectionPromiseQueue = this._connectionPromiseQueue + .then(() => this._connection.setRemoteDescription(answer)) + .then(() => this._progress('remote', response)) + .catch(error => { + logger.warn( + 'emit "peerconnection:setremotedescriptionfailed" [error:%o]', + error + ); + + this.emit('peerconnection:setremotedescriptionfailed', error); + }); + break; + } + + case /^2[0-9]{2}$/.test(response.status_code): { + this._status = C.STATUS_CONFIRMED; + + if (!response.body) { + this._acceptAndTerminate(response, 400, JsSIP_C.causes.MISSING_SDP); + this._failed( + 'remote', + response, + JsSIP_C.causes.BAD_MEDIA_DESCRIPTION + ); + break; + } + + // An error on dialog creation will fire 'failed' event. + if (!this._createDialog(response, 'UAC')) { + break; + } + + const e = { originator: 'remote', type: 'answer', sdp: response.body }; + + logger.debug('emit "sdp"'); + this.emit('sdp', e); + + const answer = new RTCSessionDescription({ + type: 'answer', + sdp: e.sdp, + }); + + this._connectionPromiseQueue = this._connectionPromiseQueue + .then(() => { + // Be ready for 200 with SDP after a 180/183 with SDP. + // We created a SDP 'answer' for it, so check the current signaling state. + if (this._connection.signalingState === 'stable') { + return this._connection + .createOffer(this._rtcOfferConstraints) + .then(offer => this._connection.setLocalDescription(offer)) + .catch(error => { + this._acceptAndTerminate(response, 500, error.toString()); + this._failed('local', response, JsSIP_C.causes.WEBRTC_ERROR); + }); + } + }) + .then(() => { + this._connection + .setRemoteDescription(answer) + .then(() => { + // Handle Session Timers. + this._handleSessionTimersInIncomingResponse(response); + + this._accepted('remote', response); + this.sendRequest(JsSIP_C.ACK); + this._confirmed('local', null); + }) + .catch(error => { + this._acceptAndTerminate(response, 488, 'Not Acceptable Here'); + this._failed( + 'remote', + response, + JsSIP_C.causes.BAD_MEDIA_DESCRIPTION + ); + + logger.warn( + 'emit "peerconnection:setremotedescriptionfailed" [error:%o]', + error + ); + + this.emit('peerconnection:setremotedescriptionfailed', error); + }); + }); + break; + } + + default: { + const cause = Utils.sipErrorCause(response.status_code); + + this._failed('remote', response, cause); + } + } + } + + /** + * Send Re-INVITE + */ + _sendReinvite(options = {}) { + logger.debug('sendReinvite()'); + + const extraHeaders = Utils.cloneArray(options.extraHeaders); + const eventHandlers = Utils.cloneObject(options.eventHandlers); + const rtcOfferConstraints = + options.rtcOfferConstraints || this._rtcOfferConstraints || null; + + let succeeded = false; + + extraHeaders.push(`Contact: ${this._contact}`); + extraHeaders.push('Content-Type: application/sdp'); + + // Session Timers. + if (this._sessionTimers.running) { + extraHeaders.push( + `Session-Expires: ${this._sessionTimers.currentExpires};refresher=${this._sessionTimers.refresher ? 'uac' : 'uas'}` + ); + } + + this._connectionPromiseQueue = this._connectionPromiseQueue + .then(() => this._createLocalDescription('offer', rtcOfferConstraints)) + .then(sdp => { + sdp = this._mangleOffer(sdp); + + const e = { originator: 'local', type: 'offer', sdp }; + + logger.debug('emit "sdp"'); + this.emit('sdp', e); + + this.sendRequest(JsSIP_C.INVITE, { + extraHeaders, + body: sdp, + eventHandlers: { + onSuccessResponse: response => { + onSucceeded.call(this, response); + succeeded = true; + }, + onErrorResponse: response => { + onFailed.call(this, response); + }, + onTransportError: () => { + this.onTransportError(); // Do nothing because session ends. + }, + onRequestTimeout: () => { + this.onRequestTimeout(); // Do nothing because session ends. + }, + onDialogError: () => { + this.onDialogError(); // Do nothing because session ends. + }, + }, + }); + }) + .catch(() => { + onFailed(); + }); + + function onSucceeded(response) { + if (this._status === C.STATUS_TERMINATED) { + return; + } + + this.sendRequest(JsSIP_C.ACK); + + // If it is a 2XX retransmission exit now. + if (succeeded) { + return; + } + + // Handle Session Timers. + this._handleSessionTimersInIncomingResponse(response); + + // Must have SDP answer. + if (!response.body) { + onFailed.call(this); + + return; + } else if ( + !response.hasHeader('Content-Type') || + response.getHeader('Content-Type').toLowerCase() !== 'application/sdp' + ) { + onFailed.call(this); + + return; + } + + const e = { originator: 'remote', type: 'answer', sdp: response.body }; + + logger.debug('emit "sdp"'); + this.emit('sdp', e); + + const answer = new RTCSessionDescription({ type: 'answer', sdp: e.sdp }); + + this._connectionPromiseQueue = this._connectionPromiseQueue + .then(() => this._connection.setRemoteDescription(answer)) + .then(() => { + if (eventHandlers.succeeded) { + eventHandlers.succeeded(response); + } + }) + .catch(error => { + onFailed.call(this); + + logger.warn( + 'emit "peerconnection:setremotedescriptionfailed" [error:%o]', + error + ); + + this.emit('peerconnection:setremotedescriptionfailed', error); + }); + } + + function onFailed(response) { + if (eventHandlers.failed) { + eventHandlers.failed(response); + } + } + } + + /** + * Send UPDATE + */ + _sendUpdate(options = {}) { + logger.debug('sendUpdate()'); + + const extraHeaders = Utils.cloneArray(options.extraHeaders); + const eventHandlers = Utils.cloneObject(options.eventHandlers); + const rtcOfferConstraints = + options.rtcOfferConstraints || this._rtcOfferConstraints || null; + const sdpOffer = options.sdpOffer || false; + + let succeeded = false; + + extraHeaders.push(`Contact: ${this._contact}`); + + // Session Timers. + if (this._sessionTimers.running) { + extraHeaders.push( + `Session-Expires: ${this._sessionTimers.currentExpires};refresher=${this._sessionTimers.refresher ? 'uac' : 'uas'}` + ); + } + + if (sdpOffer) { + extraHeaders.push('Content-Type: application/sdp'); + + this._connectionPromiseQueue = this._connectionPromiseQueue + .then(() => this._createLocalDescription('offer', rtcOfferConstraints)) + .then(sdp => { + sdp = this._mangleOffer(sdp); + + const e = { originator: 'local', type: 'offer', sdp }; + + logger.debug('emit "sdp"'); + this.emit('sdp', e); + + this.sendRequest(JsSIP_C.UPDATE, { + extraHeaders, + body: sdp, + eventHandlers: { + onSuccessResponse: response => { + onSucceeded.call(this, response); + succeeded = true; + }, + onErrorResponse: response => { + onFailed.call(this, response); + }, + onTransportError: () => { + this.onTransportError(); // Do nothing because session ends. + }, + onRequestTimeout: () => { + this.onRequestTimeout(); // Do nothing because session ends. + }, + onDialogError: () => { + this.onDialogError(); // Do nothing because session ends. + }, + }, + }); + }) + .catch(() => { + onFailed.call(this); + }); + } + + // No SDP. + else { + this.sendRequest(JsSIP_C.UPDATE, { + extraHeaders, + eventHandlers: { + onSuccessResponse: response => { + onSucceeded.call(this, response); + }, + onErrorResponse: response => { + onFailed.call(this, response); + }, + onTransportError: () => { + this.onTransportError(); // Do nothing because session ends. + }, + onRequestTimeout: () => { + this.onRequestTimeout(); // Do nothing because session ends. + }, + onDialogError: () => { + this.onDialogError(); // Do nothing because session ends. + }, + }, + }); + } + + function onSucceeded(response) { + if (this._status === C.STATUS_TERMINATED) { + return; + } + + // If it is a 2XX retransmission exit now. + if (succeeded) { + return; + } + + // Handle Session Timers. + this._handleSessionTimersInIncomingResponse(response); + + // Must have SDP answer. + if (sdpOffer) { + if (!response.body) { + onFailed.call(this); + + return; + } else if ( + !response.hasHeader('Content-Type') || + response.getHeader('Content-Type').toLowerCase() !== 'application/sdp' + ) { + onFailed.call(this); + + return; + } + + const e = { originator: 'remote', type: 'answer', sdp: response.body }; + + logger.debug('emit "sdp"'); + this.emit('sdp', e); + + const answer = new RTCSessionDescription({ + type: 'answer', + sdp: e.sdp, + }); + + this._connectionPromiseQueue = this._connectionPromiseQueue + .then(() => this._connection.setRemoteDescription(answer)) + .then(() => { + if (eventHandlers.succeeded) { + eventHandlers.succeeded(response); + } + }) + .catch(error => { + onFailed.call(this); + + logger.warn( + 'emit "peerconnection:setremotedescriptionfailed" [error:%o]', + error + ); + + this.emit('peerconnection:setremotedescriptionfailed', error); + }); + } + // No SDP answer. + else if (eventHandlers.succeeded) { + eventHandlers.succeeded(response); + } + } + + function onFailed(response) { + if (eventHandlers.failed) { + eventHandlers.failed(response); + } + } + } + + _acceptAndTerminate(response, status_code, reason_phrase) { + logger.debug('acceptAndTerminate()'); + + const extraHeaders = []; + + if (status_code) { + reason_phrase = reason_phrase || JsSIP_C.REASON_PHRASE[status_code] || ''; + extraHeaders.push( + `Reason: SIP ;cause=${status_code}; text="${reason_phrase}"` + ); + } + + // An error on dialog creation will fire 'failed' event. + if (this._dialog || this._createDialog(response, 'UAC')) { + this.sendRequest(JsSIP_C.ACK); + this.sendRequest(JsSIP_C.BYE, { + extraHeaders, + }); + } + + // Update session status. + this._status = C.STATUS_TERMINATED; + } + + /** + * Correctly set the SDP direction attributes if the call is on local hold + */ + _mangleOffer(sdp) { + if (!this._localHold && !this._remoteHold) { + return sdp; + } + + sdp = sdp_transform.parse(sdp); + + // Local hold. + if (this._localHold && !this._remoteHold) { + logger.debug('mangleOffer() | me on hold, mangling offer'); + for (const m of sdp.media) { + if (holdMediaTypes.indexOf(m.type) === -1) { + continue; + } + if (!m.direction) { + m.direction = 'sendonly'; + } else if (m.direction === 'sendrecv') { + m.direction = 'sendonly'; + } else if (m.direction === 'recvonly') { + m.direction = 'inactive'; + } + } + } + // Local and remote hold. + else if (this._localHold && this._remoteHold) { + logger.debug('mangleOffer() | both on hold, mangling offer'); + for (const m of sdp.media) { + if (holdMediaTypes.indexOf(m.type) === -1) { + continue; + } + m.direction = 'inactive'; + } + } + // Remote hold. + else if (this._remoteHold) { + logger.debug('mangleOffer() | remote on hold, mangling offer'); + for (const m of sdp.media) { + if (holdMediaTypes.indexOf(m.type) === -1) { + continue; + } + if (!m.direction) { + m.direction = 'recvonly'; + } else if (m.direction === 'sendrecv') { + m.direction = 'recvonly'; + } else if (m.direction === 'recvonly') { + m.direction = 'inactive'; + } + } + } + + return sdp_transform.write(sdp); + } + + _setLocalMediaStatus() { + let enableAudio = true, + enableVideo = true; + + if (this._localHold || this._remoteHold) { + enableAudio = false; + enableVideo = false; + } + + if (this._audioMuted) { + enableAudio = false; + } + + if (this._videoMuted) { + enableVideo = false; + } + + this._toggleMuteAudio(!enableAudio); + this._toggleMuteVideo(!enableVideo); + } + + /** + * Handle SessionTimers for an incoming INVITE or UPDATE. + * @param {IncomingRequest} request + * @param {Array} responseExtraHeaders Extra headers for the 200 response. + */ + _handleSessionTimersInIncomingRequest(request, responseExtraHeaders) { + if (!this._sessionTimers.enabled) { + return; + } + + let session_expires_refresher; + + if ( + request.session_expires && + request.session_expires >= JsSIP_C.MIN_SESSION_EXPIRES + ) { + this._sessionTimers.currentExpires = request.session_expires; + session_expires_refresher = request.session_expires_refresher || 'uas'; + } else { + this._sessionTimers.currentExpires = this._sessionTimers.defaultExpires; + session_expires_refresher = 'uas'; + } + + responseExtraHeaders.push( + `Session-Expires: ${this._sessionTimers.currentExpires};refresher=${session_expires_refresher}` + ); + + this._sessionTimers.refresher = session_expires_refresher === 'uas'; + this._runSessionTimer(); + } + + /** + * Handle SessionTimers for an incoming response to INVITE or UPDATE. + * @param {IncomingResponse} response + */ + _handleSessionTimersInIncomingResponse(response) { + if (!this._sessionTimers.enabled) { + return; + } + + let session_expires_refresher; + + if ( + response.session_expires && + response.session_expires >= JsSIP_C.MIN_SESSION_EXPIRES + ) { + this._sessionTimers.currentExpires = response.session_expires; + session_expires_refresher = response.session_expires_refresher || 'uac'; + } else { + this._sessionTimers.currentExpires = this._sessionTimers.defaultExpires; + session_expires_refresher = 'uac'; + } + + this._sessionTimers.refresher = session_expires_refresher === 'uac'; + this._runSessionTimer(); + } + + _runSessionTimer() { + const expires = this._sessionTimers.currentExpires; + + this._sessionTimers.running = true; + + clearTimeout(this._sessionTimers.timer); + + // I'm the refresher. + if (this._sessionTimers.refresher) { + this._sessionTimers.timer = setTimeout(() => { + if (this._status === C.STATUS_TERMINATED) { + return; + } + + if (!this.isReadyToReOffer()) { + return; + } + + logger.debug('runSessionTimer() | sending session refresh request'); + + if (this._sessionTimers.refreshMethod === JsSIP_C.UPDATE) { + this._sendUpdate(); + } else { + this._sendReinvite(); + } + }, expires * 500); // Half the given interval (as the RFC states). + } + + // I'm not the refresher. + else { + this._sessionTimers.timer = setTimeout(() => { + if (this._status === C.STATUS_TERMINATED) { + return; + } + + logger.warn( + 'runSessionTimer() | timer expired, terminating the session' + ); + + this.terminate({ + cause: JsSIP_C.causes.REQUEST_TIMEOUT, + status_code: 408, + reason_phrase: 'Session Timer Expired', + }); + }, expires * 1100); + } + } + + _toggleMuteAudio(mute) { + const senders = this._connection.getSenders().filter(sender => { + return sender.track && sender.track.kind === 'audio'; + }); + + for (const sender of senders) { + sender.track.enabled = !mute; + } + } + + _toggleMuteVideo(mute) { + const senders = this._connection.getSenders().filter(sender => { + return sender.track && sender.track.kind === 'video'; + }); + + for (const sender of senders) { + sender.track.enabled = !mute; + } + } + + _newRTCSession(originator, request) { + logger.debug('newRTCSession()'); + + this._ua.newRTCSession(this, { + originator, + session: this, + request, + }); + } + + _connecting(request) { + logger.debug('session connecting'); - this._is_confirmed = true; + logger.debug('emit "connecting"'); - logger.debug('emit "confirmed"'); + this.emit('connecting', { + request, + }); + } - this.emit('confirmed', { - originator, - ack : ack || null - }); - } + _progress(originator, response) { + logger.debug('session progress'); - _ended(originator, message, cause) - { - logger.debug('session ended'); + logger.debug('emit "progress"'); - this._end_time = new Date(); + this.emit('progress', { + originator, + response: response || null, + }); + } - this._close(); + _accepted(originator, message) { + logger.debug('session accepted'); - logger.debug('emit "ended"'); + this._start_time = new Date(); - this.emit('ended', { - originator, - message : message || null, - cause - }); - } + logger.debug('emit "accepted"'); - _failed(originator, message, cause) - { - logger.debug('session failed'); + this.emit('accepted', { + originator, + response: message || null, + }); + } - // Emit private '_failed' event first. - logger.debug('emit "_failed"'); + _confirmed(originator, ack) { + logger.debug('session confirmed'); - this.emit('_failed', { - originator, - message : message || null, - cause - }); + this._is_confirmed = true; - this._close(); + logger.debug('emit "confirmed"'); - logger.debug('emit "failed"'); + this.emit('confirmed', { + originator, + ack: ack || null, + }); + } - this.emit('failed', { - originator, - message : message || null, - cause - }); - } + _ended(originator, message, cause) { + logger.debug('session ended'); - _onhold(originator) - { - logger.debug('session onhold'); + this._end_time = new Date(); - this._setLocalMediaStatus(); + this._close(); - logger.debug('emit "hold"'); + logger.debug('emit "ended"'); - this.emit('hold', { - originator - }); - } + this.emit('ended', { + originator, + message: message || null, + cause, + }); + } - _onunhold(originator) - { - logger.debug('session onunhold'); + _failed(originator, message, cause) { + logger.debug('session failed'); - this._setLocalMediaStatus(); + // Emit private '_failed' event first. + logger.debug('emit "_failed"'); - logger.debug('emit "unhold"'); + this.emit('_failed', { + originator, + message: message || null, + cause, + }); - this.emit('unhold', { - originator - }); - } + this._close(); - _onmute({ audio, video }) - { - logger.debug('session onmute'); + logger.debug('emit "failed"'); - this._setLocalMediaStatus(); + this.emit('failed', { + originator, + message: message || null, + cause, + }); + } - logger.debug('emit "muted"'); + _onhold(originator) { + logger.debug('session onhold'); - this.emit('muted', { - audio, - video - }); - } + this._setLocalMediaStatus(); - _onunmute({ audio, video }) - { - logger.debug('session onunmute'); + logger.debug('emit "hold"'); - this._setLocalMediaStatus(); + this.emit('hold', { + originator, + }); + } - logger.debug('emit "unmuted"'); + _onunhold(originator) { + logger.debug('session onunhold'); - this.emit('unmuted', { - audio, - video - }); - } + this._setLocalMediaStatus(); + + logger.debug('emit "unhold"'); + + this.emit('unhold', { + originator, + }); + } + + _onmute({ audio, video }) { + logger.debug('session onmute'); + + this._setLocalMediaStatus(); + + logger.debug('emit "muted"'); + + this.emit('muted', { + audio, + video, + }); + } + + _onunmute({ audio, video }) { + logger.debug('session onunmute'); + + this._setLocalMediaStatus(); + + logger.debug('emit "unmuted"'); + + this.emit('unmuted', { + audio, + video, + }); + } }; diff --git a/src/RTCSession/DTMF.js b/src/RTCSession/DTMF.js index b296bae2..abacc0fc 100644 --- a/src/RTCSession/DTMF.js +++ b/src/RTCSession/DTMF.js @@ -7,185 +7,154 @@ const Utils = require('../Utils'); const logger = new Logger('RTCSession:DTMF'); const C = { - MIN_DURATION : 70, - MAX_DURATION : 6000, - DEFAULT_DURATION : 100, - MIN_INTER_TONE_GAP : 50, - DEFAULT_INTER_TONE_GAP : 500 + MIN_DURATION: 70, + MAX_DURATION: 6000, + DEFAULT_DURATION: 100, + MIN_INTER_TONE_GAP: 50, + DEFAULT_INTER_TONE_GAP: 500, }; -module.exports = class DTMF extends EventEmitter -{ - constructor(session) - { - super(); - - this._session = session; - this._direction = null; - this._tone = null; - this._duration = null; - this._request = null; - } - - get tone() - { - return this._tone; - } - - get duration() - { - return this._duration; - } - - send(tone, options = {}) - { - if (tone === undefined) - { - throw new TypeError('Not enough arguments'); - } - - this._direction = 'outgoing'; - - // Check RTCSession Status. - if ( - this._session.status !== this._session.C.STATUS_CONFIRMED && - this._session.status !== this._session.C.STATUS_WAITING_FOR_ACK && - this._session.status !== this._session.C.STATUS_1XX_RECEIVED - ) - { - throw new Exceptions.InvalidStateError(this._session.status); - } - - const extraHeaders = Utils.cloneArray(options.extraHeaders); - - this.eventHandlers = Utils.cloneObject(options.eventHandlers); - - // Check tone type. - if (typeof tone === 'string') - { - tone = tone.toUpperCase(); - } - else if (typeof tone === 'number') - { - tone = tone.toString(); - } - else - { - throw new TypeError(`Invalid tone: ${tone}`); - } - - // Check tone value. - if (!tone.match(/^[0-9A-DR#*]$/)) - { - throw new TypeError(`Invalid tone: ${tone}`); - } - else - { - this._tone = tone; - } - - // Duration is checked/corrected in RTCSession. - this._duration = options.duration; - - extraHeaders.push('Content-Type: application/dtmf-relay'); - - let body = `Signal=${this._tone}\r\n`; - - body += `Duration=${this._duration}`; - - this._session.newDTMF({ - originator : 'local', - dtmf : this, - request : this._request - }); - - this._session.sendRequest(JsSIP_C.INFO, { - extraHeaders, - eventHandlers : { - onSuccessResponse : (response) => - { - this.emit('succeeded', { - originator : 'remote', - response - }); - }, - onErrorResponse : (response) => - { - if (this.eventHandlers.onFailed) - { - this.eventHandlers.onFailed(); - } - - this.emit('failed', { - originator : 'remote', - response - }); - }, - onRequestTimeout : () => - { - this._session.onRequestTimeout(); - }, - onTransportError : () => - { - this._session.onTransportError(); - }, - onDialogError : () => - { - this._session.onDialogError(); - } - }, - body - }); - } - - init_incoming(request) - { - const reg_tone = /^(Signal\s*?=\s*?)([0-9A-D#*]{1})(\s)?.*/; - const reg_duration = /^(Duration\s?=\s?)([0-9]{1,4})(\s)?.*/; - - this._direction = 'incoming'; - this._request = request; - - request.reply(200); - - if (request.body) - { - const body = request.body.split('\n'); - - if (body.length >= 1) - { - if (reg_tone.test(body[0])) - { - this._tone = body[0].replace(reg_tone, '$2'); - } - } - if (body.length >=2) - { - if (reg_duration.test(body[1])) - { - this._duration = parseInt(body[1].replace(reg_duration, '$2'), 10); - } - } - } - - if (!this._duration) - { - this._duration = C.DEFAULT_DURATION; - } - - if (!this._tone) - { - logger.debug('invalid INFO DTMF received, discarded'); - } - else - { - this._session.newDTMF({ - originator : 'remote', - dtmf : this, - request - }); - } - } +module.exports = class DTMF extends EventEmitter { + constructor(session) { + super(); + + this._session = session; + this._direction = null; + this._tone = null; + this._duration = null; + this._request = null; + } + + get tone() { + return this._tone; + } + + get duration() { + return this._duration; + } + + send(tone, options = {}) { + if (tone === undefined) { + throw new TypeError('Not enough arguments'); + } + + this._direction = 'outgoing'; + + // Check RTCSession Status. + if ( + this._session.status !== this._session.C.STATUS_CONFIRMED && + this._session.status !== this._session.C.STATUS_WAITING_FOR_ACK && + this._session.status !== this._session.C.STATUS_1XX_RECEIVED + ) { + throw new Exceptions.InvalidStateError(this._session.status); + } + + const extraHeaders = Utils.cloneArray(options.extraHeaders); + + this.eventHandlers = Utils.cloneObject(options.eventHandlers); + + // Check tone type. + if (typeof tone === 'string') { + tone = tone.toUpperCase(); + } else if (typeof tone === 'number') { + tone = tone.toString(); + } else { + throw new TypeError(`Invalid tone: ${tone}`); + } + + // Check tone value. + if (!tone.match(/^[0-9A-DR#*]$/)) { + throw new TypeError(`Invalid tone: ${tone}`); + } else { + this._tone = tone; + } + + // Duration is checked/corrected in RTCSession. + this._duration = options.duration; + + extraHeaders.push('Content-Type: application/dtmf-relay'); + + let body = `Signal=${this._tone}\r\n`; + + body += `Duration=${this._duration}`; + + this._session.newDTMF({ + originator: 'local', + dtmf: this, + request: this._request, + }); + + this._session.sendRequest(JsSIP_C.INFO, { + extraHeaders, + eventHandlers: { + onSuccessResponse: response => { + this.emit('succeeded', { + originator: 'remote', + response, + }); + }, + onErrorResponse: response => { + if (this.eventHandlers.onFailed) { + this.eventHandlers.onFailed(); + } + + this.emit('failed', { + originator: 'remote', + response, + }); + }, + onRequestTimeout: () => { + this._session.onRequestTimeout(); + }, + onTransportError: () => { + this._session.onTransportError(); + }, + onDialogError: () => { + this._session.onDialogError(); + }, + }, + body, + }); + } + + init_incoming(request) { + const reg_tone = /^(Signal\s*?=\s*?)([0-9A-D#*]{1})(\s)?.*/; + const reg_duration = /^(Duration\s?=\s?)([0-9]{1,4})(\s)?.*/; + + this._direction = 'incoming'; + this._request = request; + + request.reply(200); + + if (request.body) { + const body = request.body.split('\n'); + + if (body.length >= 1) { + if (reg_tone.test(body[0])) { + this._tone = body[0].replace(reg_tone, '$2'); + } + } + if (body.length >= 2) { + if (reg_duration.test(body[1])) { + this._duration = parseInt(body[1].replace(reg_duration, '$2'), 10); + } + } + } + + if (!this._duration) { + this._duration = C.DEFAULT_DURATION; + } + + if (!this._tone) { + logger.debug('invalid INFO DTMF received, discarded'); + } else { + this._session.newDTMF({ + originator: 'remote', + dtmf: this, + request, + }); + } + } }; /** diff --git a/src/RTCSession/Info.js b/src/RTCSession/Info.js index c331c351..8c57bb96 100644 --- a/src/RTCSession/Info.js +++ b/src/RTCSession/Info.js @@ -3,106 +3,96 @@ const JsSIP_C = require('../Constants'); const Exceptions = require('../Exceptions'); const Utils = require('../Utils'); -module.exports = class Info extends EventEmitter -{ - constructor(session) - { - super(); - - this._session = session; - this._direction = null; - this._contentType = null; - this._body = null; - } - - get contentType() - { - return this._contentType; - } - - get body() - { - return this._body; - } - - send(contentType, body, options = {}) - { - this._direction = 'outgoing'; - - if (contentType === undefined) - { - throw new TypeError('Not enough arguments'); - } - - // Check RTCSession Status. - if (this._session.status !== this._session.C.STATUS_CONFIRMED && - this._session.status !== this._session.C.STATUS_WAITING_FOR_ACK) - { - throw new Exceptions.InvalidStateError(this._session.status); - } - - this._contentType = contentType; - this._body = body; - - const extraHeaders = Utils.cloneArray(options.extraHeaders); - - extraHeaders.push(`Content-Type: ${contentType}`); - - this._session.newInfo({ - originator : 'local', - info : this, - request : this.request - }); - - this._session.sendRequest(JsSIP_C.INFO, { - extraHeaders, - eventHandlers : { - onSuccessResponse : (response) => - { - this.emit('succeeded', { - originator : 'remote', - response - }); - }, - onErrorResponse : (response) => - { - this.emit('failed', { - originator : 'remote', - response - }); - }, - onTransportError : () => - { - this._session.onTransportError(); - }, - onRequestTimeout : () => - { - this._session.onRequestTimeout(); - }, - onDialogError : () => - { - this._session.onDialogError(); - } - }, - body - }); - } - - init_incoming(request) - { - this._direction = 'incoming'; - this.request = request; - - request.reply(200); - - this._contentType = request.hasHeader('Content-Type') ? - request.getHeader('Content-Type').toLowerCase() : undefined; - this._body = request.body; - - this._session.newInfo({ - originator : 'remote', - info : this, - request - }); - } +module.exports = class Info extends EventEmitter { + constructor(session) { + super(); + + this._session = session; + this._direction = null; + this._contentType = null; + this._body = null; + } + + get contentType() { + return this._contentType; + } + + get body() { + return this._body; + } + + send(contentType, body, options = {}) { + this._direction = 'outgoing'; + + if (contentType === undefined) { + throw new TypeError('Not enough arguments'); + } + + // Check RTCSession Status. + if ( + this._session.status !== this._session.C.STATUS_CONFIRMED && + this._session.status !== this._session.C.STATUS_WAITING_FOR_ACK + ) { + throw new Exceptions.InvalidStateError(this._session.status); + } + + this._contentType = contentType; + this._body = body; + + const extraHeaders = Utils.cloneArray(options.extraHeaders); + + extraHeaders.push(`Content-Type: ${contentType}`); + + this._session.newInfo({ + originator: 'local', + info: this, + request: this.request, + }); + + this._session.sendRequest(JsSIP_C.INFO, { + extraHeaders, + eventHandlers: { + onSuccessResponse: response => { + this.emit('succeeded', { + originator: 'remote', + response, + }); + }, + onErrorResponse: response => { + this.emit('failed', { + originator: 'remote', + response, + }); + }, + onTransportError: () => { + this._session.onTransportError(); + }, + onRequestTimeout: () => { + this._session.onRequestTimeout(); + }, + onDialogError: () => { + this._session.onDialogError(); + }, + }, + body, + }); + } + + init_incoming(request) { + this._direction = 'incoming'; + this.request = request; + + request.reply(200); + + this._contentType = request.hasHeader('Content-Type') + ? request.getHeader('Content-Type').toLowerCase() + : undefined; + this._body = request.body; + + this._session.newInfo({ + originator: 'remote', + info: this, + request, + }); + } }; diff --git a/src/RTCSession/ReferNotifier.js b/src/RTCSession/ReferNotifier.js index dcf50712..4af0ea04 100644 --- a/src/RTCSession/ReferNotifier.js +++ b/src/RTCSession/ReferNotifier.js @@ -4,58 +4,53 @@ const JsSIP_C = require('../Constants'); const logger = new Logger('RTCSession:ReferNotifier'); const C = { - event_type : 'refer', - body_type : 'message/sipfrag;version=2.0', - expires : 300 + event_type: 'refer', + body_type: 'message/sipfrag;version=2.0', + expires: 300, }; -module.exports = class ReferNotifier -{ - constructor(session, id, expires) - { - this._session = session; - this._id = id; - this._expires = expires || C.expires; - this._active = true; - - // The creation of a Notifier results in an immediate NOTIFY. - this.notify(100); - } - - notify(code, reason) - { - logger.debug('notify()'); - - if (this._active === false) - { - return; - } - - reason = reason || JsSIP_C.REASON_PHRASE[code] || ''; - - let state; - - if (code >= 200) - { - state = 'terminated;reason=noresource'; - } - else - { - state = `active;expires=${this._expires}`; - } - - // Put this in a try/catch block. - this._session.sendRequest(JsSIP_C.NOTIFY, { - extraHeaders : [ - `Event: ${C.event_type};id=${this._id}`, - `Subscription-State: ${state}`, - `Content-Type: ${C.body_type}` - ], - body : `SIP/2.0 ${code} ${reason}`, - eventHandlers : { - // If a negative response is received, subscription is canceled. - onErrorResponse() { this._active = false; } - } - }); - } +module.exports = class ReferNotifier { + constructor(session, id, expires) { + this._session = session; + this._id = id; + this._expires = expires || C.expires; + this._active = true; + + // The creation of a Notifier results in an immediate NOTIFY. + this.notify(100); + } + + notify(code, reason) { + logger.debug('notify()'); + + if (this._active === false) { + return; + } + + reason = reason || JsSIP_C.REASON_PHRASE[code] || ''; + + let state; + + if (code >= 200) { + state = 'terminated;reason=noresource'; + } else { + state = `active;expires=${this._expires}`; + } + + // Put this in a try/catch block. + this._session.sendRequest(JsSIP_C.NOTIFY, { + extraHeaders: [ + `Event: ${C.event_type};id=${this._id}`, + `Subscription-State: ${state}`, + `Content-Type: ${C.body_type}`, + ], + body: `SIP/2.0 ${code} ${reason}`, + eventHandlers: { + // If a negative response is received, subscription is canceled. + onErrorResponse() { + this._active = false; + }, + }, + }); + } }; diff --git a/src/RTCSession/ReferSubscriber.js b/src/RTCSession/ReferSubscriber.js index 0d2dbaa9..26231efb 100644 --- a/src/RTCSession/ReferSubscriber.js +++ b/src/RTCSession/ReferSubscriber.js @@ -6,163 +6,157 @@ const Utils = require('../Utils'); const logger = new Logger('RTCSession:ReferSubscriber'); -module.exports = class ReferSubscriber extends EventEmitter -{ - constructor(session) - { - super(); - - this._id = null; - this._session = session; - } - - get id() - { - return this._id; - } - - sendRefer(target, options = {}) - { - logger.debug('sendRefer()'); - - const extraHeaders = Utils.cloneArray(options.extraHeaders); - const eventHandlers = Utils.cloneObject(options.eventHandlers); - - // Set event handlers. - for (const event in eventHandlers) - { - if (Object.prototype.hasOwnProperty.call(eventHandlers, event)) - { - this.on(event, eventHandlers[event]); - } - } - - // Replaces URI header field. - let replaces = null; - - if (options.replaces) - { - replaces = options.replaces._request.call_id; - replaces += `;to-tag=${options.replaces._to_tag}`; - replaces += `;from-tag=${options.replaces._from_tag}`; - - replaces = encodeURIComponent(replaces); - } - - // Refer-To header field. - const referTo = `Refer-To: <${target}${replaces?`?Replaces=${replaces}`:''}>`; - - extraHeaders.push(referTo); - - // Referred-By header field (if not already present). - if (!extraHeaders.some((header) => header.toLowerCase().startsWith('referred-by:'))) - { - const referredBy = `Referred-By: <${this._session._ua._configuration.uri._scheme}:${this._session._ua._configuration.uri._user}@${this._session._ua._configuration.uri._host}>`; - - extraHeaders.push(referredBy); - } - - extraHeaders.push(`Contact: ${this._session.contact}`); - - const request = this._session.sendRequest(JsSIP_C.REFER, { - extraHeaders, - eventHandlers : { - onSuccessResponse : (response) => - { - this._requestSucceeded(response); - }, - onErrorResponse : (response) => - { - this._requestFailed(response, JsSIP_C.causes.REJECTED); - }, - onTransportError : () => - { - this._requestFailed(null, JsSIP_C.causes.CONNECTION_ERROR); - }, - onRequestTimeout : () => - { - this._requestFailed(null, JsSIP_C.causes.REQUEST_TIMEOUT); - }, - onDialogError : () => - { - this._requestFailed(null, JsSIP_C.causes.DIALOG_ERROR); - } - } - }); - - this._id = request.cseq; - } - - receiveNotify(request) - { - logger.debug('receiveNotify()'); - - if (!request.body) - { - return; - } - - const status_line = Grammar.parse(request.body.trim().split('\r\n', 1)[0], 'Status_Line'); - - if (status_line === -1) - { - logger.debug(`receiveNotify() | error parsing NOTIFY body: "${request.body}"`); - - return; - } - - switch (true) - { - case /^100$/.test(status_line.status_code): - this.emit('trying', { - request, - status_line - }); - break; - - case /^1[0-9]{2}$/.test(status_line.status_code): - this.emit('progress', { - request, - status_line - }); - break; - - case /^2[0-9]{2}$/.test(status_line.status_code): - this.emit('accepted', { - request, - status_line - }); - break; - - default: - this.emit('failed', { - request, - status_line - }); - break; - } - } - - _requestSucceeded(response) - { - logger.debug('REFER succeeded'); - - logger.debug('emit "requestSucceeded"'); - - this.emit('requestSucceeded', { - response - }); - } - - _requestFailed(response, cause) - { - logger.debug('REFER failed'); - - logger.debug('emit "requestFailed"'); - - this.emit('requestFailed', { - response : response || null, - cause - }); - } +module.exports = class ReferSubscriber extends EventEmitter { + constructor(session) { + super(); + + this._id = null; + this._session = session; + } + + get id() { + return this._id; + } + + sendRefer(target, options = {}) { + logger.debug('sendRefer()'); + + const extraHeaders = Utils.cloneArray(options.extraHeaders); + const eventHandlers = Utils.cloneObject(options.eventHandlers); + + // Set event handlers. + for (const event in eventHandlers) { + if (Object.prototype.hasOwnProperty.call(eventHandlers, event)) { + this.on(event, eventHandlers[event]); + } + } + + // Replaces URI header field. + let replaces = null; + + if (options.replaces) { + replaces = options.replaces._request.call_id; + replaces += `;to-tag=${options.replaces._to_tag}`; + replaces += `;from-tag=${options.replaces._from_tag}`; + + replaces = encodeURIComponent(replaces); + } + + // Refer-To header field. + const referTo = `Refer-To: <${target}${replaces ? `?Replaces=${replaces}` : ''}>`; + + extraHeaders.push(referTo); + + // Referred-By header field (if not already present). + if ( + !extraHeaders.some(header => + header.toLowerCase().startsWith('referred-by:') + ) + ) { + const referredBy = `Referred-By: <${this._session._ua._configuration.uri._scheme}:${this._session._ua._configuration.uri._user}@${this._session._ua._configuration.uri._host}>`; + + extraHeaders.push(referredBy); + } + + extraHeaders.push(`Contact: ${this._session.contact}`); + + const request = this._session.sendRequest(JsSIP_C.REFER, { + extraHeaders, + eventHandlers: { + onSuccessResponse: response => { + this._requestSucceeded(response); + }, + onErrorResponse: response => { + this._requestFailed(response, JsSIP_C.causes.REJECTED); + }, + onTransportError: () => { + this._requestFailed(null, JsSIP_C.causes.CONNECTION_ERROR); + }, + onRequestTimeout: () => { + this._requestFailed(null, JsSIP_C.causes.REQUEST_TIMEOUT); + }, + onDialogError: () => { + this._requestFailed(null, JsSIP_C.causes.DIALOG_ERROR); + }, + }, + }); + + this._id = request.cseq; + } + + receiveNotify(request) { + logger.debug('receiveNotify()'); + + if (!request.body) { + return; + } + + const status_line = Grammar.parse( + request.body.trim().split('\r\n', 1)[0], + 'Status_Line' + ); + + if (status_line === -1) { + logger.debug( + `receiveNotify() | error parsing NOTIFY body: "${request.body}"` + ); + + return; + } + + switch (true) { + case /^100$/.test(status_line.status_code): { + this.emit('trying', { + request, + status_line, + }); + break; + } + + case /^1[0-9]{2}$/.test(status_line.status_code): { + this.emit('progress', { + request, + status_line, + }); + break; + } + + case /^2[0-9]{2}$/.test(status_line.status_code): { + this.emit('accepted', { + request, + status_line, + }); + break; + } + + default: { + this.emit('failed', { + request, + status_line, + }); + break; + } + } + } + + _requestSucceeded(response) { + logger.debug('REFER succeeded'); + + logger.debug('emit "requestSucceeded"'); + + this.emit('requestSucceeded', { + response, + }); + } + + _requestFailed(response, cause) { + logger.debug('REFER failed'); + + logger.debug('emit "requestFailed"'); + + this.emit('requestFailed', { + response: response || null, + cause, + }); + } }; diff --git a/src/Registrator.d.ts b/src/Registrator.d.ts index 7d5d6a15..544ddd68 100644 --- a/src/Registrator.d.ts +++ b/src/Registrator.d.ts @@ -1,12 +1,12 @@ -import {UA} from './UA' -import {Transport} from './Transport' +import { UA } from './UA'; +import { Transport } from './Transport'; export type ExtraContactParams = Record; export class Registrator { - constructor(ua: UA, transport: Transport); + constructor(ua: UA, transport: Transport); - setExtraHeaders(extraHeaders: string[]): void; + setExtraHeaders(extraHeaders: string[]): void; - setExtraContactParams(extraContactParams: ExtraContactParams): void; + setExtraContactParams(extraContactParams: ExtraContactParams): void; } diff --git a/src/Registrator.js b/src/Registrator.js index 0a704656..7ce227b0 100644 --- a/src/Registrator.js +++ b/src/Registrator.js @@ -8,436 +8,406 @@ const logger = new Logger('Registrator'); const MIN_REGISTER_EXPIRES = 10; // In seconds. -module.exports = class Registrator -{ - constructor(ua, transport) - { - // Force reg_id to 1. - this._reg_id = 1; - - this._ua = ua; - this._transport = transport; - - this._registrar = ua.configuration.registrar_server; - this._expires = ua.configuration.register_expires; +module.exports = class Registrator { + constructor(ua, transport) { + // Force reg_id to 1. + this._reg_id = 1; - // Call-ID and CSeq values RFC3261 10.2. - this._call_id = Utils.createRandomToken(22); - this._cseq = 0; - - this._to_uri = ua.configuration.uri; - - this._registrationTimer = null; - - // Ongoing Register request. - this._registering = false; - - // Set status. - this._registered = false; - - // Contact header. - this._contact = this._ua.contact.toString(); - - // Sip.ice media feature tag (RFC 5768). - this._contact += ';+sip.ice'; - - // Custom headers for REGISTER and un-REGISTER. - this._extraHeaders = []; - - // Custom Contact header params for REGISTER and un-REGISTER. - this._extraContactParams = ''; - - // Contents of the sip.instance Contact header parameter. - this._sipInstance = `""`; - - this._contact += `;reg-id=${this._reg_id}`; - this._contact += `;+sip.instance=${this._sipInstance}`; - } - - get registered() - { - return this._registered; - } - - setExtraHeaders(extraHeaders) - { - if (!Array.isArray(extraHeaders)) - { - extraHeaders = []; - } - - this._extraHeaders = extraHeaders.slice(); - } - - setExtraContactParams(extraContactParams) - { - if (!(extraContactParams instanceof Object)) - { - extraContactParams = {}; - } - - // Reset it. - this._extraContactParams = ''; - - for (const param_key in extraContactParams) - { - if (Object.prototype.hasOwnProperty.call(extraContactParams, param_key)) - { - const param_value = extraContactParams[param_key]; - - this._extraContactParams += (`;${param_key}`); - if (param_value) - { - this._extraContactParams += (`=${param_value}`); - } - } - } - } - - register() - { - if (this._registering) - { - logger.debug('Register request in progress...'); - - return; - } - - const extraHeaders = Utils.cloneArray(this._extraHeaders); - - let contactValue; - - if (this._expires) - { - contactValue = `${this._contact};expires=${this._expires}${this._extraContactParams}`; - extraHeaders.push(`Expires: ${this._expires}`); - } - else - { - contactValue = `${this._contact}${this._extraContactParams}`; - } - - extraHeaders.push(`Contact: ${contactValue}`); - - let fromTag = Utils.newTag(); - - if (this._ua.configuration.register_from_tag_trail) - { - if (typeof this._ua.configuration.register_from_tag_trail === 'function') - { - fromTag += this._ua.configuration.register_from_tag_trail(); - } - else - { - fromTag += this._ua.configuration.register_from_tag_trail; - } - } - - const request = new SIPMessage.OutgoingRequest( - JsSIP_C.REGISTER, this._registrar, this._ua, { - 'to_uri' : this._to_uri, - 'call_id' : this._call_id, - 'cseq' : (this._cseq += 1), - 'from_tag' : fromTag - }, extraHeaders); - - const request_sender = new RequestSender(this._ua, request, { - onRequestTimeout : () => - { - this._registrationFailure(null, JsSIP_C.causes.REQUEST_TIMEOUT); - }, - onTransportError : () => - { - this._registrationFailure(null, JsSIP_C.causes.CONNECTION_ERROR); - }, - // Increase the CSeq on authentication. - onAuthenticated : () => - { - this._cseq += 1; - }, - onReceiveResponse : (response) => - { - // Discard responses to older REGISTER/un-REGISTER requests. - if (response.cseq !== this._cseq) - { - return; - } - - // Clear registration timer. - if (this._registrationTimer !== null) - { - clearTimeout(this._registrationTimer); - this._registrationTimer = null; - } - - switch (true) - { - case /^1[0-9]{2}$/.test(response.status_code): - { - // Ignore provisional responses. - break; - } - - case /^2[0-9]{2}$/.test(response.status_code): - { - this._registering = false; - - if (!response.hasHeader('Contact')) - { - logger.debug('no Contact header in response to REGISTER, response ignored'); - - break; - } - - const contacts = response.headers['Contact'] - .reduce((a, b) => a.concat(b.parsed), []); - - // Get the Contact pointing to us and update the expires value accordingly. - // Try to find a matching Contact using sip.instance and reg-id. - let contact = contacts.find((element) => ( - (this._sipInstance === element.getParam('+sip.instance')) && - (this._reg_id === parseInt(element.getParam('reg-id'))) - )); - - // If no match was found using the sip.instance try comparing the URIs. - if (!contact) - { - contact = contacts.find((element) => ( - (element.uri.user === this._ua.contact.uri.user) - )); - } - - if (!contact) - { - logger.debug('no Contact header pointing to us, response ignored'); - - break; - } - - let expires = contact.getParam('expires'); - - if (!expires && response.hasHeader('expires')) - { - expires = response.getHeader('expires'); - } - - if (!expires) - { - expires = this._expires; - } - - expires = Number(expires); - - if (expires < MIN_REGISTER_EXPIRES) - expires = MIN_REGISTER_EXPIRES; - - const timeout = expires > 64 - ? (expires * 1000 / 2) + - Math.floor(((expires / 2) - 32) * 1000 * Math.random()) - : (expires * 1000) - 5000; - - // Re-Register or emit an event before the expiration interval has elapsed. - // For that, decrease the expires value. ie: 3 seconds. - this._registrationTimer = setTimeout(() => - { - this._registrationTimer = null; - // If there are no listeners for registrationExpiring, renew registration. - // If there are listeners, let the function listening do the register call. - if (this._ua.listeners('registrationExpiring').length === 0) - { - this.register(); - } - else - { - this._ua.emit('registrationExpiring'); - } - }, timeout); - - // Save gruu values. - if (contact.hasParam('temp-gruu')) - { - this._ua.contact.temp_gruu = contact.getParam('temp-gruu').replace(/"/g, ''); - } - if (contact.hasParam('pub-gruu')) - { - this._ua.contact.pub_gruu = contact.getParam('pub-gruu').replace(/"/g, ''); - } - - if (!this._registered) - { - this._registered = true; - this._ua.registered({ response }); - } - - break; - } - - // Interval too brief RFC3261 10.2.8. - case /^423$/.test(response.status_code): - { - if (response.hasHeader('min-expires')) - { - // Increase our registration interval to the suggested minimum. - this._expires = Number(response.getHeader('min-expires')); - - if (this._expires < MIN_REGISTER_EXPIRES) - this._expires = MIN_REGISTER_EXPIRES; - - // Assure register re-try with new expire. - this._registering = false; - - // Attempt the registration again immediately. - this.register(); - } - else - { // This response MUST contain a Min-Expires header field. - logger.debug('423 response received for REGISTER without Min-Expires'); - - this._registrationFailure(response, JsSIP_C.causes.SIP_FAILURE_CODE); - } - - break; - } - - default: - { - const cause = Utils.sipErrorCause(response.status_code); - - this._registrationFailure(response, cause); - } - } - } - }); - - this._registering = true; - request_sender.send(); - } - - unregister(options = {}) - { - if (!this._registered) - { - logger.debug('already unregistered'); - - return; - } - - this._registered = false; - - // Clear the registration timer. - if (this._registrationTimer !== null) - { - clearTimeout(this._registrationTimer); - this._registrationTimer = null; - } - - const extraHeaders = Utils.cloneArray(this._extraHeaders); - - if (options.all) - { - extraHeaders.push(`Contact: *${this._extraContactParams}`); - } - else - { - extraHeaders.push(`Contact: ${this._contact};expires=0${this._extraContactParams}`); - } - - extraHeaders.push('Expires: 0'); - - const request = new SIPMessage.OutgoingRequest( - JsSIP_C.REGISTER, this._registrar, this._ua, { - 'to_uri' : this._to_uri, - 'call_id' : this._call_id, - 'cseq' : (this._cseq += 1) - }, extraHeaders); - - const request_sender = new RequestSender(this._ua, request, { - onRequestTimeout : () => - { - this._unregistered(null, JsSIP_C.causes.REQUEST_TIMEOUT); - }, - onTransportError : () => - { - this._unregistered(null, JsSIP_C.causes.CONNECTION_ERROR); - }, - // Increase the CSeq on authentication. - onAuthenticated : () => - { - this._cseq += 1; - }, - onReceiveResponse : (response) => - { - switch (true) - { - case /^1[0-9]{2}$/.test(response.status_code): - // Ignore provisional responses. - break; - case /^2[0-9]{2}$/.test(response.status_code): - this._unregistered(response); - break; - default: - { - const cause = Utils.sipErrorCause(response.status_code); - - this._unregistered(response, cause); - } - } - } - }); - - request_sender.send(); - } - - close() - { - if (this._registered) - { - this.unregister(); - } - } - - - onTransportClosed() - { - this._registering = false; - if (this._registrationTimer !== null) - { - clearTimeout(this._registrationTimer); - this._registrationTimer = null; - } - - if (this._registered) - { - this._registered = false; - this._ua.unregistered({}); - } - } - - _registrationFailure(response, cause) - { - this._registering = false; - this._ua.registrationFailed({ - response : response || null, - cause - }); - - if (this._registered) - { - this._registered = false; - this._ua.unregistered({ - response : response || null, - cause - }); - } - } - - _unregistered(response, cause) - { - this._registering = false; - this._registered = false; - this._ua.unregistered({ - response : response || null, - cause : cause || null - }); - } + this._ua = ua; + this._transport = transport; + + this._registrar = ua.configuration.registrar_server; + this._expires = ua.configuration.register_expires; + + // Call-ID and CSeq values RFC3261 10.2. + this._call_id = Utils.createRandomToken(22); + this._cseq = 0; + + this._to_uri = ua.configuration.uri; + + this._registrationTimer = null; + + // Ongoing Register request. + this._registering = false; + + // Set status. + this._registered = false; + + // Contact header. + this._contact = this._ua.contact.toString(); + + // Sip.ice media feature tag (RFC 5768). + this._contact += ';+sip.ice'; + + // Custom headers for REGISTER and un-REGISTER. + this._extraHeaders = []; + + // Custom Contact header params for REGISTER and un-REGISTER. + this._extraContactParams = ''; + + // Contents of the sip.instance Contact header parameter. + this._sipInstance = `""`; + + this._contact += `;reg-id=${this._reg_id}`; + this._contact += `;+sip.instance=${this._sipInstance}`; + } + + get registered() { + return this._registered; + } + + setExtraHeaders(extraHeaders) { + if (!Array.isArray(extraHeaders)) { + extraHeaders = []; + } + + this._extraHeaders = extraHeaders.slice(); + } + + setExtraContactParams(extraContactParams) { + if (!(extraContactParams instanceof Object)) { + extraContactParams = {}; + } + + // Reset it. + this._extraContactParams = ''; + + for (const param_key in extraContactParams) { + if (Object.prototype.hasOwnProperty.call(extraContactParams, param_key)) { + const param_value = extraContactParams[param_key]; + + this._extraContactParams += `;${param_key}`; + if (param_value) { + this._extraContactParams += `=${param_value}`; + } + } + } + } + + register() { + if (this._registering) { + logger.debug('Register request in progress...'); + + return; + } + + const extraHeaders = Utils.cloneArray(this._extraHeaders); + + let contactValue; + + if (this._expires) { + contactValue = `${this._contact};expires=${this._expires}${this._extraContactParams}`; + extraHeaders.push(`Expires: ${this._expires}`); + } else { + contactValue = `${this._contact}${this._extraContactParams}`; + } + + extraHeaders.push(`Contact: ${contactValue}`); + + let fromTag = Utils.newTag(); + + if (this._ua.configuration.register_from_tag_trail) { + if ( + typeof this._ua.configuration.register_from_tag_trail === 'function' + ) { + fromTag += this._ua.configuration.register_from_tag_trail(); + } else { + fromTag += this._ua.configuration.register_from_tag_trail; + } + } + + const request = new SIPMessage.OutgoingRequest( + JsSIP_C.REGISTER, + this._registrar, + this._ua, + { + to_uri: this._to_uri, + call_id: this._call_id, + cseq: (this._cseq += 1), + from_tag: fromTag, + }, + extraHeaders + ); + + const request_sender = new RequestSender(this._ua, request, { + onRequestTimeout: () => { + this._registrationFailure(null, JsSIP_C.causes.REQUEST_TIMEOUT); + }, + onTransportError: () => { + this._registrationFailure(null, JsSIP_C.causes.CONNECTION_ERROR); + }, + // Increase the CSeq on authentication. + onAuthenticated: () => { + this._cseq += 1; + }, + onReceiveResponse: response => { + // Discard responses to older REGISTER/un-REGISTER requests. + if (response.cseq !== this._cseq) { + return; + } + + // Clear registration timer. + if (this._registrationTimer !== null) { + clearTimeout(this._registrationTimer); + this._registrationTimer = null; + } + + switch (true) { + case /^1[0-9]{2}$/.test(response.status_code): { + // Ignore provisional responses. + break; + } + + case /^2[0-9]{2}$/.test(response.status_code): { + this._registering = false; + + if (!response.hasHeader('Contact')) { + logger.debug( + 'no Contact header in response to REGISTER, response ignored' + ); + + break; + } + + const contacts = response.headers['Contact'].reduce( + (a, b) => a.concat(b.parsed), + [] + ); + + // Get the Contact pointing to us and update the expires value accordingly. + // Try to find a matching Contact using sip.instance and reg-id. + let contact = contacts.find( + element => + this._sipInstance === element.getParam('+sip.instance') && + this._reg_id === parseInt(element.getParam('reg-id')) + ); + + // If no match was found using the sip.instance try comparing the URIs. + if (!contact) { + contact = contacts.find( + element => element.uri.user === this._ua.contact.uri.user + ); + } + + if (!contact) { + logger.debug( + 'no Contact header pointing to us, response ignored' + ); + + break; + } + + let expires = contact.getParam('expires'); + + if (!expires && response.hasHeader('expires')) { + expires = response.getHeader('expires'); + } + + if (!expires) { + expires = this._expires; + } + + expires = Number(expires); + + if (expires < MIN_REGISTER_EXPIRES) { + expires = MIN_REGISTER_EXPIRES; + } + + const timeout = + expires > 64 + ? (expires * 1000) / 2 + + Math.floor((expires / 2 - 32) * 1000 * Math.random()) + : expires * 1000 - 5000; + + // Re-Register or emit an event before the expiration interval has elapsed. + // For that, decrease the expires value. ie: 3 seconds. + this._registrationTimer = setTimeout(() => { + this._registrationTimer = null; + // If there are no listeners for registrationExpiring, renew registration. + // If there are listeners, let the function listening do the register call. + if (this._ua.listeners('registrationExpiring').length === 0) { + this.register(); + } else { + this._ua.emit('registrationExpiring'); + } + }, timeout); + + // Save gruu values. + if (contact.hasParam('temp-gruu')) { + this._ua.contact.temp_gruu = contact + .getParam('temp-gruu') + .replace(/"/g, ''); + } + if (contact.hasParam('pub-gruu')) { + this._ua.contact.pub_gruu = contact + .getParam('pub-gruu') + .replace(/"/g, ''); + } + + if (!this._registered) { + this._registered = true; + this._ua.registered({ response }); + } + + break; + } + + // Interval too brief RFC3261 10.2.8. + case /^423$/.test(response.status_code): { + if (response.hasHeader('min-expires')) { + // Increase our registration interval to the suggested minimum. + this._expires = Number(response.getHeader('min-expires')); + + if (this._expires < MIN_REGISTER_EXPIRES) { + this._expires = MIN_REGISTER_EXPIRES; + } + + // Assure register re-try with new expire. + this._registering = false; + + // Attempt the registration again immediately. + this.register(); + } else { + // This response MUST contain a Min-Expires header field. + logger.debug( + '423 response received for REGISTER without Min-Expires' + ); + + this._registrationFailure( + response, + JsSIP_C.causes.SIP_FAILURE_CODE + ); + } + + break; + } + + default: { + const cause = Utils.sipErrorCause(response.status_code); + + this._registrationFailure(response, cause); + } + } + }, + }); + + this._registering = true; + request_sender.send(); + } + + unregister(options = {}) { + if (!this._registered) { + logger.debug('already unregistered'); + + return; + } + + this._registered = false; + + // Clear the registration timer. + if (this._registrationTimer !== null) { + clearTimeout(this._registrationTimer); + this._registrationTimer = null; + } + + const extraHeaders = Utils.cloneArray(this._extraHeaders); + + if (options.all) { + extraHeaders.push(`Contact: *${this._extraContactParams}`); + } else { + extraHeaders.push( + `Contact: ${this._contact};expires=0${this._extraContactParams}` + ); + } + + extraHeaders.push('Expires: 0'); + + const request = new SIPMessage.OutgoingRequest( + JsSIP_C.REGISTER, + this._registrar, + this._ua, + { + to_uri: this._to_uri, + call_id: this._call_id, + cseq: (this._cseq += 1), + }, + extraHeaders + ); + + const request_sender = new RequestSender(this._ua, request, { + onRequestTimeout: () => { + this._unregistered(null, JsSIP_C.causes.REQUEST_TIMEOUT); + }, + onTransportError: () => { + this._unregistered(null, JsSIP_C.causes.CONNECTION_ERROR); + }, + // Increase the CSeq on authentication. + onAuthenticated: () => { + this._cseq += 1; + }, + onReceiveResponse: response => { + switch (true) { + case /^1[0-9]{2}$/.test(response.status_code): { + // Ignore provisional responses. + break; + } + case /^2[0-9]{2}$/.test(response.status_code): { + this._unregistered(response); + break; + } + default: { + const cause = Utils.sipErrorCause(response.status_code); + + this._unregistered(response, cause); + } + } + }, + }); + + request_sender.send(); + } + + close() { + if (this._registered) { + this.unregister(); + } + } + + onTransportClosed() { + this._registering = false; + if (this._registrationTimer !== null) { + clearTimeout(this._registrationTimer); + this._registrationTimer = null; + } + + if (this._registered) { + this._registered = false; + this._ua.unregistered({}); + } + } + + _registrationFailure(response, cause) { + this._registering = false; + this._ua.registrationFailed({ + response: response || null, + cause, + }); + + if (this._registered) { + this._registered = false; + this._ua.unregistered({ + response: response || null, + cause, + }); + } + } + + _unregistered(response, cause) { + this._registering = false; + this._registered = false; + this._ua.unregistered({ + response: response || null, + cause: cause || null, + }); + } }; diff --git a/src/RequestSender.js b/src/RequestSender.js index f4e55ba1..c7ddc358 100644 --- a/src/RequestSender.js +++ b/src/RequestSender.js @@ -7,163 +7,176 @@ const logger = new Logger('RequestSender'); // Default event handlers. const EventHandlers = { - onRequestTimeout : () => {}, - onTransportError : () => {}, - onReceiveResponse : () => {}, - onAuthenticated : () => {} + onRequestTimeout: () => {}, + onTransportError: () => {}, + onReceiveResponse: () => {}, + onAuthenticated: () => {}, }; -module.exports = class RequestSender -{ - constructor(ua, request, eventHandlers) - { - this._ua = ua; - this._eventHandlers = eventHandlers; - this._method = request.method; - this._request = request; - this._auth = null; - this._challenged = false; - this._staled = false; - - // Define the undefined handlers. - for (const handler in EventHandlers) - { - if (Object.prototype.hasOwnProperty.call(EventHandlers, handler)) - { - if (!this._eventHandlers[handler]) - { - this._eventHandlers[handler] = EventHandlers[handler]; - } - } - } - - // If ua is in closing process or even closed just allow sending Bye and ACK. - if (ua.status === ua.C.STATUS_USER_CLOSED && - (this._method !== JsSIP_C.BYE || this._method !== JsSIP_C.ACK)) - { - this._eventHandlers.onTransportError(); - } - } - - /** - * Create the client transaction and send the message. - */ - send() - { - const eventHandlers = { - onRequestTimeout : () => { this._eventHandlers.onRequestTimeout(); }, - onTransportError : () => { this._eventHandlers.onTransportError(); }, - onReceiveResponse : (response) => { this._receiveResponse(response); } - }; - - switch (this._method) - { - case 'INVITE': - this.clientTransaction = new Transactions.InviteClientTransaction( - this._ua, this._ua.transport, this._request, eventHandlers); - break; - case 'ACK': - this.clientTransaction = new Transactions.AckClientTransaction( - this._ua, this._ua.transport, this._request, eventHandlers); - break; - default: - this.clientTransaction = new Transactions.NonInviteClientTransaction( - this._ua, this._ua.transport, this._request, eventHandlers); - } - // If authorization JWT is present, use it. - if (this._ua._configuration.authorization_jwt) - { - this._request.setHeader('Authorization', this._ua._configuration.authorization_jwt); - } - - this.clientTransaction.send(); - } - - /** - * Called from client transaction when receiving a correct response to the request. - * Authenticate request if needed or pass the response back to the applicant. - */ - _receiveResponse(response) - { - let challenge; - let authorization_header_name; - const status_code = response.status_code; - - /* - * Authentication - * Authenticate once. _challenged_ flag used to avoid infinite authentications. - */ - if ((status_code === 401 || status_code === 407) && - (this._ua.configuration.password !== null || this._ua.configuration.ha1 !== null)) - { - - // Get and parse the appropriate WWW-Authenticate or Proxy-Authenticate header. - if (response.status_code === 401) - { - challenge = response.parseHeader('www-authenticate'); - authorization_header_name = 'authorization'; - } - else - { - challenge = response.parseHeader('proxy-authenticate'); - authorization_header_name = 'proxy-authorization'; - } - - // Verify it seems a valid challenge. - if (!challenge) - { - logger.debug(`${response.status_code} with wrong or missing challenge, cannot authenticate`); - this._eventHandlers.onReceiveResponse(response); - - return; - } - - if (!this._challenged || (!this._staled && challenge.stale === true)) - { - if (!this._auth) - { - this._auth = new DigestAuthentication({ - username : this._ua.configuration.authorization_user, - password : this._ua.configuration.password, - realm : this._ua.configuration.realm, - ha1 : this._ua.configuration.ha1 - }); - } - - // Verify that the challenge is really valid. - if (!this._auth.authenticate(this._request, challenge)) - { - this._eventHandlers.onReceiveResponse(response); - - return; - } - this._challenged = true; - - // Update ha1 and realm in the UA. - this._ua.set('realm', this._auth.get('realm')); - this._ua.set('ha1', this._auth.get('ha1')); - - if (challenge.stale) - { - this._staled = true; - } - - this._request = this._request.clone(); - this._request.cseq += 1; - this._request.setHeader('cseq', `${this._request.cseq} ${this._method}`); - this._request.setHeader(authorization_header_name, this._auth.toString()); - - this._eventHandlers.onAuthenticated(this._request); - this.send(); - } - else - { - this._eventHandlers.onReceiveResponse(response); - } - } - else - { - this._eventHandlers.onReceiveResponse(response); - } - } +module.exports = class RequestSender { + constructor(ua, request, eventHandlers) { + this._ua = ua; + this._eventHandlers = eventHandlers; + this._method = request.method; + this._request = request; + this._auth = null; + this._challenged = false; + this._staled = false; + + // Define the undefined handlers. + for (const handler in EventHandlers) { + if (Object.prototype.hasOwnProperty.call(EventHandlers, handler)) { + if (!this._eventHandlers[handler]) { + this._eventHandlers[handler] = EventHandlers[handler]; + } + } + } + + // If ua is in closing process or even closed just allow sending Bye and ACK. + if ( + ua.status === ua.C.STATUS_USER_CLOSED && + (this._method !== JsSIP_C.BYE || this._method !== JsSIP_C.ACK) + ) { + this._eventHandlers.onTransportError(); + } + } + + /** + * Create the client transaction and send the message. + */ + send() { + const eventHandlers = { + onRequestTimeout: () => { + this._eventHandlers.onRequestTimeout(); + }, + onTransportError: () => { + this._eventHandlers.onTransportError(); + }, + onReceiveResponse: response => { + this._receiveResponse(response); + }, + }; + + switch (this._method) { + case 'INVITE': { + this.clientTransaction = new Transactions.InviteClientTransaction( + this._ua, + this._ua.transport, + this._request, + eventHandlers + ); + break; + } + case 'ACK': { + this.clientTransaction = new Transactions.AckClientTransaction( + this._ua, + this._ua.transport, + this._request, + eventHandlers + ); + break; + } + default: { + this.clientTransaction = new Transactions.NonInviteClientTransaction( + this._ua, + this._ua.transport, + this._request, + eventHandlers + ); + } + } + // If authorization JWT is present, use it. + if (this._ua._configuration.authorization_jwt) { + this._request.setHeader( + 'Authorization', + this._ua._configuration.authorization_jwt + ); + } + + this.clientTransaction.send(); + } + + /** + * Called from client transaction when receiving a correct response to the request. + * Authenticate request if needed or pass the response back to the applicant. + */ + _receiveResponse(response) { + let challenge; + let authorization_header_name; + const status_code = response.status_code; + + /* + * Authentication + * Authenticate once. _challenged_ flag used to avoid infinite authentications. + */ + if ( + (status_code === 401 || status_code === 407) && + (this._ua.configuration.password !== null || + this._ua.configuration.ha1 !== null) + ) { + // Get and parse the appropriate WWW-Authenticate or Proxy-Authenticate header. + if (response.status_code === 401) { + challenge = response.parseHeader('www-authenticate'); + authorization_header_name = 'authorization'; + } else { + challenge = response.parseHeader('proxy-authenticate'); + authorization_header_name = 'proxy-authorization'; + } + + // Verify it seems a valid challenge. + if (!challenge) { + logger.debug( + `${response.status_code} with wrong or missing challenge, cannot authenticate` + ); + this._eventHandlers.onReceiveResponse(response); + + return; + } + + if (!this._challenged || (!this._staled && challenge.stale === true)) { + if (!this._auth) { + this._auth = new DigestAuthentication({ + username: this._ua.configuration.authorization_user, + password: this._ua.configuration.password, + realm: this._ua.configuration.realm, + ha1: this._ua.configuration.ha1, + }); + } + + // Verify that the challenge is really valid. + if (!this._auth.authenticate(this._request, challenge)) { + this._eventHandlers.onReceiveResponse(response); + + return; + } + this._challenged = true; + + // Update ha1 and realm in the UA. + this._ua.set('realm', this._auth.get('realm')); + this._ua.set('ha1', this._auth.get('ha1')); + + if (challenge.stale) { + this._staled = true; + } + + this._request = this._request.clone(); + this._request.cseq += 1; + this._request.setHeader( + 'cseq', + `${this._request.cseq} ${this._method}` + ); + this._request.setHeader( + authorization_header_name, + this._auth.toString() + ); + + this._eventHandlers.onAuthenticated(this._request); + this.send(); + } else { + this._eventHandlers.onReceiveResponse(response); + } + } else { + this._eventHandlers.onReceiveResponse(response); + } + } }; diff --git a/src/SIPMessage.d.ts b/src/SIPMessage.d.ts index 9b737332..31a20a98 100644 --- a/src/SIPMessage.d.ts +++ b/src/SIPMessage.d.ts @@ -1,52 +1,52 @@ -import {NameAddrHeader} from './NameAddrHeader' -import {URI} from './URI' +import { NameAddrHeader } from './NameAddrHeader'; +import { URI } from './URI'; declare class IncomingMessage { - method: string - from: NameAddrHeader - to: NameAddrHeader - body: string + method: string; + from: NameAddrHeader; + to: NameAddrHeader; + body: string; - constructor(); + constructor(); - countHeader(name: string): number; + countHeader(name: string): number; - getHeader(name: string): string; + getHeader(name: string): string; - getHeaders(name: string): string[]; + getHeaders(name: string): string[]; - hasHeader(name: string): boolean; + hasHeader(name: string): boolean; - parseHeader(name: string, idx?: number): T; + parseHeader(name: string, idx?: number): T; - toString(): string; + toString(): string; } export class IncomingRequest extends IncomingMessage { - ruri: URI + ruri: URI; } export class IncomingResponse extends IncomingMessage { - status_code: number - reason_phrase: string + status_code: number; + reason_phrase: string; } export class OutgoingRequest { - method: string - ruri: URI - cseq: number - call_id: string - from: NameAddrHeader - to: NameAddrHeader - body: string + method: string; + ruri: URI; + cseq: number; + call_id: string; + from: NameAddrHeader; + to: NameAddrHeader; + body: string; - setHeader(name: string, value: string | string[]): void; + setHeader(name: string, value: string | string[]): void; - getHeader(name: string): string; + getHeader(name: string): string; - getHeaders(name: string): string[]; + getHeaders(name: string): string[]; - hasHeader(name: string): boolean; + hasHeader(name: string): boolean; - toString(): string; + toString(): string; } diff --git a/src/SIPMessage.js b/src/SIPMessage.js index 15d46e54..e2d205f8 100644 --- a/src/SIPMessage.js +++ b/src/SIPMessage.js @@ -17,768 +17,668 @@ const logger = new Logger('SIPMessage'); * -param {Object} [headers] extra headers * -param {String} [body] */ -class OutgoingRequest -{ - constructor(method, ruri, ua, params, extraHeaders, body) - { - // Mandatory parameters check. - if (!method || !ruri || !ua) - { - return null; - } - - params = params || {}; - - this.ua = ua; - this.headers = {}; - this.method = method; - this.ruri = ruri; - this.body = body; - this.extraHeaders = Utils.cloneArray(extraHeaders); - - if (this.ua.configuration.extra_headers) - { - this.extraHeaders = this.extraHeaders.concat(this.ua.configuration.extra_headers); - } - - // Fill the Common SIP Request Headers. - - // Route. - if (params.route_set) - { - this.setHeader('route', params.route_set); - } - else if (ua.configuration.use_preloaded_route) - { - this.setHeader('route', `<${ua.transport.sip_uri};lr>`); - } - - // Via. - // Empty Via header. Will be filled by the client transaction. - this.setHeader('via', ''); - - // Max-Forwards. - this.setHeader('max-forwards', JsSIP_C.MAX_FORWARDS); - - // To - const to_uri = params.to_uri || ruri; - const to_params = params.to_tag ? { tag: params.to_tag } : null; - const to_display_name = typeof params.to_display_name !== 'undefined' ? params.to_display_name : null; - - this.to = new NameAddrHeader(to_uri, to_display_name, to_params); - this.setHeader('to', this.to.toString()); - - // From. - const from_uri = params.from_uri || ua.configuration.uri; - const from_params = { tag: params.from_tag || Utils.newTag() }; - let display_name; - - if (typeof params.from_display_name !== 'undefined') - { - display_name = params.from_display_name; - } - else if (ua.configuration.display_name) - { - display_name = ua.configuration.display_name; - } - else - { - display_name = null; - } - - this.from = new NameAddrHeader(from_uri, display_name, from_params); - this.setHeader('from', this.from.toString()); - - // Call-ID. - const call_id = params.call_id || - (ua.configuration.jssip_id + Utils.createRandomToken(15)); - - this.call_id = call_id; - this.setHeader('call-id', call_id); - - // CSeq. - const cseq = params.cseq || Math.floor(Math.random() * 10000); - - this.cseq = cseq; - this.setHeader('cseq', `${cseq} ${method}`); - } - - /** - * Replace the the given header by the given value. - * -param {String} name header name - * -param {String | Array} value header value - */ - setHeader(name, value) - { - // Remove the header from extraHeaders if present. - const regexp = new RegExp(`^\\s*${name}\\s*:`, 'i'); - - for (let idx=0; idx`); + } + + // Via. + // Empty Via header. Will be filled by the client transaction. + this.setHeader('via', ''); + + // Max-Forwards. + this.setHeader('max-forwards', JsSIP_C.MAX_FORWARDS); + + // To + const to_uri = params.to_uri || ruri; + const to_params = params.to_tag ? { tag: params.to_tag } : null; + const to_display_name = + typeof params.to_display_name !== 'undefined' + ? params.to_display_name + : null; + + this.to = new NameAddrHeader(to_uri, to_display_name, to_params); + this.setHeader('to', this.to.toString()); + + // From. + const from_uri = params.from_uri || ua.configuration.uri; + const from_params = { tag: params.from_tag || Utils.newTag() }; + let display_name; + + if (typeof params.from_display_name !== 'undefined') { + display_name = params.from_display_name; + } else if (ua.configuration.display_name) { + display_name = ua.configuration.display_name; + } else { + display_name = null; + } + + this.from = new NameAddrHeader(from_uri, display_name, from_params); + this.setHeader('from', this.from.toString()); + + // Call-ID. + const call_id = + params.call_id || ua.configuration.jssip_id + Utils.createRandomToken(15); + + this.call_id = call_id; + this.setHeader('call-id', call_id); + + // CSeq. + const cseq = params.cseq || Math.floor(Math.random() * 10000); + + this.cseq = cseq; + this.setHeader('cseq', `${cseq} ${method}`); + } + + /** + * Replace the the given header by the given value. + * -param {String} name header name + * -param {String | Array} value header value + */ + setHeader(name, value) { + // Remove the header from extraHeaders if present. + const regexp = new RegExp(`^\\s*${name}\\s*:`, 'i'); + + for (let idx = 0; idx < this.extraHeaders.length; idx++) { + if (regexp.test(this.extraHeaders[idx])) { + this.extraHeaders.splice(idx, 1); + } + } + + this.headers[Utils.headerize(name)] = Array.isArray(value) + ? value + : [value]; + } + + /** + * Get the value of the given header name at the given position. + * -param {String} name header name + * -returns {String|undefined} Returns the specified header, null if header doesn't exist. + */ + getHeader(name) { + const headers = this.headers[Utils.headerize(name)]; + + if (headers) { + if (headers[0]) { + return headers[0]; + } + } else { + const regexp = new RegExp(`^\\s*${name}\\s*:`, 'i'); + + for (const header of this.extraHeaders) { + if (regexp.test(header)) { + return header.substring(header.indexOf(':') + 1).trim(); + } + } + } + + return; + } + + /** + * Get the header/s of the given name. + * -param {String} name header name + * -returns {Array} Array with all the headers of the specified name. + */ + getHeaders(name) { + const headers = this.headers[Utils.headerize(name)]; + const result = []; + + if (headers) { + for (const header of headers) { + result.push(header); + } + + return result; + } else { + const regexp = new RegExp(`^\\s*${name}\\s*:`, 'i'); + + for (const header of this.extraHeaders) { + if (regexp.test(header)) { + result.push(header.substring(header.indexOf(':') + 1).trim()); + } + } + + return result; + } + } + + /** + * Verify the existence of the given header. + * -param {String} name header name + * -returns {boolean} true if header with given name exists, false otherwise + */ + hasHeader(name) { + if (this.headers[Utils.headerize(name)]) { + return true; + } else { + const regexp = new RegExp(`^\\s*${name}\\s*:`, 'i'); + + for (const header of this.extraHeaders) { + if (regexp.test(header)) { + return true; + } + } + } + + return false; + } + + /** + * Parse the current body as a SDP and store the resulting object + * into this.sdp. + * -param {Boolean} force: Parse even if this.sdp already exists. + * + * Returns this.sdp. + */ + parseSDP(force) { + if (!force && this.sdp) { + return this.sdp; + } else { + this.sdp = sdp_transform.parse(this.body || ''); + + return this.sdp; + } + } + + toString() { + let msg = `${this.method} ${this.ruri} SIP/2.0\r\n`; + + for (const headerName in this.headers) { + if (Object.prototype.hasOwnProperty.call(this.headers, headerName)) { + for (const headerValue of this.headers[headerName]) { + msg += `${headerName}: ${headerValue}\r\n`; + } + } + } + + for (const header of this.extraHeaders) { + msg += `${header.trim()}\r\n`; + } + + // Supported. + const supported = []; + + switch (this.method) { + case JsSIP_C.REGISTER: { + supported.push('path', 'gruu'); + break; + } + case JsSIP_C.INVITE: { + if (this.ua.configuration.session_timers) { + supported.push('timer'); + } + if (this.ua.contact.pub_gruu || this.ua.contact.temp_gruu) { + supported.push('gruu'); + } + supported.push('ice', 'replaces'); + break; + } + case JsSIP_C.UPDATE: { + if (this.ua.configuration.session_timers) { + supported.push('timer'); + } + supported.push('ice'); + break; + } + } + + supported.push('outbound'); + + const userAgent = this.ua.configuration.user_agent || JsSIP_C.USER_AGENT; + + // Allow. + msg += `Allow: ${JsSIP_C.ALLOWED_METHODS}\r\n`; + msg += `Supported: ${supported}\r\n`; + msg += `User-Agent: ${userAgent}\r\n`; + + if (this.body) { + const length = Utils.str_utf8_length(this.body); + + msg += `Content-Length: ${length}\r\n\r\n`; + msg += this.body; + } else { + msg += 'Content-Length: 0\r\n\r\n'; + } + + return msg; + } + + clone() { + const request = new OutgoingRequest(this.method, this.ruri, this.ua); + + Object.keys(this.headers).forEach(function (name) { + request.headers[name] = this.headers[name].slice(); + }, this); + + request.body = this.body; + request.extraHeaders = Utils.cloneArray(this.extraHeaders); + request.to = this.to; + request.from = this.from; + request.call_id = this.call_id; + request.cseq = this.cseq; + + return request; + } } -class InitialOutgoingInviteRequest extends OutgoingRequest -{ - constructor(ruri, ua, params, extraHeaders, body) - { - super(JsSIP_C.INVITE, ruri, ua, params, extraHeaders, body); +class InitialOutgoingInviteRequest extends OutgoingRequest { + constructor(ruri, ua, params, extraHeaders, body) { + super(JsSIP_C.INVITE, ruri, ua, params, extraHeaders, body); - this.transaction = null; - } + this.transaction = null; + } - cancel(reason) - { - this.transaction.cancel(reason); - } + cancel(reason) { + this.transaction.cancel(reason); + } - clone() - { - const request = new InitialOutgoingInviteRequest(this.ruri, this.ua); + clone() { + const request = new InitialOutgoingInviteRequest(this.ruri, this.ua); - Object.keys(this.headers).forEach(function(name) - { - request.headers[name] = this.headers[name].slice(); - }, this); + Object.keys(this.headers).forEach(function (name) { + request.headers[name] = this.headers[name].slice(); + }, this); - request.body = this.body; - request.extraHeaders = Utils.cloneArray(this.extraHeaders); - request.to = this.to; - request.from = this.from; - request.call_id = this.call_id; - request.cseq = this.cseq; + request.body = this.body; + request.extraHeaders = Utils.cloneArray(this.extraHeaders); + request.to = this.to; + request.from = this.from; + request.call_id = this.call_id; + request.cseq = this.cseq; - request.transaction = this.transaction; + request.transaction = this.transaction; - return request; - } + return request; + } } -class IncomingMessage -{ - constructor() - { - this.data = null; - this.headers = null; - this.method = null; - this.via = null; - this.via_branch = null; - this.call_id = null; - this.cseq = null; - this.from = null; - this.from_tag = null; - this.to = null; - this.to_tag = null; - this.body = null; - this.sdp = null; - } - - /** - * Insert a header of the given name and value into the last position of the - * header array. - */ - addHeader(name, value) - { - const header = { raw: value }; - - name = Utils.headerize(name); - - if (this.headers[name]) - { - this.headers[name].push(header); - } - else - { - this.headers[name] = [ header ]; - } - } - - /** - * Get the value of the given header name at the given position. - */ - getHeader(name) - { - const header = this.headers[Utils.headerize(name)]; - - if (header) - { - if (header[0]) - { - return header[0].raw; - } - } - else - { - return; - } - } - - /** - * Get the header/s of the given name. - */ - getHeaders(name) - { - const headers = this.headers[Utils.headerize(name)]; - const result = []; - - if (!headers) - { - return []; - } - - for (const header of headers) - { - result.push(header.raw); - } - - return result; - } - - /** - * Verify the existence of the given header. - */ - hasHeader(name) - { - return (this.headers[Utils.headerize(name)]) ? true : false; - } - - /** - * Parse the given header on the given index. - * -param {String} name header name - * -param {Number} [idx=0] header index - * -returns {Object|undefined} Parsed header object, undefined if the header - * is not present or in case of a parsing error. - */ - parseHeader(name, idx = 0) - { - name = Utils.headerize(name); - - if (!this.headers[name]) - { - logger.debug(`header "${name}" not present`); - - return; - } - else if (idx >= this.headers[name].length) - { - logger.debug(`not so many "${name}" headers present`); - - return; - } - - const header = this.headers[name][idx]; - const value = header.raw; - - if (header.parsed) - { - return header.parsed; - } - - // Substitute '-' by '_' for grammar rule matching. - const parsed = Grammar.parse(value, name.replace(/-/g, '_')); - - if (parsed === -1) - { - this.headers[name].splice(idx, 1); // delete from headers - logger.debug(`error parsing "${name}" header field with value "${value}"`); - - return; - } - else - { - header.parsed = parsed; - - return parsed; - } - } - - /** - * Message Header attribute selector. Alias of parseHeader. - * -param {String} name header name - * -param {Number} [idx=0] header index - * -returns {Object|undefined} Parsed header object, undefined if the header - * is not present or in case of a parsing error. - * - * -example - * message.s('via',3).port - */ - s(name, idx) - { - return this.parseHeader(name, idx); - } - - /** - * Replace the value of the given header by the value. - * -param {String} name header name - * -param {String} value header value - */ - setHeader(name, value) - { - const header = { raw: value }; - - this.headers[Utils.headerize(name)] = [ header ]; - } - - /** - * Parse the current body as a SDP and store the resulting object - * into this.sdp. - * -param {Boolean} force: Parse even if this.sdp already exists. - * - * Returns this.sdp. - */ - parseSDP(force) - { - if (!force && this.sdp) - { - return this.sdp; - } - else - { - this.sdp = sdp_transform.parse(this.body || ''); - - return this.sdp; - } - } - - toString() - { - return this.data; - } +class IncomingMessage { + constructor() { + this.data = null; + this.headers = null; + this.method = null; + this.via = null; + this.via_branch = null; + this.call_id = null; + this.cseq = null; + this.from = null; + this.from_tag = null; + this.to = null; + this.to_tag = null; + this.body = null; + this.sdp = null; + } + + /** + * Insert a header of the given name and value into the last position of the + * header array. + */ + addHeader(name, value) { + const header = { raw: value }; + + name = Utils.headerize(name); + + if (this.headers[name]) { + this.headers[name].push(header); + } else { + this.headers[name] = [header]; + } + } + + /** + * Get the value of the given header name at the given position. + */ + getHeader(name) { + const header = this.headers[Utils.headerize(name)]; + + if (header) { + if (header[0]) { + return header[0].raw; + } + } else { + return; + } + } + + /** + * Get the header/s of the given name. + */ + getHeaders(name) { + const headers = this.headers[Utils.headerize(name)]; + const result = []; + + if (!headers) { + return []; + } + + for (const header of headers) { + result.push(header.raw); + } + + return result; + } + + /** + * Verify the existence of the given header. + */ + hasHeader(name) { + return this.headers[Utils.headerize(name)] ? true : false; + } + + /** + * Parse the given header on the given index. + * -param {String} name header name + * -param {Number} [idx=0] header index + * -returns {Object|undefined} Parsed header object, undefined if the header + * is not present or in case of a parsing error. + */ + parseHeader(name, idx = 0) { + name = Utils.headerize(name); + + if (!this.headers[name]) { + logger.debug(`header "${name}" not present`); + + return; + } else if (idx >= this.headers[name].length) { + logger.debug(`not so many "${name}" headers present`); + + return; + } + + const header = this.headers[name][idx]; + const value = header.raw; + + if (header.parsed) { + return header.parsed; + } + + // Substitute '-' by '_' for grammar rule matching. + const parsed = Grammar.parse(value, name.replace(/-/g, '_')); + + if (parsed === -1) { + this.headers[name].splice(idx, 1); // delete from headers + logger.debug( + `error parsing "${name}" header field with value "${value}"` + ); + + return; + } else { + header.parsed = parsed; + + return parsed; + } + } + + /** + * Message Header attribute selector. Alias of parseHeader. + * -param {String} name header name + * -param {Number} [idx=0] header index + * -returns {Object|undefined} Parsed header object, undefined if the header + * is not present or in case of a parsing error. + * + * -example + * message.s('via',3).port + */ + s(name, idx) { + return this.parseHeader(name, idx); + } + + /** + * Replace the value of the given header by the value. + * -param {String} name header name + * -param {String} value header value + */ + setHeader(name, value) { + const header = { raw: value }; + + this.headers[Utils.headerize(name)] = [header]; + } + + /** + * Parse the current body as a SDP and store the resulting object + * into this.sdp. + * -param {Boolean} force: Parse even if this.sdp already exists. + * + * Returns this.sdp. + */ + parseSDP(force) { + if (!force && this.sdp) { + return this.sdp; + } else { + this.sdp = sdp_transform.parse(this.body || ''); + + return this.sdp; + } + } + + toString() { + return this.data; + } } -class IncomingRequest extends IncomingMessage -{ - constructor(ua) - { - super(); - - this.ua = ua; - this.headers = {}; - this.ruri = null; - this.transport = null; - this.server_transaction = null; - } - - /** - * Stateful reply. - * -param {Number} code status code - * -param {String} reason reason phrase - * -param {Object} headers extra headers - * -param {String} body body - * -param {Function} [onSuccess] onSuccess callback - * -param {Function} [onFailure] onFailure callback - */ - reply(code, reason, extraHeaders, body, onSuccess, onFailure) - { - const supported = []; - let to = this.getHeader('To'); - - code = code || null; - reason = reason || null; - - // Validate code and reason values. - if (!code || (code < 100 || code > 699)) - { - throw new TypeError(`Invalid status_code: ${code}`); - } - else if (reason && typeof reason !== 'string' && !(reason instanceof String)) - { - throw new TypeError(`Invalid reason_phrase: ${reason}`); - } - - reason = reason || JsSIP_C.REASON_PHRASE[code] || ''; - extraHeaders = Utils.cloneArray(extraHeaders); - - if (this.ua.configuration.extra_headers) - { - extraHeaders = extraHeaders.concat(this.ua.configuration.extra_headers); - } - - let response = `SIP/2.0 ${code} ${reason}\r\n`; - - if (this.method === JsSIP_C.INVITE && code > 100 && code <= 200) - { - const headers = this.getHeaders('record-route'); - - for (const header of headers) - { - response += `Record-Route: ${header}\r\n`; - } - } - - const vias = this.getHeaders('via'); - - for (const via of vias) - { - response += `Via: ${via}\r\n`; - } - - if (!this.to_tag && code > 100) - { - to += `;tag=${Utils.newTag()}`; - } - else if (this.to_tag && !this.s('to').hasParam('tag')) - { - to += `;tag=${this.to_tag}`; - } - - response += `To: ${to}\r\n`; - response += `From: ${this.getHeader('From')}\r\n`; - response += `Call-ID: ${this.call_id}\r\n`; - response += `CSeq: ${this.cseq} ${this.method}\r\n`; - - for (const header of extraHeaders) - { - response += `${header.trim()}\r\n`; - } - - // Supported. - switch (this.method) - { - case JsSIP_C.INVITE: - if (this.ua.configuration.session_timers) - { - supported.push('timer'); - } - if (this.ua.contact.pub_gruu || this.ua.contact.temp_gruu) - { - supported.push('gruu'); - } - supported.push('ice', 'replaces'); - break; - case JsSIP_C.UPDATE: - if (this.ua.configuration.session_timers) - { - supported.push('timer'); - } - if (body) - { - supported.push('ice'); - } - supported.push('replaces'); - } - - supported.push('outbound'); - - // Allow and Accept. - if (this.method === JsSIP_C.OPTIONS) - { - response += `Allow: ${JsSIP_C.ALLOWED_METHODS}\r\n`; - response += `Accept: ${JsSIP_C.ACCEPTED_BODY_TYPES}\r\n`; - } - else if (code === 405) - { - response += `Allow: ${JsSIP_C.ALLOWED_METHODS}\r\n`; - } - else if (code === 415) - { - response += `Accept: ${JsSIP_C.ACCEPTED_BODY_TYPES}\r\n`; - } - - response += `Supported: ${supported}\r\n`; - - if (body) - { - const length = Utils.str_utf8_length(body); - - response += 'Content-Type: application/sdp\r\n'; - response += `Content-Length: ${length}\r\n\r\n`; - response += body; - } - else - { - response += `Content-Length: ${0}\r\n\r\n`; - } - - this.server_transaction.receiveResponse(code, response, onSuccess, onFailure); - } - - /** - * Stateless reply. - * -param {Number} code status code - * -param {String} reason reason phrase - */ - reply_sl(code = null, reason = null) - { - const vias = this.getHeaders('via'); - - // Validate code and reason values. - if (!code || (code < 100 || code > 699)) - { - throw new TypeError(`Invalid status_code: ${code}`); - } - else if (reason && typeof reason !== 'string' && !(reason instanceof String)) - { - throw new TypeError(`Invalid reason_phrase: ${reason}`); - } - - reason = reason || JsSIP_C.REASON_PHRASE[code] || ''; - - let response = `SIP/2.0 ${code} ${reason}\r\n`; - - for (const via of vias) - { - response += `Via: ${via}\r\n`; - } - - let to = this.getHeader('To'); - - if (!this.to_tag && code > 100) - { - to += `;tag=${Utils.newTag()}`; - } - else if (this.to_tag && !this.s('to').hasParam('tag')) - { - to += `;tag=${this.to_tag}`; - } - - response += `To: ${to}\r\n`; - response += `From: ${this.getHeader('From')}\r\n`; - response += `Call-ID: ${this.call_id}\r\n`; - response += `CSeq: ${this.cseq} ${this.method}\r\n`; - - if (this.ua.configuration.extra_headers) - { - for (const header of this.ua.configuration.extra_headers) - { - response += `${header.trim()}\r\n`; - } - } - - response += `Content-Length: ${0}\r\n\r\n`; - - this.transport.send(response); - } +class IncomingRequest extends IncomingMessage { + constructor(ua) { + super(); + + this.ua = ua; + this.headers = {}; + this.ruri = null; + this.transport = null; + this.server_transaction = null; + } + + /** + * Stateful reply. + * -param {Number} code status code + * -param {String} reason reason phrase + * -param {Object} headers extra headers + * -param {String} body body + * -param {Function} [onSuccess] onSuccess callback + * -param {Function} [onFailure] onFailure callback + */ + reply(code, reason, extraHeaders, body, onSuccess, onFailure) { + const supported = []; + let to = this.getHeader('To'); + + code = code || null; + reason = reason || null; + + // Validate code and reason values. + if (!code || code < 100 || code > 699) { + throw new TypeError(`Invalid status_code: ${code}`); + } else if ( + reason && + typeof reason !== 'string' && + !(reason instanceof String) + ) { + throw new TypeError(`Invalid reason_phrase: ${reason}`); + } + + reason = reason || JsSIP_C.REASON_PHRASE[code] || ''; + extraHeaders = Utils.cloneArray(extraHeaders); + + if (this.ua.configuration.extra_headers) { + extraHeaders = extraHeaders.concat(this.ua.configuration.extra_headers); + } + + let response = `SIP/2.0 ${code} ${reason}\r\n`; + + if (this.method === JsSIP_C.INVITE && code > 100 && code <= 200) { + const headers = this.getHeaders('record-route'); + + for (const header of headers) { + response += `Record-Route: ${header}\r\n`; + } + } + + const vias = this.getHeaders('via'); + + for (const via of vias) { + response += `Via: ${via}\r\n`; + } + + if (!this.to_tag && code > 100) { + to += `;tag=${Utils.newTag()}`; + } else if (this.to_tag && !this.s('to').hasParam('tag')) { + to += `;tag=${this.to_tag}`; + } + + response += `To: ${to}\r\n`; + response += `From: ${this.getHeader('From')}\r\n`; + response += `Call-ID: ${this.call_id}\r\n`; + response += `CSeq: ${this.cseq} ${this.method}\r\n`; + + for (const header of extraHeaders) { + response += `${header.trim()}\r\n`; + } + + // Supported. + switch (this.method) { + case JsSIP_C.INVITE: { + if (this.ua.configuration.session_timers) { + supported.push('timer'); + } + if (this.ua.contact.pub_gruu || this.ua.contact.temp_gruu) { + supported.push('gruu'); + } + supported.push('ice', 'replaces'); + break; + } + case JsSIP_C.UPDATE: { + if (this.ua.configuration.session_timers) { + supported.push('timer'); + } + if (body) { + supported.push('ice'); + } + supported.push('replaces'); + } + } + + supported.push('outbound'); + + // Allow and Accept. + if (this.method === JsSIP_C.OPTIONS) { + response += `Allow: ${JsSIP_C.ALLOWED_METHODS}\r\n`; + response += `Accept: ${JsSIP_C.ACCEPTED_BODY_TYPES}\r\n`; + } else if (code === 405) { + response += `Allow: ${JsSIP_C.ALLOWED_METHODS}\r\n`; + } else if (code === 415) { + response += `Accept: ${JsSIP_C.ACCEPTED_BODY_TYPES}\r\n`; + } + + response += `Supported: ${supported}\r\n`; + + if (body) { + const length = Utils.str_utf8_length(body); + + response += 'Content-Type: application/sdp\r\n'; + response += `Content-Length: ${length}\r\n\r\n`; + response += body; + } else { + response += `Content-Length: ${0}\r\n\r\n`; + } + + this.server_transaction.receiveResponse( + code, + response, + onSuccess, + onFailure + ); + } + + /** + * Stateless reply. + * -param {Number} code status code + * -param {String} reason reason phrase + */ + reply_sl(code = null, reason = null) { + const vias = this.getHeaders('via'); + + // Validate code and reason values. + if (!code || code < 100 || code > 699) { + throw new TypeError(`Invalid status_code: ${code}`); + } else if ( + reason && + typeof reason !== 'string' && + !(reason instanceof String) + ) { + throw new TypeError(`Invalid reason_phrase: ${reason}`); + } + + reason = reason || JsSIP_C.REASON_PHRASE[code] || ''; + + let response = `SIP/2.0 ${code} ${reason}\r\n`; + + for (const via of vias) { + response += `Via: ${via}\r\n`; + } + + let to = this.getHeader('To'); + + if (!this.to_tag && code > 100) { + to += `;tag=${Utils.newTag()}`; + } else if (this.to_tag && !this.s('to').hasParam('tag')) { + to += `;tag=${this.to_tag}`; + } + + response += `To: ${to}\r\n`; + response += `From: ${this.getHeader('From')}\r\n`; + response += `Call-ID: ${this.call_id}\r\n`; + response += `CSeq: ${this.cseq} ${this.method}\r\n`; + + if (this.ua.configuration.extra_headers) { + for (const header of this.ua.configuration.extra_headers) { + response += `${header.trim()}\r\n`; + } + } + + response += `Content-Length: ${0}\r\n\r\n`; + + this.transport.send(response); + } } -class IncomingResponse extends IncomingMessage -{ - constructor() - { - super(); +class IncomingResponse extends IncomingMessage { + constructor() { + super(); - this.headers = {}; - this.status_code = null; - this.reason_phrase = null; - } + this.headers = {}; + this.status_code = null; + this.reason_phrase = null; + } } module.exports = { - OutgoingRequest, - InitialOutgoingInviteRequest, - IncomingRequest, - IncomingResponse + OutgoingRequest, + InitialOutgoingInviteRequest, + IncomingRequest, + IncomingResponse, }; diff --git a/src/Socket.d.ts b/src/Socket.d.ts index 6f63543b..034ac624 100644 --- a/src/Socket.d.ts +++ b/src/Socket.d.ts @@ -1,29 +1,29 @@ -export interface WeightedSocket { - socket: Socket; - weight: number +export interface WeightedSocket { + socket: Socket; + weight: number; } export class Socket { - get via_transport(): string; - set via_transport(value: string); + get via_transport(): string; + set via_transport(value: string); - get url(): string; + get url(): string; - get sip_uri(): string; + get sip_uri(): string; - connect(): void; + connect(): void; - disconnect(): void; + disconnect(): void; - send(message: string | ArrayBufferLike | Blob | ArrayBufferView): boolean; + send(message: string | ArrayBufferLike | Blob | ArrayBufferView): boolean; - isConnected(): boolean; + isConnected(): boolean; - isConnecting(): boolean; + isConnecting(): boolean; - onconnect(): void; + onconnect(): void; - ondisconnect(error: boolean, code?: number, reason?: string): void; + ondisconnect(error: boolean, code?: number, reason?: string): void; - ondata(event: T): void; + ondata(event: T): void; } diff --git a/src/Socket.js b/src/Socket.js index a45b4ad8..e5023605 100644 --- a/src/Socket.js +++ b/src/Socket.js @@ -23,65 +23,49 @@ const logger = new Logger('Socket'); * */ -exports.isSocket = (socket) => -{ - // Ignore if an array is given. - if (Array.isArray(socket)) - { - return false; - } +exports.isSocket = socket => { + // Ignore if an array is given. + if (Array.isArray(socket)) { + return false; + } - if (typeof socket === 'undefined') - { - logger.warn('undefined JsSIP.Socket instance'); + if (typeof socket === 'undefined') { + logger.warn('undefined JsSIP.Socket instance'); - return false; - } + return false; + } - // Check Properties. - try - { - if (!Utils.isString(socket.url)) - { - logger.warn('missing or invalid JsSIP.Socket url property'); - throw new Error('Missing or invalid JsSIP.Socket url property'); - } + // Check Properties. + try { + if (!Utils.isString(socket.url)) { + logger.warn('missing or invalid JsSIP.Socket url property'); + throw new Error('Missing or invalid JsSIP.Socket url property'); + } - if (!Utils.isString(socket.via_transport)) - { - logger.warn('missing or invalid JsSIP.Socket via_transport property'); - throw new Error('Missing or invalid JsSIP.Socket via_transport property'); - } + if (!Utils.isString(socket.via_transport)) { + logger.warn('missing or invalid JsSIP.Socket via_transport property'); + throw new Error('Missing or invalid JsSIP.Socket via_transport property'); + } - if (Grammar.parse(socket.sip_uri, 'SIP_URI') === -1) - { - logger.warn('missing or invalid JsSIP.Socket sip_uri property'); - throw new Error('missing or invalid JsSIP.Socket sip_uri property'); - } - } - // eslint-disable-next-line no-unused-vars - catch (error) - { - return false; - } + if (Grammar.parse(socket.sip_uri, 'SIP_URI') === -1) { + logger.warn('missing or invalid JsSIP.Socket sip_uri property'); + throw new Error('missing or invalid JsSIP.Socket sip_uri property'); + } + } catch (error) { + return false; + } - // Check Methods. - try - { - [ 'connect', 'disconnect', 'send' ].forEach((method) => - { - if (!Utils.isFunction(socket[method])) - { - logger.warn(`missing or invalid JsSIP.Socket method: ${method}`); - throw new Error(`Missing or invalid JsSIP.Socket method: ${method}`); - } - }); - } - // eslint-disable-next-line no-unused-vars - catch (error) - { - return false; - } + // Check Methods. + try { + ['connect', 'disconnect', 'send'].forEach(method => { + if (!Utils.isFunction(socket[method])) { + logger.warn(`missing or invalid JsSIP.Socket method: ${method}`); + throw new Error(`Missing or invalid JsSIP.Socket method: ${method}`); + } + }); + } catch (error) { + return false; + } - return true; + return true; }; diff --git a/src/Subscriber.d.ts b/src/Subscriber.d.ts index 060f0ea3..20bb0392 100644 --- a/src/Subscriber.d.ts +++ b/src/Subscriber.d.ts @@ -1,42 +1,60 @@ -import {EventEmitter} from 'events' -import {IncomingRequest} from './SIPMessage' -import {UA} from './UA' +import { EventEmitter } from 'events'; +import { IncomingRequest } from './SIPMessage'; +import { UA } from './UA'; declare enum SubscriberTerminatedCode { - SUBSCRIBE_RESPONSE_TIMEOUT = 0, - SUBSCRIBE_TRANSPORT_ERROR = 1, - SUBSCRIBE_NON_OK_RESPONSE = 2, - SUBSCRIBE_WRONG_OK_RESPONSE = 3, - SUBSCRIBE_AUTHENTICATION_FAILED = 4, - UNSUBSCRIBE_TIMEOUT = 5, - FINAL_NOTIFY_RECEIVED = 6, - WRONG_NOTIFY_RECEIVED = 7 + SUBSCRIBE_RESPONSE_TIMEOUT = 0, + SUBSCRIBE_TRANSPORT_ERROR = 1, + SUBSCRIBE_NON_OK_RESPONSE = 2, + SUBSCRIBE_WRONG_OK_RESPONSE = 3, + SUBSCRIBE_AUTHENTICATION_FAILED = 4, + UNSUBSCRIBE_TIMEOUT = 5, + FINAL_NOTIFY_RECEIVED = 6, + WRONG_NOTIFY_RECEIVED = 7, } export interface MessageEventMap { - pending: []; - accepted: []; - active: []; - terminated: [terminationCode: SubscriberTerminatedCode, reason: string | undefined, retryAfter: number | undefined]; - notify: [isFinal: boolean, request: IncomingRequest, body: string | undefined, contentType: string | undefined]; + pending: []; + accepted: []; + active: []; + terminated: [ + terminationCode: SubscriberTerminatedCode, + reason: string | undefined, + retryAfter: number | undefined, + ]; + notify: [ + isFinal: boolean, + request: IncomingRequest, + body: string | undefined, + contentType: string | undefined, + ]; } interface SubscriberOptions { - expires?: number; - contentType: string; - allowEvents?: string; - params?: Record; - extraHeaders?: Array; + expires?: number; + contentType: string; + allowEvents?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + params?: Record; + extraHeaders?: string[]; } export class Subscriber extends EventEmitter { - constructor(ua: UA, target: string, eventName: string, accept: string, options: SubscriberOptions) - subscribe(body?: string): void; - terminate(body?: string): void; - get state(): string; - get id(): string; - set data(_data: any); - get data(): any; - static get C(): typeof SubscriberTerminatedCode; - get C(): typeof SubscriberTerminatedCode; + constructor( + ua: UA, + target: string, + eventName: string, + accept: string, + options: SubscriberOptions + ); + subscribe(body?: string): void; + terminate(body?: string): void; + get state(): string; + get id(): string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + set data(_data: any); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + get data(): any; + static get C(): typeof SubscriberTerminatedCode; + get C(): typeof SubscriberTerminatedCode; } diff --git a/src/Subscriber.js b/src/Subscriber.js index f8d5fe94..13f8bde8 100644 --- a/src/Subscriber.js +++ b/src/Subscriber.js @@ -14,571 +14,515 @@ const logger = new Logger('Subscriber'); * Termination codes. */ const C = { - // Termination codes. - SUBSCRIBE_RESPONSE_TIMEOUT : 0, - SUBSCRIBE_TRANSPORT_ERROR : 1, - SUBSCRIBE_NON_OK_RESPONSE : 2, - SUBSCRIBE_WRONG_OK_RESPONSE : 3, - SUBSCRIBE_AUTHENTICATION_FAILED : 4, - UNSUBSCRIBE_TIMEOUT : 5, - FINAL_NOTIFY_RECEIVED : 6, - WRONG_NOTIFY_RECEIVED : 7, - - // Subscriber states. - STATE_PENDING : 0, - STATE_ACTIVE : 1, - STATE_TERMINATED : 2, - STATE_INIT : 3, - STATE_WAITING_NOTIFY : 4, - - // RFC 6665 3.1.1, default expires value. - DEFAULT_EXPIRES_SEC : 900 + // Termination codes. + SUBSCRIBE_RESPONSE_TIMEOUT: 0, + SUBSCRIBE_TRANSPORT_ERROR: 1, + SUBSCRIBE_NON_OK_RESPONSE: 2, + SUBSCRIBE_WRONG_OK_RESPONSE: 3, + SUBSCRIBE_AUTHENTICATION_FAILED: 4, + UNSUBSCRIBE_TIMEOUT: 5, + FINAL_NOTIFY_RECEIVED: 6, + WRONG_NOTIFY_RECEIVED: 7, + + // Subscriber states. + STATE_PENDING: 0, + STATE_ACTIVE: 1, + STATE_TERMINATED: 2, + STATE_INIT: 3, + STATE_WAITING_NOTIFY: 4, + + // RFC 6665 3.1.1, default expires value. + DEFAULT_EXPIRES_SEC: 900, }; /** * RFC 6665 Subscriber implementation. */ -module.exports = class Subscriber extends EventEmitter -{ - /** - * Expose C object. - */ - static get C() - { - return C; - } - - /** - * @param {UA} ua - reference to JsSIP.UA - * @param {string} target - * @param {string} eventName - Event header value. May end with optional ;id=xxx - * @param {string} accept - Accept header value. - * - * @param {SubscriberOption} options - optional parameters. - * @param {number} expires - Expires header value. Default is 900. - * @param {string} contentType - Content-Type header value. Used for SUBSCRIBE with body - * @param {string} allowEvents - Allow-Events header value. - * @param {RequestParams} params - Will have priority over ua.configuration. - * If set please define: to_uri, to_display_name, from_uri, from_display_name - * @param {Array} extraHeaders - Additional SIP headers. - */ - constructor(ua, target, eventName, accept, { expires, contentType, - allowEvents, params, extraHeaders }) - { - logger.debug('new'); - - super(); - - // Check that arguments are defined. - if (!target) - { - throw new TypeError('Not enough arguments: Missing target'); - } - - if (!eventName) - { - throw new TypeError('Not enough arguments: Missing eventName'); - } - - if (!accept) - { - throw new TypeError('Not enough arguments: Missing accept'); - } - - const event_header = Grammar.parse(eventName, 'Event'); - - if (event_header === -1) - { - throw new TypeError('Missing Event header field'); - } - - this._ua = ua; - this._target = target; - - if (!Utils.isDecimal(expires) || expires <= 0) - { - expires = C.DEFAULT_EXPIRES_SEC; - } - - this._expires = expires; - - // Used to subscribe with body. - this._content_type = contentType; - - // Set initial subscribe parameters. - this._params = Utils.cloneObject(params); - - if (!this._params.from_uri) - { - this._params.from_uri = this._ua.configuration.uri; - } - - this._params.from_tag = Utils.newTag(); - this._params.to_tag = null; - this._params.call_id = Utils.createRandomToken(20); - - // Create subscribe cseq if not defined custom cseq. - if (this._params.cseq === undefined) - { - this._params.cseq = Math.floor((Math.random() * 10000) + 1); - } - - // Subscriber state. - this._state = C.STATE_INIT; - - // Dialog. - this._dialog = null; - - // To refresh subscription. - this._expires_timer = null; - this._expires_timestamp = null; - - // To prevent duplicate terminated call. - this._terminated = false; - - this._event_name = event_header.event; - this._event_id = event_header.params && event_header.params.id; - - let eventValue = this._event_name; - - if (this._event_id) - { - eventValue += `;id=${this._event_id}`; - } - - this._headers = Utils.cloneArray(extraHeaders); - this._headers = this._headers.concat([ - `Event: ${eventValue}`, - `Expires: ${this._expires}`, - `Accept: ${accept}` - ]); - - if (!this._headers.find((header) => header.startsWith('Contact'))) - { - const contact = `Contact: ${this._ua._contact.toString()}`; - - this._headers.push(contact); - } - - if (allowEvents) - { - this._headers.push(`Allow-Events: ${allowEvents}`); - } - - // To enqueue SUBSCRIBE requests created before the reception of the initial subscribe OK response. - this._queue = []; - - // Custom session empty object for high level use. - this._data = {}; - } - - // Expose Subscriber constants as a property of the Subscriber instance. - get C() - { - return C; - } - - /** - * Get dialog state. - */ - get state() - { - return this._state; - } - - /** - * Get dialog id. - */ - get id() - { - return this._dialog ? this._dialog.id : null; - } - - get data() - { - return this._data; - } - - set data(_data) - { - this._data = _data; - } - - onRequestTimeout() - { - this._terminateDialog(C.SUBSCRIBE_RESPONSE_TIMEOUT); - } - - onTransportError() - { - this._terminateDialog(C.SUBSCRIBE_TRANSPORT_ERROR); - } - - /** - * Dialog callback. - */ - receiveRequest(request) - { - if (request.method !== JsSIP_C.NOTIFY) - { - logger.warn('received non-NOTIFY request'); - request.reply(405); - - return; - } - - // RFC 6665 8.2.1. Check if event header matches. - const event_header = request.parseHeader('Event'); - - if (!event_header) - { - logger.warn('missing Event header'); - request.reply(400); - this._terminateDialog(C.WRONG_NOTIFY_RECEIVED); - - return; - } - - const event_name = event_header.event; - const event_id = event_header.params && event_header.params.id; - - if (event_name !== this._event_name || event_id !== this._event_id) - { - logger.warn('Event header does not match the one in SUBSCRIBE request'); - request.reply(489); - this._terminateDialog(C.WRONG_NOTIFY_RECEIVED); - - return; - } - - // Process Subscription-State header. - const subs_state = request.parseHeader('subscription-state'); - - if (!subs_state) - { - logger.warn('missing Subscription-State header'); - request.reply(400); - this._terminateDialog(C.WRONG_NOTIFY_RECEIVED); - - return; - } - - const new_state = this._parseSubscriptionState(subs_state.state); - - if (new_state === undefined) - { - logger.warn(`Invalid Subscription-State header value: ${subs_state.state}`); - request.reply(400); - this._terminateDialog(C.WRONG_NOTIFY_RECEIVED); - - return; - } - request.reply(200); - - const prev_state = this._state; - - if (prev_state !== C.STATE_TERMINATED && new_state !== C.STATE_TERMINATED) - { - this._state = new_state; - - if (subs_state.expires !== undefined) - { - const expires = subs_state.expires; - const expires_timestamp = new Date().getTime() + (expires * 1000); - const max_time_deviation = 2000; - - // Expiration time is shorter and the difference is not too small. - if (this._expires_timestamp - expires_timestamp > max_time_deviation) - { - logger.debug('update sending re-SUBSCRIBE time'); - - this._scheduleSubscribe(expires); - } - } - } - - if (prev_state !== C.STATE_PENDING && new_state === C.STATE_PENDING) - { - logger.debug('emit "pending"'); - - this.emit('pending'); - } - else if (prev_state !== C.STATE_ACTIVE && new_state === C.STATE_ACTIVE) - { - logger.debug('emit "active"'); - - this.emit('active'); - } - - const body = request.body; - - // Check if the notify is final. - const is_final = new_state === C.STATE_TERMINATED; - - // Notify event fired only for notify with body. - if (body) - { - const content_type = request.getHeader('content-type'); - - logger.debug('emit "notify"'); - - this.emit('notify', is_final, request, body, content_type); - } - - if (is_final) - { - const reason = subs_state.reason; - let retry_after = undefined; - - if (subs_state.params && subs_state.params['retry-after'] !== undefined) - { - retry_after = parseInt(subs_state.params['retry-after']); - } - - this._terminateDialog(C.FINAL_NOTIFY_RECEIVED, reason, retry_after); - } - } - - /** - * User API - */ - - /** - * Send the initial (non-fetch) and subsequent subscribe. - * @param {string} body - subscribe request body. - */ - subscribe(body = null) - { - logger.debug('subscribe()'); - - if (this._state === C.STATE_INIT) - { - this._sendInitialSubscribe(body, this._headers); - } - else - { - this._sendSubsequentSubscribe(body, this._headers); - } - } - - /** - * terminate. - * Send un-subscribe or fetch-subscribe (with Expires: 0). - * @param {string} body - un-subscribe request body - */ - terminate(body = null) - { - logger.debug('terminate()'); - - if (this._state === C.STATE_INIT) - { - throw new Exceptions.InvalidStateError(this._state); - } - - // Prevent duplication un-subscribe sending. - if (this._terminated) - { - return; - } - this._terminated = true; - - // Set header Expires: 0. - const headers = this._headers.map((header) => - { - return header.startsWith('Expires') ? 'Expires: 0' : header; - }); - - this._sendSubsequentSubscribe(body, headers); - } - - /** - * Private API. - */ - _terminateDialog(terminationCode, reason = undefined, retryAfter = undefined) - { - // To prevent duplicate emit terminated event. - if (this._state === C.STATE_TERMINATED) - { - return; - } - - this._state = C.STATE_TERMINATED; - - // Clear timers. - clearTimeout(this._expires_timer); - - if (this._dialog) - { - this._dialog.terminate(); - this._dialog = null; - } - - logger.debug(`emit "terminated" code=${terminationCode}`); - - this.emit('terminated', terminationCode, reason, retryAfter); - } - - _sendInitialSubscribe(body, headers) - { - if (body) - { - if (!this._content_type) - { - throw new TypeError('content_type is undefined'); - } - - headers = Utils.cloneArray(headers); - headers.push(`Content-Type: ${this._content_type}`); - } - - this._state = C.STATE_WAITING_NOTIFY; - - const request = new SIPMessage.OutgoingRequest(JsSIP_C.SUBSCRIBE, - this._ua.normalizeTarget(this._target), this._ua, this._params, headers, body); - - const request_sender = new RequestSender(this._ua, request, { - onRequestTimeout : () => - { - this.onRequestTimeout(); - }, - onTransportError : () => - { - this.onTransportError(); - }, - onReceiveResponse : (response) => - { - this._receiveSubscribeResponse(response); - } - }); - - request_sender.send(); - } - - _sendSubsequentSubscribe(body, headers) - { - if (this._state === C.STATE_TERMINATED) - { - return; - } - - if (!this._dialog) - { - logger.debug('enqueue subscribe'); - - this._queue.push({ body, headers: Utils.cloneArray(headers) }); - - return; - } - - if (body) - { - if (!this._content_type) - { - throw new TypeError('content_type is undefined'); - } - - headers = Utils.cloneArray(headers); - headers.push(`Content-Type: ${this._content_type}`); - } - - this._dialog.sendRequest(JsSIP_C.SUBSCRIBE, { - body, - extraHeaders : headers, - eventHandlers : { - onRequestTimeout : () => - { - this.onRequestTimeout(); - }, - onTransportError : () => - { - this.onTransportError(); - }, - onSuccessResponse : (response) => - { - this._receiveSubscribeResponse(response); - }, - onErrorResponse : (response) => - { - this._receiveSubscribeResponse(response); - }, - onDialogError : (response) => - { - this._receiveSubscribeResponse(response); - } - } - }); - } - - _receiveSubscribeResponse(response) - { - if (this._state === C.STATE_TERMINATED) - { - return; - } - - if (response.status_code >= 200 && response.status_code < 300) - { - // Create dialog. - if (this._dialog === null) - { - const dialog = new Dialog(this, response, 'UAC'); - - if (dialog.error) - { - // OK response without Contact. - logger.warn(dialog.error); - this._terminateDialog(C.SUBSCRIBE_WRONG_OK_RESPONSE); - - return; - } - - this._dialog = dialog; - - logger.debug('emit "accepted"'); - - this.emit('accepted'); - - // Subsequent subscribes saved in the queue until dialog created. - for (const subscribe of this._queue) - { - logger.debug('dequeue subscribe'); - - this._sendSubsequentSubscribe(subscribe.body, subscribe.headers); - } - } - - // Check expires value. - const expires_value = response.getHeader('expires'); - - let expires = parseInt(expires_value); - - if (!Utils.isDecimal(expires) || expires <= 0) - { - logger.warn(`response without Expires header, setting a default value of ${C.DEFAULT_EXPIRES_SEC}`); - - // RFC 6665 3.1.1 subscribe OK response must contain Expires header. - // Use workaround expires value. - expires = C.DEFAULT_EXPIRES_SEC; - } - - if (expires > 0) - { - this._scheduleSubscribe(expires); - } - } - else if (response.status_code === 401 || response.status_code === 407) - { - this._terminateDialog(C.SUBSCRIBE_AUTHENTICATION_FAILED); - } - else if (response.status_code >= 300) - { - this._terminateDialog(C.SUBSCRIBE_NON_OK_RESPONSE); - } - } - - _scheduleSubscribe(expires) - { - /* +module.exports = class Subscriber extends EventEmitter { + /** + * Expose C object. + */ + static get C() { + return C; + } + + /** + * @param {UA} ua - reference to JsSIP.UA + * @param {string} target + * @param {string} eventName - Event header value. May end with optional ;id=xxx + * @param {string} accept - Accept header value. + * + * @param {SubscriberOption} options - optional parameters. + * @param {number} expires - Expires header value. Default is 900. + * @param {string} contentType - Content-Type header value. Used for SUBSCRIBE with body + * @param {string} allowEvents - Allow-Events header value. + * @param {RequestParams} params - Will have priority over ua.configuration. + * If set please define: to_uri, to_display_name, from_uri, from_display_name + * @param {Array} extraHeaders - Additional SIP headers. + */ + constructor( + ua, + target, + eventName, + accept, + { expires, contentType, allowEvents, params, extraHeaders } + ) { + logger.debug('new'); + + super(); + + // Check that arguments are defined. + if (!target) { + throw new TypeError('Not enough arguments: Missing target'); + } + + if (!eventName) { + throw new TypeError('Not enough arguments: Missing eventName'); + } + + if (!accept) { + throw new TypeError('Not enough arguments: Missing accept'); + } + + const event_header = Grammar.parse(eventName, 'Event'); + + if (event_header === -1) { + throw new TypeError('Missing Event header field'); + } + + this._ua = ua; + this._target = target; + + if (!Utils.isDecimal(expires) || expires <= 0) { + expires = C.DEFAULT_EXPIRES_SEC; + } + + this._expires = expires; + + // Used to subscribe with body. + this._content_type = contentType; + + // Set initial subscribe parameters. + this._params = Utils.cloneObject(params); + + if (!this._params.from_uri) { + this._params.from_uri = this._ua.configuration.uri; + } + + this._params.from_tag = Utils.newTag(); + this._params.to_tag = null; + this._params.call_id = Utils.createRandomToken(20); + + // Create subscribe cseq if not defined custom cseq. + if (this._params.cseq === undefined) { + this._params.cseq = Math.floor(Math.random() * 10000 + 1); + } + + // Subscriber state. + this._state = C.STATE_INIT; + + // Dialog. + this._dialog = null; + + // To refresh subscription. + this._expires_timer = null; + this._expires_timestamp = null; + + // To prevent duplicate terminated call. + this._terminated = false; + + this._event_name = event_header.event; + this._event_id = event_header.params && event_header.params.id; + + let eventValue = this._event_name; + + if (this._event_id) { + eventValue += `;id=${this._event_id}`; + } + + this._headers = Utils.cloneArray(extraHeaders); + this._headers = this._headers.concat([ + `Event: ${eventValue}`, + `Expires: ${this._expires}`, + `Accept: ${accept}`, + ]); + + if (!this._headers.find(header => header.startsWith('Contact'))) { + const contact = `Contact: ${this._ua._contact.toString()}`; + + this._headers.push(contact); + } + + if (allowEvents) { + this._headers.push(`Allow-Events: ${allowEvents}`); + } + + // To enqueue SUBSCRIBE requests created before the reception of the initial subscribe OK response. + this._queue = []; + + // Custom session empty object for high level use. + this._data = {}; + } + + // Expose Subscriber constants as a property of the Subscriber instance. + get C() { + return C; + } + + /** + * Get dialog state. + */ + get state() { + return this._state; + } + + /** + * Get dialog id. + */ + get id() { + return this._dialog ? this._dialog.id : null; + } + + get data() { + return this._data; + } + + set data(_data) { + this._data = _data; + } + + onRequestTimeout() { + this._terminateDialog(C.SUBSCRIBE_RESPONSE_TIMEOUT); + } + + onTransportError() { + this._terminateDialog(C.SUBSCRIBE_TRANSPORT_ERROR); + } + + /** + * Dialog callback. + */ + receiveRequest(request) { + if (request.method !== JsSIP_C.NOTIFY) { + logger.warn('received non-NOTIFY request'); + request.reply(405); + + return; + } + + // RFC 6665 8.2.1. Check if event header matches. + const event_header = request.parseHeader('Event'); + + if (!event_header) { + logger.warn('missing Event header'); + request.reply(400); + this._terminateDialog(C.WRONG_NOTIFY_RECEIVED); + + return; + } + + const event_name = event_header.event; + const event_id = event_header.params && event_header.params.id; + + if (event_name !== this._event_name || event_id !== this._event_id) { + logger.warn('Event header does not match the one in SUBSCRIBE request'); + request.reply(489); + this._terminateDialog(C.WRONG_NOTIFY_RECEIVED); + + return; + } + + // Process Subscription-State header. + const subs_state = request.parseHeader('subscription-state'); + + if (!subs_state) { + logger.warn('missing Subscription-State header'); + request.reply(400); + this._terminateDialog(C.WRONG_NOTIFY_RECEIVED); + + return; + } + + const new_state = this._parseSubscriptionState(subs_state.state); + + if (new_state === undefined) { + logger.warn( + `Invalid Subscription-State header value: ${subs_state.state}` + ); + request.reply(400); + this._terminateDialog(C.WRONG_NOTIFY_RECEIVED); + + return; + } + request.reply(200); + + const prev_state = this._state; + + if (prev_state !== C.STATE_TERMINATED && new_state !== C.STATE_TERMINATED) { + this._state = new_state; + + if (subs_state.expires !== undefined) { + const expires = subs_state.expires; + const expires_timestamp = new Date().getTime() + expires * 1000; + const max_time_deviation = 2000; + + // Expiration time is shorter and the difference is not too small. + if (this._expires_timestamp - expires_timestamp > max_time_deviation) { + logger.debug('update sending re-SUBSCRIBE time'); + + this._scheduleSubscribe(expires); + } + } + } + + if (prev_state !== C.STATE_PENDING && new_state === C.STATE_PENDING) { + logger.debug('emit "pending"'); + + this.emit('pending'); + } else if (prev_state !== C.STATE_ACTIVE && new_state === C.STATE_ACTIVE) { + logger.debug('emit "active"'); + + this.emit('active'); + } + + const body = request.body; + + // Check if the notify is final. + const is_final = new_state === C.STATE_TERMINATED; + + // Notify event fired only for notify with body. + if (body) { + const content_type = request.getHeader('content-type'); + + logger.debug('emit "notify"'); + + this.emit('notify', is_final, request, body, content_type); + } + + if (is_final) { + const reason = subs_state.reason; + let retry_after = undefined; + + if (subs_state.params && subs_state.params['retry-after'] !== undefined) { + retry_after = parseInt(subs_state.params['retry-after']); + } + + this._terminateDialog(C.FINAL_NOTIFY_RECEIVED, reason, retry_after); + } + } + + /** + * User API + */ + + /** + * Send the initial (non-fetch) and subsequent subscribe. + * @param {string} body - subscribe request body. + */ + subscribe(body = null) { + logger.debug('subscribe()'); + + if (this._state === C.STATE_INIT) { + this._sendInitialSubscribe(body, this._headers); + } else { + this._sendSubsequentSubscribe(body, this._headers); + } + } + + /** + * terminate. + * Send un-subscribe or fetch-subscribe (with Expires: 0). + * @param {string} body - un-subscribe request body + */ + terminate(body = null) { + logger.debug('terminate()'); + + if (this._state === C.STATE_INIT) { + throw new Exceptions.InvalidStateError(this._state); + } + + // Prevent duplication un-subscribe sending. + if (this._terminated) { + return; + } + this._terminated = true; + + // Set header Expires: 0. + const headers = this._headers.map(header => { + return header.startsWith('Expires') ? 'Expires: 0' : header; + }); + + this._sendSubsequentSubscribe(body, headers); + } + + /** + * Private API. + */ + _terminateDialog( + terminationCode, + reason = undefined, + retryAfter = undefined + ) { + // To prevent duplicate emit terminated event. + if (this._state === C.STATE_TERMINATED) { + return; + } + + this._state = C.STATE_TERMINATED; + + // Clear timers. + clearTimeout(this._expires_timer); + + if (this._dialog) { + this._dialog.terminate(); + this._dialog = null; + } + + logger.debug(`emit "terminated" code=${terminationCode}`); + + this.emit('terminated', terminationCode, reason, retryAfter); + } + + _sendInitialSubscribe(body, headers) { + if (body) { + if (!this._content_type) { + throw new TypeError('content_type is undefined'); + } + + headers = Utils.cloneArray(headers); + headers.push(`Content-Type: ${this._content_type}`); + } + + this._state = C.STATE_WAITING_NOTIFY; + + const request = new SIPMessage.OutgoingRequest( + JsSIP_C.SUBSCRIBE, + this._ua.normalizeTarget(this._target), + this._ua, + this._params, + headers, + body + ); + + const request_sender = new RequestSender(this._ua, request, { + onRequestTimeout: () => { + this.onRequestTimeout(); + }, + onTransportError: () => { + this.onTransportError(); + }, + onReceiveResponse: response => { + this._receiveSubscribeResponse(response); + }, + }); + + request_sender.send(); + } + + _sendSubsequentSubscribe(body, headers) { + if (this._state === C.STATE_TERMINATED) { + return; + } + + if (!this._dialog) { + logger.debug('enqueue subscribe'); + + this._queue.push({ body, headers: Utils.cloneArray(headers) }); + + return; + } + + if (body) { + if (!this._content_type) { + throw new TypeError('content_type is undefined'); + } + + headers = Utils.cloneArray(headers); + headers.push(`Content-Type: ${this._content_type}`); + } + + this._dialog.sendRequest(JsSIP_C.SUBSCRIBE, { + body, + extraHeaders: headers, + eventHandlers: { + onRequestTimeout: () => { + this.onRequestTimeout(); + }, + onTransportError: () => { + this.onTransportError(); + }, + onSuccessResponse: response => { + this._receiveSubscribeResponse(response); + }, + onErrorResponse: response => { + this._receiveSubscribeResponse(response); + }, + onDialogError: response => { + this._receiveSubscribeResponse(response); + }, + }, + }); + } + + _receiveSubscribeResponse(response) { + if (this._state === C.STATE_TERMINATED) { + return; + } + + if (response.status_code >= 200 && response.status_code < 300) { + // Create dialog. + if (this._dialog === null) { + const dialog = new Dialog(this, response, 'UAC'); + + if (dialog.error) { + // OK response without Contact. + logger.warn(dialog.error); + this._terminateDialog(C.SUBSCRIBE_WRONG_OK_RESPONSE); + + return; + } + + this._dialog = dialog; + + logger.debug('emit "accepted"'); + + this.emit('accepted'); + + // Subsequent subscribes saved in the queue until dialog created. + for (const subscribe of this._queue) { + logger.debug('dequeue subscribe'); + + this._sendSubsequentSubscribe(subscribe.body, subscribe.headers); + } + } + + // Check expires value. + const expires_value = response.getHeader('expires'); + + let expires = parseInt(expires_value); + + if (!Utils.isDecimal(expires) || expires <= 0) { + logger.warn( + `response without Expires header, setting a default value of ${C.DEFAULT_EXPIRES_SEC}` + ); + + // RFC 6665 3.1.1 subscribe OK response must contain Expires header. + // Use workaround expires value. + expires = C.DEFAULT_EXPIRES_SEC; + } + + if (expires > 0) { + this._scheduleSubscribe(expires); + } + } else if (response.status_code === 401 || response.status_code === 407) { + this._terminateDialog(C.SUBSCRIBE_AUTHENTICATION_FAILED); + } else if (response.status_code >= 300) { + this._terminateDialog(C.SUBSCRIBE_NON_OK_RESPONSE); + } + } + + _scheduleSubscribe(expires) { + /* If the expires time is less than 140 seconds we do not support Chrome intensive timer throttling mode. In this case, the re-subcribe is sent 5 seconds before the subscription expiration. @@ -592,31 +536,45 @@ module.exports = class Subscriber extends EventEmitter expires is 600, re-subscribe will be ordered to send in 300 + (0 .. 230) seconds. */ - const timeout = expires >= 140 ? (expires * 1000 / 2) - + Math.floor(((expires / 2) - 70) * 1000 * Math.random()) : (expires * 1000) - 5000; - - this._expires_timestamp = new Date().getTime() + (expires * 1000); - - logger.debug(`next SUBSCRIBE will be sent in ${Math.floor(timeout / 1000)} sec`); - - clearTimeout(this._expires_timer); - this._expires_timer = setTimeout(() => - { - this._expires_timer = null; - this._sendSubsequentSubscribe(null, this._headers); - }, timeout); - } - - _parseSubscriptionState(strState) - { - switch (strState) - { - case 'pending': return C.STATE_PENDING; - case 'active': return C.STATE_ACTIVE; - case 'terminated': return C.STATE_TERMINATED; - case 'init': return C.STATE_INIT; - case 'notify_wait': return C.STATE_WAITING_NOTIFY; - default: return undefined; - } - } + const timeout = + expires >= 140 + ? (expires * 1000) / 2 + + Math.floor((expires / 2 - 70) * 1000 * Math.random()) + : expires * 1000 - 5000; + + this._expires_timestamp = new Date().getTime() + expires * 1000; + + logger.debug( + `next SUBSCRIBE will be sent in ${Math.floor(timeout / 1000)} sec` + ); + + clearTimeout(this._expires_timer); + this._expires_timer = setTimeout(() => { + this._expires_timer = null; + this._sendSubsequentSubscribe(null, this._headers); + }, timeout); + } + + _parseSubscriptionState(strState) { + switch (strState) { + case 'pending': { + return C.STATE_PENDING; + } + case 'active': { + return C.STATE_ACTIVE; + } + case 'terminated': { + return C.STATE_TERMINATED; + } + case 'init': { + return C.STATE_INIT; + } + case 'notify_wait': { + return C.STATE_WAITING_NOTIFY; + } + default: { + return undefined; + } + } + } }; diff --git a/src/Timers.js b/src/Timers.js index 48fb2543..2af8bfa7 100644 --- a/src/Timers.js +++ b/src/Timers.js @@ -1,17 +1,19 @@ -const T1 = 500, T2 = 4000, T4 = 5000; +const T1 = 500, + T2 = 4000, + T4 = 5000; module.exports = { - T1, - T2, - T4, - TIMER_B : 64 * T1, - TIMER_D : 0 * T1, - TIMER_F : 64 * T1, - TIMER_H : 64 * T1, - TIMER_I : 0 * T1, - TIMER_J : 0 * T1, - TIMER_K : 0 * T4, - TIMER_L : 64 * T1, - TIMER_M : 64 * T1, - PROVISIONAL_RESPONSE_INTERVAL : 60000 // See RFC 3261 Section 13.3.1.1 + T1, + T2, + T4, + TIMER_B: 64 * T1, + TIMER_D: 0 * T1, + TIMER_F: 64 * T1, + TIMER_H: 64 * T1, + TIMER_I: 0 * T1, + TIMER_J: 0 * T1, + TIMER_K: 0 * T4, + TIMER_L: 64 * T1, + TIMER_M: 64 * T1, + PROVISIONAL_RESPONSE_INTERVAL: 60000, // See RFC 3261 Section 13.3.1.1 }; diff --git a/src/Transactions.js b/src/Transactions.js index 1f9ded36..4978bc9a 100644 --- a/src/Transactions.js +++ b/src/Transactions.js @@ -11,678 +11,605 @@ const loggernist = new Logger('NonInviteServerTransaction'); const loggerist = new Logger('InviteServerTransaction'); const C = { - // Transaction states. - STATUS_TRYING : 1, - STATUS_PROCEEDING : 2, - STATUS_CALLING : 3, - STATUS_ACCEPTED : 4, - STATUS_COMPLETED : 5, - STATUS_TERMINATED : 6, - STATUS_CONFIRMED : 7, - - // Transaction types. - NON_INVITE_CLIENT : 'nict', - NON_INVITE_SERVER : 'nist', - INVITE_CLIENT : 'ict', - INVITE_SERVER : 'ist' + // Transaction states. + STATUS_TRYING: 1, + STATUS_PROCEEDING: 2, + STATUS_CALLING: 3, + STATUS_ACCEPTED: 4, + STATUS_COMPLETED: 5, + STATUS_TERMINATED: 6, + STATUS_CONFIRMED: 7, + + // Transaction types. + NON_INVITE_CLIENT: 'nict', + NON_INVITE_SERVER: 'nist', + INVITE_CLIENT: 'ict', + INVITE_SERVER: 'ist', }; -class NonInviteClientTransaction extends EventEmitter -{ - constructor(ua, transport, request, eventHandlers) - { - super(); - - this.type = C.NON_INVITE_CLIENT; - this.id = `z9hG4bK${Math.floor(Math.random() * 10000000)}`; - this.ua = ua; - this.transport = transport; - this.request = request; - this.eventHandlers = eventHandlers; - - let via = `SIP/2.0/${transport.via_transport}`; - - via += ` ${ua.configuration.via_host};branch=${this.id}`; - - this.request.setHeader('via', via); - - this.ua.newTransaction(this); - } - - get C() - { - return C; - } - - stateChanged(state) - { - this.state = state; - this.emit('stateChanged'); - } - - send() - { - this.stateChanged(C.STATUS_TRYING); - this.F = setTimeout(() => { this.timer_F(); }, Timers.TIMER_F); - - if (!this.transport.send(this.request)) - { - this.onTransportError(); - } - } - - onTransportError() - { - loggernict.debug(`transport error occurred, deleting transaction ${this.id}`); - clearTimeout(this.F); - clearTimeout(this.K); - this.stateChanged(C.STATUS_TERMINATED); - this.ua.destroyTransaction(this); - this.eventHandlers.onTransportError(); - } - - timer_F() - { - loggernict.debug(`Timer F expired for transaction ${this.id}`); - this.stateChanged(C.STATUS_TERMINATED); - this.ua.destroyTransaction(this); - this.eventHandlers.onRequestTimeout(); - } - - timer_K() - { - this.stateChanged(C.STATUS_TERMINATED); - this.ua.destroyTransaction(this); - } - - receiveResponse(response) - { - const status_code = response.status_code; - - if (status_code < 200) - { - switch (this.state) - { - case C.STATUS_TRYING: - case C.STATUS_PROCEEDING: - this.stateChanged(C.STATUS_PROCEEDING); - this.eventHandlers.onReceiveResponse(response); - break; - } - } - else - { - switch (this.state) - { - case C.STATUS_TRYING: - case C.STATUS_PROCEEDING: - this.stateChanged(C.STATUS_COMPLETED); - clearTimeout(this.F); - - if (status_code === 408) - { - this.eventHandlers.onRequestTimeout(); - } - else - { - this.eventHandlers.onReceiveResponse(response); - } - - this.K = setTimeout(() => { this.timer_K(); }, Timers.TIMER_K); - break; - case C.STATUS_COMPLETED: - break; - } - } - } +class NonInviteClientTransaction extends EventEmitter { + constructor(ua, transport, request, eventHandlers) { + super(); + + this.type = C.NON_INVITE_CLIENT; + this.id = `z9hG4bK${Math.floor(Math.random() * 10000000)}`; + this.ua = ua; + this.transport = transport; + this.request = request; + this.eventHandlers = eventHandlers; + + let via = `SIP/2.0/${transport.via_transport}`; + + via += ` ${ua.configuration.via_host};branch=${this.id}`; + + this.request.setHeader('via', via); + + this.ua.newTransaction(this); + } + + get C() { + return C; + } + + stateChanged(state) { + this.state = state; + this.emit('stateChanged'); + } + + send() { + this.stateChanged(C.STATUS_TRYING); + this.F = setTimeout(() => { + this.timer_F(); + }, Timers.TIMER_F); + + if (!this.transport.send(this.request)) { + this.onTransportError(); + } + } + + onTransportError() { + loggernict.debug( + `transport error occurred, deleting transaction ${this.id}` + ); + clearTimeout(this.F); + clearTimeout(this.K); + this.stateChanged(C.STATUS_TERMINATED); + this.ua.destroyTransaction(this); + this.eventHandlers.onTransportError(); + } + + timer_F() { + loggernict.debug(`Timer F expired for transaction ${this.id}`); + this.stateChanged(C.STATUS_TERMINATED); + this.ua.destroyTransaction(this); + this.eventHandlers.onRequestTimeout(); + } + + timer_K() { + this.stateChanged(C.STATUS_TERMINATED); + this.ua.destroyTransaction(this); + } + + receiveResponse(response) { + const status_code = response.status_code; + + if (status_code < 200) { + switch (this.state) { + case C.STATUS_TRYING: + case C.STATUS_PROCEEDING: { + this.stateChanged(C.STATUS_PROCEEDING); + this.eventHandlers.onReceiveResponse(response); + break; + } + } + } else { + switch (this.state) { + case C.STATUS_TRYING: + case C.STATUS_PROCEEDING: { + this.stateChanged(C.STATUS_COMPLETED); + clearTimeout(this.F); + + if (status_code === 408) { + this.eventHandlers.onRequestTimeout(); + } else { + this.eventHandlers.onReceiveResponse(response); + } + + this.K = setTimeout(() => { + this.timer_K(); + }, Timers.TIMER_K); + break; + } + case C.STATUS_COMPLETED: { + break; + } + } + } + } } -class InviteClientTransaction extends EventEmitter -{ - constructor(ua, transport, request, eventHandlers) - { - super(); - - this.type = C.INVITE_CLIENT; - this.id = `z9hG4bK${Math.floor(Math.random() * 10000000)}`; - this.ua = ua; - this.transport = transport; - this.request = request; - this.eventHandlers = eventHandlers; - request.transaction = this; - - let via = `SIP/2.0/${transport.via_transport}`; - - via += ` ${ua.configuration.via_host};branch=${this.id}`; - - this.request.setHeader('via', via); - - this.ua.newTransaction(this); - } - - get C() - { - return C; - } - - stateChanged(state) - { - this.state = state; - this.emit('stateChanged'); - } - - send() - { - this.stateChanged(C.STATUS_CALLING); - this.B = setTimeout(() => - { - this.timer_B(); - }, Timers.TIMER_B); - - if (!this.transport.send(this.request)) - { - this.onTransportError(); - } - } - - onTransportError() - { - clearTimeout(this.B); - clearTimeout(this.D); - clearTimeout(this.M); - - if (this.state !== C.STATUS_ACCEPTED) - { - loggerict.debug(`transport error occurred, deleting transaction ${this.id}`); - this.eventHandlers.onTransportError(); - } - - this.stateChanged(C.STATUS_TERMINATED); - this.ua.destroyTransaction(this); - } - - // RFC 6026 7.2. - timer_M() - { - loggerict.debug(`Timer M expired for transaction ${this.id}`); - - if (this.state === C.STATUS_ACCEPTED) - { - clearTimeout(this.B); - this.stateChanged(C.STATUS_TERMINATED); - this.ua.destroyTransaction(this); - } - } - - // RFC 3261 17.1.1. - timer_B() - { - loggerict.debug(`Timer B expired for transaction ${this.id}`); - if (this.state === C.STATUS_CALLING) - { - this.stateChanged(C.STATUS_TERMINATED); - this.ua.destroyTransaction(this); - this.eventHandlers.onRequestTimeout(); - } - } - - timer_D() - { - loggerict.debug(`Timer D expired for transaction ${this.id}`); - clearTimeout(this.B); - this.stateChanged(C.STATUS_TERMINATED); - this.ua.destroyTransaction(this); - } - - sendACK(response) - { - const ack = new SIPMessage.OutgoingRequest(JsSIP_C.ACK, this.request.ruri, - this.ua, { - 'route_set' : this.request.getHeaders('route'), - 'call_id' : this.request.getHeader('call-id'), - 'cseq' : this.request.cseq - }); - - ack.setHeader('from', this.request.getHeader('from')); - ack.setHeader('via', this.request.getHeader('via')); - ack.setHeader('to', response.getHeader('to')); - - this.D = setTimeout(() => { this.timer_D(); }, Timers.TIMER_D); - - this.transport.send(ack); - } - - cancel(reason) - { - // Send only if a provisional response (>100) has been received. - if (this.state !== C.STATUS_PROCEEDING) - { - return; - } - - const cancel = new SIPMessage.OutgoingRequest(JsSIP_C.CANCEL, this.request.ruri, - this.ua, { - 'route_set' : this.request.getHeaders('route'), - 'call_id' : this.request.getHeader('call-id'), - 'cseq' : this.request.cseq - }); - - cancel.setHeader('from', this.request.getHeader('from')); - cancel.setHeader('via', this.request.getHeader('via')); - cancel.setHeader('to', this.request.getHeader('to')); - - if (reason) - { - cancel.setHeader('reason', reason); - } - - this.transport.send(cancel); - } - - receiveResponse(response) - { - const status_code = response.status_code; - - if (status_code >= 100 && status_code <= 199) - { - switch (this.state) - { - case C.STATUS_CALLING: - this.stateChanged(C.STATUS_PROCEEDING); - this.eventHandlers.onReceiveResponse(response); - break; - case C.STATUS_PROCEEDING: - this.eventHandlers.onReceiveResponse(response); - break; - } - } - else if (status_code >= 200 && status_code <= 299) - { - switch (this.state) - { - case C.STATUS_CALLING: - case C.STATUS_PROCEEDING: - this.stateChanged(C.STATUS_ACCEPTED); - this.M = setTimeout(() => - { - this.timer_M(); - }, Timers.TIMER_M); - this.eventHandlers.onReceiveResponse(response); - break; - case C.STATUS_ACCEPTED: - this.eventHandlers.onReceiveResponse(response); - break; - } - } - else if (status_code >= 300 && status_code <= 699) - { - switch (this.state) - { - case C.STATUS_CALLING: - case C.STATUS_PROCEEDING: - this.stateChanged(C.STATUS_COMPLETED); - this.sendACK(response); - this.eventHandlers.onReceiveResponse(response); - break; - case C.STATUS_COMPLETED: - this.sendACK(response); - break; - } - } - } +class InviteClientTransaction extends EventEmitter { + constructor(ua, transport, request, eventHandlers) { + super(); + + this.type = C.INVITE_CLIENT; + this.id = `z9hG4bK${Math.floor(Math.random() * 10000000)}`; + this.ua = ua; + this.transport = transport; + this.request = request; + this.eventHandlers = eventHandlers; + request.transaction = this; + + let via = `SIP/2.0/${transport.via_transport}`; + + via += ` ${ua.configuration.via_host};branch=${this.id}`; + + this.request.setHeader('via', via); + + this.ua.newTransaction(this); + } + + get C() { + return C; + } + + stateChanged(state) { + this.state = state; + this.emit('stateChanged'); + } + + send() { + this.stateChanged(C.STATUS_CALLING); + this.B = setTimeout(() => { + this.timer_B(); + }, Timers.TIMER_B); + + if (!this.transport.send(this.request)) { + this.onTransportError(); + } + } + + onTransportError() { + clearTimeout(this.B); + clearTimeout(this.D); + clearTimeout(this.M); + + if (this.state !== C.STATUS_ACCEPTED) { + loggerict.debug( + `transport error occurred, deleting transaction ${this.id}` + ); + this.eventHandlers.onTransportError(); + } + + this.stateChanged(C.STATUS_TERMINATED); + this.ua.destroyTransaction(this); + } + + // RFC 6026 7.2. + timer_M() { + loggerict.debug(`Timer M expired for transaction ${this.id}`); + + if (this.state === C.STATUS_ACCEPTED) { + clearTimeout(this.B); + this.stateChanged(C.STATUS_TERMINATED); + this.ua.destroyTransaction(this); + } + } + + // RFC 3261 17.1.1. + timer_B() { + loggerict.debug(`Timer B expired for transaction ${this.id}`); + if (this.state === C.STATUS_CALLING) { + this.stateChanged(C.STATUS_TERMINATED); + this.ua.destroyTransaction(this); + this.eventHandlers.onRequestTimeout(); + } + } + + timer_D() { + loggerict.debug(`Timer D expired for transaction ${this.id}`); + clearTimeout(this.B); + this.stateChanged(C.STATUS_TERMINATED); + this.ua.destroyTransaction(this); + } + + sendACK(response) { + const ack = new SIPMessage.OutgoingRequest( + JsSIP_C.ACK, + this.request.ruri, + this.ua, + { + route_set: this.request.getHeaders('route'), + call_id: this.request.getHeader('call-id'), + cseq: this.request.cseq, + } + ); + + ack.setHeader('from', this.request.getHeader('from')); + ack.setHeader('via', this.request.getHeader('via')); + ack.setHeader('to', response.getHeader('to')); + + this.D = setTimeout(() => { + this.timer_D(); + }, Timers.TIMER_D); + + this.transport.send(ack); + } + + cancel(reason) { + // Send only if a provisional response (>100) has been received. + if (this.state !== C.STATUS_PROCEEDING) { + return; + } + + const cancel = new SIPMessage.OutgoingRequest( + JsSIP_C.CANCEL, + this.request.ruri, + this.ua, + { + route_set: this.request.getHeaders('route'), + call_id: this.request.getHeader('call-id'), + cseq: this.request.cseq, + } + ); + + cancel.setHeader('from', this.request.getHeader('from')); + cancel.setHeader('via', this.request.getHeader('via')); + cancel.setHeader('to', this.request.getHeader('to')); + + if (reason) { + cancel.setHeader('reason', reason); + } + + this.transport.send(cancel); + } + + receiveResponse(response) { + const status_code = response.status_code; + + if (status_code >= 100 && status_code <= 199) { + switch (this.state) { + case C.STATUS_CALLING: { + this.stateChanged(C.STATUS_PROCEEDING); + this.eventHandlers.onReceiveResponse(response); + break; + } + case C.STATUS_PROCEEDING: { + this.eventHandlers.onReceiveResponse(response); + break; + } + } + } else if (status_code >= 200 && status_code <= 299) { + switch (this.state) { + case C.STATUS_CALLING: + case C.STATUS_PROCEEDING: { + this.stateChanged(C.STATUS_ACCEPTED); + this.M = setTimeout(() => { + this.timer_M(); + }, Timers.TIMER_M); + this.eventHandlers.onReceiveResponse(response); + break; + } + case C.STATUS_ACCEPTED: { + this.eventHandlers.onReceiveResponse(response); + break; + } + } + } else if (status_code >= 300 && status_code <= 699) { + switch (this.state) { + case C.STATUS_CALLING: + case C.STATUS_PROCEEDING: { + this.stateChanged(C.STATUS_COMPLETED); + this.sendACK(response); + this.eventHandlers.onReceiveResponse(response); + break; + } + case C.STATUS_COMPLETED: { + this.sendACK(response); + break; + } + } + } + } } -class AckClientTransaction extends EventEmitter -{ - constructor(ua, transport, request, eventHandlers) - { - super(); - - this.id = `z9hG4bK${Math.floor(Math.random() * 10000000)}`; - this.transport = transport; - this.request = request; - this.eventHandlers = eventHandlers; - - let via = `SIP/2.0/${transport.via_transport}`; - - via += ` ${ua.configuration.via_host};branch=${this.id}`; - - this.request.setHeader('via', via); - } - - get C() - { - return C; - } - - send() - { - if (!this.transport.send(this.request)) - { - this.onTransportError(); - } - } - - onTransportError() - { - loggeract.debug(`transport error occurred for transaction ${this.id}`); - this.eventHandlers.onTransportError(); - } +class AckClientTransaction extends EventEmitter { + constructor(ua, transport, request, eventHandlers) { + super(); + + this.id = `z9hG4bK${Math.floor(Math.random() * 10000000)}`; + this.transport = transport; + this.request = request; + this.eventHandlers = eventHandlers; + + let via = `SIP/2.0/${transport.via_transport}`; + + via += ` ${ua.configuration.via_host};branch=${this.id}`; + + this.request.setHeader('via', via); + } + + get C() { + return C; + } + + send() { + if (!this.transport.send(this.request)) { + this.onTransportError(); + } + } + + onTransportError() { + loggeract.debug(`transport error occurred for transaction ${this.id}`); + this.eventHandlers.onTransportError(); + } } -class NonInviteServerTransaction extends EventEmitter -{ - constructor(ua, transport, request) - { - super(); - - this.type = C.NON_INVITE_SERVER; - this.id = request.via_branch; - this.ua = ua; - this.transport = transport; - this.request = request; - this.last_response = ''; - request.server_transaction = this; - - this.state = C.STATUS_TRYING; - - ua.newTransaction(this); - } - - get C() - { - return C; - } - - stateChanged(state) - { - this.state = state; - this.emit('stateChanged'); - } - - timer_J() - { - loggernist.debug(`Timer J expired for transaction ${this.id}`); - this.stateChanged(C.STATUS_TERMINATED); - this.ua.destroyTransaction(this); - } - - onTransportError() - { - if (!this.transportError) - { - this.transportError = true; - - loggernist.debug(`transport error occurred, deleting transaction ${this.id}`); - - clearTimeout(this.J); - this.stateChanged(C.STATUS_TERMINATED); - this.ua.destroyTransaction(this); - } - } - - receiveResponse(status_code, response, onSuccess, onFailure) - { - if (status_code === 100) - { - /* RFC 4320 4.1 - * 'A SIP element MUST NOT - * send any provisional response with a - * Status-Code other than 100 to a non-INVITE request.' - */ - switch (this.state) - { - case C.STATUS_TRYING: - this.stateChanged(C.STATUS_PROCEEDING); - if (!this.transport.send(response)) - { - this.onTransportError(); - } - break; - case C.STATUS_PROCEEDING: - this.last_response = response; - if (!this.transport.send(response)) - { - this.onTransportError(); - if (onFailure) - { - onFailure(); - } - } - else if (onSuccess) - { - onSuccess(); - } - break; - } - } - else if (status_code >= 200 && status_code <= 699) - { - switch (this.state) - { - case C.STATUS_TRYING: - case C.STATUS_PROCEEDING: - this.stateChanged(C.STATUS_COMPLETED); - this.last_response = response; - this.J = setTimeout(() => - { - this.timer_J(); - }, Timers.TIMER_J); - if (!this.transport.send(response)) - { - this.onTransportError(); - if (onFailure) - { - onFailure(); - } - } - else if (onSuccess) - { - onSuccess(); - } - break; - case C.STATUS_COMPLETED: - break; - } - } - } +class NonInviteServerTransaction extends EventEmitter { + constructor(ua, transport, request) { + super(); + + this.type = C.NON_INVITE_SERVER; + this.id = request.via_branch; + this.ua = ua; + this.transport = transport; + this.request = request; + this.last_response = ''; + request.server_transaction = this; + + this.state = C.STATUS_TRYING; + + ua.newTransaction(this); + } + + get C() { + return C; + } + + stateChanged(state) { + this.state = state; + this.emit('stateChanged'); + } + + timer_J() { + loggernist.debug(`Timer J expired for transaction ${this.id}`); + this.stateChanged(C.STATUS_TERMINATED); + this.ua.destroyTransaction(this); + } + + onTransportError() { + if (!this.transportError) { + this.transportError = true; + + loggernist.debug( + `transport error occurred, deleting transaction ${this.id}` + ); + + clearTimeout(this.J); + this.stateChanged(C.STATUS_TERMINATED); + this.ua.destroyTransaction(this); + } + } + + receiveResponse(status_code, response, onSuccess, onFailure) { + if (status_code === 100) { + /* RFC 4320 4.1 + * 'A SIP element MUST NOT + * send any provisional response with a + * Status-Code other than 100 to a non-INVITE request.' + */ + switch (this.state) { + case C.STATUS_TRYING: { + this.stateChanged(C.STATUS_PROCEEDING); + if (!this.transport.send(response)) { + this.onTransportError(); + } + break; + } + case C.STATUS_PROCEEDING: { + this.last_response = response; + if (!this.transport.send(response)) { + this.onTransportError(); + if (onFailure) { + onFailure(); + } + } else if (onSuccess) { + onSuccess(); + } + break; + } + } + } else if (status_code >= 200 && status_code <= 699) { + switch (this.state) { + case C.STATUS_TRYING: + case C.STATUS_PROCEEDING: { + this.stateChanged(C.STATUS_COMPLETED); + this.last_response = response; + this.J = setTimeout(() => { + this.timer_J(); + }, Timers.TIMER_J); + if (!this.transport.send(response)) { + this.onTransportError(); + if (onFailure) { + onFailure(); + } + } else if (onSuccess) { + onSuccess(); + } + break; + } + case C.STATUS_COMPLETED: { + break; + } + } + } + } } -class InviteServerTransaction extends EventEmitter -{ - constructor(ua, transport, request) - { - super(); - - this.type = C.INVITE_SERVER; - this.id = request.via_branch; - this.ua = ua; - this.transport = transport; - this.request = request; - this.last_response = ''; - request.server_transaction = this; - - this.state = C.STATUS_PROCEEDING; - - ua.newTransaction(this); - - this.resendProvisionalTimer = null; - - request.reply(100); - } - - get C() - { - return C; - } - - stateChanged(state) - { - this.state = state; - this.emit('stateChanged'); - } - - timer_H() - { - loggerist.debug(`Timer H expired for transaction ${this.id}`); - - if (this.state === C.STATUS_COMPLETED) - { - loggerist.debug('ACK not received, dialog will be terminated'); - } - - this.stateChanged(C.STATUS_TERMINATED); - this.ua.destroyTransaction(this); - } - - timer_I() - { - this.stateChanged(C.STATUS_TERMINATED); - this.ua.destroyTransaction(this); - } - - // RFC 6026 7.1. - timer_L() - { - loggerist.debug(`Timer L expired for transaction ${this.id}`); - - if (this.state === C.STATUS_ACCEPTED) - { - this.stateChanged(C.STATUS_TERMINATED); - this.ua.destroyTransaction(this); - } - } - - onTransportError() - { - if (!this.transportError) - { - this.transportError = true; - - loggerist.debug(`transport error occurred, deleting transaction ${this.id}`); - - if (this.resendProvisionalTimer !== null) - { - clearInterval(this.resendProvisionalTimer); - this.resendProvisionalTimer = null; - } - - clearTimeout(this.L); - clearTimeout(this.H); - clearTimeout(this.I); - - this.stateChanged(C.STATUS_TERMINATED); - this.ua.destroyTransaction(this); - } - } - - resend_provisional() - { - if (!this.transport.send(this.last_response)) - { - this.onTransportError(); - } - } - - // INVITE Server Transaction RFC 3261 17.2.1. - receiveResponse(status_code, response, onSuccess, onFailure) - { - if (status_code >= 100 && status_code <= 199) - { - switch (this.state) - { - case C.STATUS_PROCEEDING: - if (!this.transport.send(response)) - { - this.onTransportError(); - } - this.last_response = response; - break; - } - } - - if (status_code > 100 && status_code <= 199 && this.state === C.STATUS_PROCEEDING) - { - // Trigger the resendProvisionalTimer only for the first non 100 provisional response. - if (this.resendProvisionalTimer === null) - { - this.resendProvisionalTimer = setInterval(() => - { - this.resend_provisional(); - }, Timers.PROVISIONAL_RESPONSE_INTERVAL); - } - } - else if (status_code >= 200 && status_code <= 299) - { - switch (this.state) - { - case C.STATUS_PROCEEDING: - this.stateChanged(C.STATUS_ACCEPTED); - this.last_response = response; - this.L = setTimeout(() => - { - this.timer_L(); - }, Timers.TIMER_L); - - if (this.resendProvisionalTimer !== null) - { - clearInterval(this.resendProvisionalTimer); - this.resendProvisionalTimer = null; - } - - /* falls through */ - case C.STATUS_ACCEPTED: - // Note that this point will be reached for proceeding this.state also. - if (!this.transport.send(response)) - { - this.onTransportError(); - if (onFailure) - { - onFailure(); - } - } - else if (onSuccess) - { - onSuccess(); - } - break; - } - } - else if (status_code >= 300 && status_code <= 699) - { - switch (this.state) - { - case C.STATUS_PROCEEDING: - if (this.resendProvisionalTimer !== null) - { - clearInterval(this.resendProvisionalTimer); - this.resendProvisionalTimer = null; - } - - if (!this.transport.send(response)) - { - this.onTransportError(); - if (onFailure) - { - onFailure(); - } - } - else - { - this.stateChanged(C.STATUS_COMPLETED); - this.H = setTimeout(() => - { - this.timer_H(); - }, Timers.TIMER_H); - if (onSuccess) - { - onSuccess(); - } - } - break; - } - } - } +class InviteServerTransaction extends EventEmitter { + constructor(ua, transport, request) { + super(); + + this.type = C.INVITE_SERVER; + this.id = request.via_branch; + this.ua = ua; + this.transport = transport; + this.request = request; + this.last_response = ''; + request.server_transaction = this; + + this.state = C.STATUS_PROCEEDING; + + ua.newTransaction(this); + + this.resendProvisionalTimer = null; + + request.reply(100); + } + + get C() { + return C; + } + + stateChanged(state) { + this.state = state; + this.emit('stateChanged'); + } + + timer_H() { + loggerist.debug(`Timer H expired for transaction ${this.id}`); + + if (this.state === C.STATUS_COMPLETED) { + loggerist.debug('ACK not received, dialog will be terminated'); + } + + this.stateChanged(C.STATUS_TERMINATED); + this.ua.destroyTransaction(this); + } + + timer_I() { + this.stateChanged(C.STATUS_TERMINATED); + this.ua.destroyTransaction(this); + } + + // RFC 6026 7.1. + timer_L() { + loggerist.debug(`Timer L expired for transaction ${this.id}`); + + if (this.state === C.STATUS_ACCEPTED) { + this.stateChanged(C.STATUS_TERMINATED); + this.ua.destroyTransaction(this); + } + } + + onTransportError() { + if (!this.transportError) { + this.transportError = true; + + loggerist.debug( + `transport error occurred, deleting transaction ${this.id}` + ); + + if (this.resendProvisionalTimer !== null) { + clearInterval(this.resendProvisionalTimer); + this.resendProvisionalTimer = null; + } + + clearTimeout(this.L); + clearTimeout(this.H); + clearTimeout(this.I); + + this.stateChanged(C.STATUS_TERMINATED); + this.ua.destroyTransaction(this); + } + } + + resend_provisional() { + if (!this.transport.send(this.last_response)) { + this.onTransportError(); + } + } + + // INVITE Server Transaction RFC 3261 17.2.1. + receiveResponse(status_code, response, onSuccess, onFailure) { + if (status_code >= 100 && status_code <= 199) { + switch (this.state) { + case C.STATUS_PROCEEDING: { + if (!this.transport.send(response)) { + this.onTransportError(); + } + this.last_response = response; + break; + } + } + } + + if ( + status_code > 100 && + status_code <= 199 && + this.state === C.STATUS_PROCEEDING + ) { + // Trigger the resendProvisionalTimer only for the first non 100 provisional response. + if (this.resendProvisionalTimer === null) { + this.resendProvisionalTimer = setInterval(() => { + this.resend_provisional(); + }, Timers.PROVISIONAL_RESPONSE_INTERVAL); + } + } else if (status_code >= 200 && status_code <= 299) { + switch (this.state) { + case C.STATUS_PROCEEDING: { + this.stateChanged(C.STATUS_ACCEPTED); + this.last_response = response; + this.L = setTimeout(() => { + this.timer_L(); + }, Timers.TIMER_L); + + if (this.resendProvisionalTimer !== null) { + clearInterval(this.resendProvisionalTimer); + this.resendProvisionalTimer = null; + } + } + // falls through + case C.STATUS_ACCEPTED: { + // Note that this point will be reached for proceeding this.state also. + if (!this.transport.send(response)) { + this.onTransportError(); + if (onFailure) { + onFailure(); + } + } else if (onSuccess) { + onSuccess(); + } + break; + } + } + } else if (status_code >= 300 && status_code <= 699) { + switch (this.state) { + case C.STATUS_PROCEEDING: { + if (this.resendProvisionalTimer !== null) { + clearInterval(this.resendProvisionalTimer); + this.resendProvisionalTimer = null; + } + + if (!this.transport.send(response)) { + this.onTransportError(); + if (onFailure) { + onFailure(); + } + } else { + this.stateChanged(C.STATUS_COMPLETED); + this.H = setTimeout(() => { + this.timer_H(); + }, Timers.TIMER_H); + if (onSuccess) { + onSuccess(); + } + } + break; + } + } + } + } } /** @@ -703,103 +630,95 @@ class InviteServerTransaction extends EventEmitter * _true_ retransmission * _false_ new request */ -function checkTransaction({ _transactions }, request) -{ - let tr; - - switch (request.method) - { - case JsSIP_C.INVITE: - tr = _transactions.ist[request.via_branch]; - if (tr) - { - switch (tr.state) - { - case C.STATUS_PROCEEDING: - tr.transport.send(tr.last_response); - break; - - // RFC 6026 7.1 Invite retransmission. - // Received while in C.STATUS_ACCEPTED state. Absorb it. - case C.STATUS_ACCEPTED: - break; - } - - return true; - } - break; - case JsSIP_C.ACK: - tr = _transactions.ist[request.via_branch]; - - // RFC 6026 7.1. - if (tr) - { - if (tr.state === C.STATUS_ACCEPTED) - { - return false; - } - else if (tr.state === C.STATUS_COMPLETED) - { - tr.state = C.STATUS_CONFIRMED; - tr.I = setTimeout(() => { tr.timer_I(); }, Timers.TIMER_I); - - return true; - } - } - // ACK to 2XX Response. - else - { - return false; - } - break; - case JsSIP_C.CANCEL: - tr = _transactions.ist[request.via_branch]; - if (tr) - { - request.reply_sl(200); - if (tr.state === C.STATUS_PROCEEDING) - { - return false; - } - else - { - return true; - } - } - else - { - request.reply_sl(481); - - return true; - } - default: - - // Non-INVITE Server Transaction RFC 3261 17.2.2. - tr = _transactions.nist[request.via_branch]; - if (tr) - { - switch (tr.state) - { - case C.STATUS_TRYING: - break; - case C.STATUS_PROCEEDING: - case C.STATUS_COMPLETED: - tr.transport.send(tr.last_response); - break; - } - - return true; - } - break; - } +function checkTransaction({ _transactions }, request) { + let tr; + + switch (request.method) { + case JsSIP_C.INVITE: { + tr = _transactions.ist[request.via_branch]; + if (tr) { + switch (tr.state) { + case C.STATUS_PROCEEDING: { + tr.transport.send(tr.last_response); + break; + } + + // RFC 6026 7.1 Invite retransmission. + // Received while in C.STATUS_ACCEPTED state. Absorb it. + case C.STATUS_ACCEPTED: { + break; + } + } + + return true; + } + break; + } + case JsSIP_C.ACK: { + tr = _transactions.ist[request.via_branch]; + + // RFC 6026 7.1. + if (tr) { + if (tr.state === C.STATUS_ACCEPTED) { + return false; + } else if (tr.state === C.STATUS_COMPLETED) { + tr.state = C.STATUS_CONFIRMED; + tr.I = setTimeout(() => { + tr.timer_I(); + }, Timers.TIMER_I); + + return true; + } + } + // ACK to 2XX Response. + else { + return false; + } + break; + } + case JsSIP_C.CANCEL: { + tr = _transactions.ist[request.via_branch]; + if (tr) { + request.reply_sl(200); + if (tr.state === C.STATUS_PROCEEDING) { + return false; + } else { + return true; + } + } else { + request.reply_sl(481); + + return true; + } + } + default: { + // Non-INVITE Server Transaction RFC 3261 17.2.2. + tr = _transactions.nist[request.via_branch]; + if (tr) { + switch (tr.state) { + case C.STATUS_TRYING: { + break; + } + case C.STATUS_PROCEEDING: + case C.STATUS_COMPLETED: { + tr.transport.send(tr.last_response); + break; + } + } + + return true; + } + break; + } + } } module.exports = { - C, - NonInviteClientTransaction, - InviteClientTransaction, - AckClientTransaction, - NonInviteServerTransaction, - InviteServerTransaction, - checkTransaction + C, + NonInviteClientTransaction, + InviteClientTransaction, + AckClientTransaction, + NonInviteServerTransaction, + InviteServerTransaction, + checkTransaction, }; diff --git a/src/Transport.d.ts b/src/Transport.d.ts index 5d9e79ad..0829a9bc 100644 --- a/src/Transport.d.ts +++ b/src/Transport.d.ts @@ -1,10 +1,10 @@ -import {Socket} from './Socket' +import { Socket } from './Socket'; export interface RecoveryOptions { - min_interval: number; - max_interval: number; + min_interval: number; + max_interval: number; } export class Transport extends Socket { - constructor(sockets: Socket | Socket[], recovery_options?: RecoveryOptions) + constructor(sockets: Socket | Socket[], recovery_options?: RecoveryOptions); } diff --git a/src/Transport.js b/src/Transport.js index ca2d7157..258382c3 100644 --- a/src/Transport.js +++ b/src/Transport.js @@ -8,22 +8,22 @@ const logger = new Logger('Transport'); * Constants */ const C = { - // Transport status. - STATUS_CONNECTED : 0, - STATUS_CONNECTING : 1, - STATUS_DISCONNECTED : 2, - - // Socket status. - SOCKET_STATUS_READY : 0, - SOCKET_STATUS_ERROR : 1, - - // Recovery options. - recovery_options : { - // minimum interval in seconds between recover attempts. - min_interval : JsSIP_C.CONNECTION_RECOVERY_MIN_INTERVAL, - // maximum interval in seconds between recover attempts. - max_interval : JsSIP_C.CONNECTION_RECOVERY_MAX_INTERVAL - } + // Transport status. + STATUS_CONNECTED: 0, + STATUS_CONNECTING: 1, + STATUS_DISCONNECTED: 2, + + // Socket status. + SOCKET_STATUS_READY: 0, + SOCKET_STATUS_ERROR: 1, + + // Recovery options. + recovery_options: { + // minimum interval in seconds between recover attempts. + min_interval: JsSIP_C.CONNECTION_RECOVERY_MIN_INTERVAL, + // maximum interval in seconds between recover attempts. + max_interval: JsSIP_C.CONNECTION_RECOVERY_MAX_INTERVAL, + }, }; /* @@ -32,366 +32,314 @@ const C = { * * @socket JsSIP::Socket instance */ -module.exports = class Transport -{ - constructor(sockets, recovery_options = C.recovery_options) - { - logger.debug('new()'); - - this.status = C.STATUS_DISCONNECTED; - - // Current socket. - this.socket = null; - - // Socket collection. - this.sockets = []; - - this.recovery_options = recovery_options; - this.recover_attempts = 0; - this.recovery_timer = null; - - this.close_requested = false; - - // It seems that TextDecoder is not available in some versions of React-Native. - // See https://github.com/versatica/JsSIP/issues/695 - try - { - this.textDecoder = new TextDecoder('utf8'); - } - catch (error) - { - logger.warn(`cannot use TextDecoder: ${error}`); - } - - if (typeof sockets === 'undefined') - { - throw new TypeError('Invalid argument.' + - ' undefined \'sockets\' argument'); - } - - if (!(sockets instanceof Array)) - { - sockets = [ sockets ]; - } - - sockets.forEach(function(socket) - { - if (!Socket.isSocket(socket.socket)) - { - throw new TypeError('Invalid argument.' + - ' invalid \'JsSIP.Socket\' instance'); - } - - if (socket.weight && !Number(socket.weight)) - { - throw new TypeError('Invalid argument.' + - ' \'weight\' attribute is not a number'); - } - - this.sockets.push({ - socket : socket.socket, - weight : socket.weight || 0, - status : C.SOCKET_STATUS_READY - }); - }, this); - - // Get the socket with higher weight. - this._getSocket(); - } - - /** - * Instance Methods - */ - - get via_transport() - { - return this.socket.via_transport; - } - - get url() - { - return this.socket.url; - } - - get sip_uri() - { - return this.socket.sip_uri; - } - - connect() - { - logger.debug('connect()'); - - if (this.isConnected()) - { - logger.debug('Transport is already connected'); - - return; - } - else if (this.isConnecting()) - { - logger.debug('Transport is connecting'); - - return; - } - - this.close_requested = false; - this.status = C.STATUS_CONNECTING; - this.onconnecting({ socket: this.socket, attempts: this.recover_attempts }); - - if (!this.close_requested) - { - // Bind socket event callbacks. - this.socket.onconnect = this._onConnect.bind(this); - this.socket.ondisconnect = this._onDisconnect.bind(this); - this.socket.ondata = this._onData.bind(this); - - this.socket.connect(); - } - - return; - } - - disconnect() - { - logger.debug('close()'); - - this.close_requested = true; - this.recover_attempts = 0; - this.status = C.STATUS_DISCONNECTED; - - // Clear recovery_timer. - if (this.recovery_timer !== null) - { - clearTimeout(this.recovery_timer); - this.recovery_timer = null; - } - - // Unbind socket event callbacks. - this.socket.onconnect = () => {}; - this.socket.ondisconnect = () => {}; - this.socket.ondata = () => {}; - - this.socket.disconnect(); - this.ondisconnect({ - socket : this.socket, - error : false - }); - } - - send(data) - { - logger.debug('send()'); - - if (!this.isConnected()) - { - logger.warn('unable to send message, transport is not connected'); - - return false; - } - - const message = data.toString(); - - logger.debug(`sending message:\n\n${message}\n`); - - return this.socket.send(message); - } - - isConnected() - { - return this.status === C.STATUS_CONNECTED; - } - - isConnecting() - { - return this.status === C.STATUS_CONNECTING; - } - - /** - * Private API. - */ - - _reconnect() - { - this.recover_attempts+=1; - - let k = Math.floor((Math.random() * Math.pow(2, this.recover_attempts)) +1); - - if (k < this.recovery_options.min_interval) - { - k = this.recovery_options.min_interval; - } - - else if (k > this.recovery_options.max_interval) - { - k = this.recovery_options.max_interval; - } - - logger.debug(`reconnection attempt: ${this.recover_attempts}. next connection attempt in ${k} seconds`); - - this.recovery_timer = setTimeout(() => - { - if (!this.close_requested && !(this.isConnected() || this.isConnecting())) - { - // Get the next available socket with higher weight. - this._getSocket(); - - // Connect the socket. - this.connect(); - } - }, k * 1000); - } - - /** - * get the next available socket with higher weight - */ - _getSocket() - { - - let candidates = []; - - this.sockets.forEach((socket) => - { - if (socket.status === C.SOCKET_STATUS_ERROR) - { - return; // continue the array iteration - } - else if (candidates.length === 0) - { - candidates.push(socket); - } - else if (socket.weight > candidates[0].weight) - { - candidates = [ socket ]; - } - else if (socket.weight === candidates[0].weight) - { - candidates.push(socket); - } - }); - - if (candidates.length === 0) - { - // All sockets have failed. reset sockets status. - this.sockets.forEach((socket) => - { - socket.status = C.SOCKET_STATUS_READY; - }); - - // Get next available socket. - this._getSocket(); - - return; - } - - const idx = Math.floor((Math.random()* candidates.length)); - - this.socket = candidates[idx].socket; - } - - /** - * Socket Event Handlers - */ - - _onConnect() - { - this.recover_attempts = 0; - this.status = C.STATUS_CONNECTED; - - // Clear recovery_timer. - if (this.recovery_timer !== null) - { - clearTimeout(this.recovery_timer); - this.recovery_timer = null; - } - - this.onconnect({ socket: this }); - } - - _onDisconnect(error, code, reason) - { - this.status = C.STATUS_DISCONNECTED; - this.ondisconnect({ - socket : this.socket, - error, - code, - reason - }); - - if (this.close_requested) - { - return; - } - - // Update socket status. - else - { - this.sockets.forEach(function(socket) - { - if (this.socket === socket.socket) - { - socket.status = C.SOCKET_STATUS_ERROR; - } - }, this); - } - - this._reconnect(error); - } - - _onData(data) - { - // CRLF Keep Alive request from server, reply. - if (data === '\r\n\r\n') - { - logger.debug('received message with double-CRLF Keep Alive request'); - - try - { - // Reply with single CRLF. - this.socket.send('\r\n'); - } - catch (error) - { - logger.warn(`error sending Keep Alive response: ${error}`); - } - - return; - } - - // CRLF Keep Alive response from server, ignore it. - if (data === '\r\n') - { - logger.debug('received message with CRLF Keep Alive response'); - - return; - } - - // Binary message. - else if (typeof data !== 'string') - { - try - { - if (this.textDecoder) - data = this.textDecoder.decode(data); - else - data = String.fromCharCode.apply(null, new Uint8Array(data)); - } - catch (error) - { - logger.debug(`received binary message failed to be converted into string: ${error}`); - - return; - } - - logger.debug(`received binary message:\n\n${data}\n`); - } - - // Text message. - else - { - logger.debug(`received text message:\n\n${data}\n`); - } - - this.ondata({ transport: this, message: data }); - } +module.exports = class Transport { + constructor(sockets, recovery_options = C.recovery_options) { + logger.debug('new()'); + + this.status = C.STATUS_DISCONNECTED; + + // Current socket. + this.socket = null; + + // Socket collection. + this.sockets = []; + + this.recovery_options = recovery_options; + this.recover_attempts = 0; + this.recovery_timer = null; + + this.close_requested = false; + + // It seems that TextDecoder is not available in some versions of React-Native. + // See https://github.com/versatica/JsSIP/issues/695 + try { + this.textDecoder = new TextDecoder('utf8'); + } catch (error) { + logger.warn(`cannot use TextDecoder: ${error}`); + } + + if (typeof sockets === 'undefined') { + throw new TypeError("Invalid argument. undefined 'sockets' argument"); + } + + if (!(sockets instanceof Array)) { + sockets = [sockets]; + } + + sockets.forEach(function (socket) { + if (!Socket.isSocket(socket.socket)) { + throw new TypeError( + "Invalid argument. invalid 'JsSIP.Socket' instance" + ); + } + + if (socket.weight && !Number(socket.weight)) { + throw new TypeError( + "Invalid argument. 'weight' attribute is not a number" + ); + } + + this.sockets.push({ + socket: socket.socket, + weight: socket.weight || 0, + status: C.SOCKET_STATUS_READY, + }); + }, this); + + // Get the socket with higher weight. + this._getSocket(); + } + + /** + * Instance Methods + */ + + get via_transport() { + return this.socket.via_transport; + } + + get url() { + return this.socket.url; + } + + get sip_uri() { + return this.socket.sip_uri; + } + + connect() { + logger.debug('connect()'); + + if (this.isConnected()) { + logger.debug('Transport is already connected'); + + return; + } else if (this.isConnecting()) { + logger.debug('Transport is connecting'); + + return; + } + + this.close_requested = false; + this.status = C.STATUS_CONNECTING; + this.onconnecting({ socket: this.socket, attempts: this.recover_attempts }); + + if (!this.close_requested) { + // Bind socket event callbacks. + this.socket.onconnect = this._onConnect.bind(this); + this.socket.ondisconnect = this._onDisconnect.bind(this); + this.socket.ondata = this._onData.bind(this); + + this.socket.connect(); + } + + return; + } + + disconnect() { + logger.debug('close()'); + + this.close_requested = true; + this.recover_attempts = 0; + this.status = C.STATUS_DISCONNECTED; + + // Clear recovery_timer. + if (this.recovery_timer !== null) { + clearTimeout(this.recovery_timer); + this.recovery_timer = null; + } + + // Unbind socket event callbacks. + this.socket.onconnect = () => {}; + this.socket.ondisconnect = () => {}; + this.socket.ondata = () => {}; + + this.socket.disconnect(); + this.ondisconnect({ + socket: this.socket, + error: false, + }); + } + + send(data) { + logger.debug('send()'); + + if (!this.isConnected()) { + logger.warn('unable to send message, transport is not connected'); + + return false; + } + + const message = data.toString(); + + logger.debug(`sending message:\n\n${message}\n`); + + return this.socket.send(message); + } + + isConnected() { + return this.status === C.STATUS_CONNECTED; + } + + isConnecting() { + return this.status === C.STATUS_CONNECTING; + } + + /** + * Private API. + */ + + _reconnect() { + this.recover_attempts += 1; + + let k = Math.floor(Math.random() * Math.pow(2, this.recover_attempts) + 1); + + if (k < this.recovery_options.min_interval) { + k = this.recovery_options.min_interval; + } else if (k > this.recovery_options.max_interval) { + k = this.recovery_options.max_interval; + } + + logger.debug( + `reconnection attempt: ${this.recover_attempts}. next connection attempt in ${k} seconds` + ); + + this.recovery_timer = setTimeout(() => { + if ( + !this.close_requested && + !(this.isConnected() || this.isConnecting()) + ) { + // Get the next available socket with higher weight. + this._getSocket(); + + // Connect the socket. + this.connect(); + } + }, k * 1000); + } + + /** + * get the next available socket with higher weight + */ + _getSocket() { + let candidates = []; + + this.sockets.forEach(socket => { + if (socket.status === C.SOCKET_STATUS_ERROR) { + return; // continue the array iteration + } else if (candidates.length === 0) { + candidates.push(socket); + } else if (socket.weight > candidates[0].weight) { + candidates = [socket]; + } else if (socket.weight === candidates[0].weight) { + candidates.push(socket); + } + }); + + if (candidates.length === 0) { + // All sockets have failed. reset sockets status. + this.sockets.forEach(socket => { + socket.status = C.SOCKET_STATUS_READY; + }); + + // Get next available socket. + this._getSocket(); + + return; + } + + const idx = Math.floor(Math.random() * candidates.length); + + this.socket = candidates[idx].socket; + } + + /** + * Socket Event Handlers + */ + + _onConnect() { + this.recover_attempts = 0; + this.status = C.STATUS_CONNECTED; + + // Clear recovery_timer. + if (this.recovery_timer !== null) { + clearTimeout(this.recovery_timer); + this.recovery_timer = null; + } + + this.onconnect({ socket: this }); + } + + _onDisconnect(error, code, reason) { + this.status = C.STATUS_DISCONNECTED; + this.ondisconnect({ + socket: this.socket, + error, + code, + reason, + }); + + if (this.close_requested) { + return; + } + + // Update socket status. + else { + this.sockets.forEach(function (socket) { + if (this.socket === socket.socket) { + socket.status = C.SOCKET_STATUS_ERROR; + } + }, this); + } + + this._reconnect(error); + } + + _onData(data) { + // CRLF Keep Alive request from server, reply. + if (data === '\r\n\r\n') { + logger.debug('received message with double-CRLF Keep Alive request'); + + try { + // Reply with single CRLF. + this.socket.send('\r\n'); + } catch (error) { + logger.warn(`error sending Keep Alive response: ${error}`); + } + + return; + } + + // CRLF Keep Alive response from server, ignore it. + if (data === '\r\n') { + logger.debug('received message with CRLF Keep Alive response'); + + return; + } + + // Binary message. + else if (typeof data !== 'string') { + try { + if (this.textDecoder) { + data = this.textDecoder.decode(data); + } else { + data = String.fromCharCode.apply(null, new Uint8Array(data)); + } + } catch (error) { + logger.debug( + `received binary message failed to be converted into string: ${error}` + ); + + return; + } + + logger.debug(`received binary message:\n\n${data}\n`); + } + + // Text message. + else { + logger.debug(`received text message:\n\n${data}\n`); + } + + this.ondata({ transport: this, message: data }); + } }; diff --git a/src/UA.d.ts b/src/UA.d.ts index 0fff768d..ae562cc6 100644 --- a/src/UA.d.ts +++ b/src/UA.d.ts @@ -1,113 +1,123 @@ -import {EventEmitter} from 'events' - -import {Socket, WeightedSocket} from './Socket' -import {AnswerOptions, Originator, RTCSession, RTCSessionEventMap, TerminateOptions} from './RTCSession' -import {IncomingRequest, IncomingResponse, OutgoingRequest} from './SIPMessage' -import {Message, SendMessageOptions} from './Message' -import {Registrator} from './Registrator' -import {Notifier} from './Notifier' -import {Subscriber} from './Subscriber' -import {URI} from './URI' -import {causes} from './Constants' +import { EventEmitter } from 'events'; + +import { Socket, WeightedSocket } from './Socket'; +import { + AnswerOptions, + Originator, + RTCSession, + RTCSessionEventMap, + TerminateOptions, +} from './RTCSession'; +import { + IncomingRequest, + IncomingResponse, + OutgoingRequest, +} from './SIPMessage'; +import { Message, SendMessageOptions } from './Message'; +import { Registrator } from './Registrator'; +import { Notifier } from './Notifier'; +import { Subscriber } from './Subscriber'; +import { URI } from './URI'; +import { causes } from './Constants'; export interface UnRegisterOptions { - all?: boolean; + all?: boolean; } export interface CallOptions extends AnswerOptions { - eventHandlers?: Partial; - anonymous?: boolean; - fromUserName?: string; - fromDisplayName?: string; + eventHandlers?: Partial; + anonymous?: boolean; + fromUserName?: string; + fromDisplayName?: string; } export interface UAConfiguration { - // mandatory parameters - sockets: Socket | Socket[] | WeightedSocket[] ; - uri: string; - // optional parameters - authorization_jwt?: string; - authorization_user?: string; - connection_recovery_max_interval?: number; - connection_recovery_min_interval?: number; - contact_uri?: string; - display_name?: string; - instance_id?: string; - no_answer_timeout?: number; - session_timers?: boolean; - session_timers_refresh_method?: string; - session_timers_force_refresher?: boolean; - password?: string; - realm?: string; - ha1?: string; - register?: boolean; - register_expires?: number; - register_from_tag_trail?: string | (() => string); - registrar_server?: string; - use_preloaded_route?: boolean; - user_agent?: string; - extra_headers?: string[]; + // mandatory parameters + sockets: Socket | Socket[] | WeightedSocket[]; + uri: string; + // optional parameters + authorization_jwt?: string; + authorization_user?: string; + connection_recovery_max_interval?: number; + connection_recovery_min_interval?: number; + contact_uri?: string; + display_name?: string; + instance_id?: string; + no_answer_timeout?: number; + session_timers?: boolean; + session_timers_refresh_method?: string; + session_timers_force_refresher?: boolean; + password?: string; + realm?: string; + ha1?: string; + register?: boolean; + register_expires?: number; + register_from_tag_trail?: string | (() => string); + registrar_server?: string; + use_preloaded_route?: boolean; + user_agent?: string; + extra_headers?: string[]; } export interface IncomingRTCSessionEvent { - originator: Originator.REMOTE; - session: RTCSession; - request: IncomingRequest; + originator: Originator.REMOTE; + session: RTCSession; + request: IncomingRequest; } export interface OutgoingRTCSessionEvent { - originator: Originator.LOCAL; - session: RTCSession; - request: OutgoingRequest; + originator: Originator.LOCAL; + session: RTCSession; + request: OutgoingRequest; } export type RTCSessionEvent = IncomingRTCSessionEvent | OutgoingRTCSessionEvent; export interface ConnectingEvent { - socket: Socket; - attempts: number + socket: Socket; + attempts: number; } export interface ConnectedEvent { - socket: Socket; + socket: Socket; } export interface DisconnectEvent { - socket: Socket; - error: boolean; - code?: number; - reason?: string; + socket: Socket; + error: boolean; + code?: number; + reason?: string; } export interface RegisteredEvent { - response: IncomingResponse; + response: IncomingResponse; } export interface UnRegisteredEvent { - response: IncomingResponse; - cause?: causes; + response: IncomingResponse; + cause?: causes; } export interface IncomingMessageEvent { - originator: Originator.REMOTE; - message: Message; - request: IncomingRequest; + originator: Originator.REMOTE; + message: Message; + request: IncomingRequest; } export interface OutgoingMessageEvent { - originator: Originator.LOCAL; - message: Message; - request: OutgoingRequest; + originator: Originator.LOCAL; + message: Message; + request: OutgoingRequest; } export interface IncomingOptionsEvent { - originator: Originator.REMOTE; - request: IncomingRequest; + originator: Originator.REMOTE; + request: IncomingRequest; } export interface OutgoingOptionsEvent { - originator: Originator.LOCAL; - request: OutgoingRequest; + originator: Originator.LOCAL; + request: OutgoingRequest; } export type ConnectingListener = (event: ConnectingEvent) => void; @@ -117,127 +127,159 @@ export type RegisteredListener = (event: RegisteredEvent) => void; export type UnRegisteredListener = (event: UnRegisteredEvent) => void; export type RegistrationFailedListener = UnRegisteredListener; export type RegistrationExpiringListener = () => void; -export type IncomingRTCSessionListener = (event: IncomingRTCSessionEvent) => void; -export type OutgoingRTCSessionListener = (event: OutgoingRTCSessionEvent) => void; -export type RTCSessionListener = IncomingRTCSessionListener | OutgoingRTCSessionListener; +export type IncomingRTCSessionListener = ( + event: IncomingRTCSessionEvent +) => void; +export type OutgoingRTCSessionListener = ( + event: OutgoingRTCSessionEvent +) => void; +export type RTCSessionListener = + | IncomingRTCSessionListener + | OutgoingRTCSessionListener; export type IncomingMessageListener = (event: IncomingMessageEvent) => void; export type OutgoingMessageListener = (event: OutgoingMessageEvent) => void; export type MessageListener = IncomingMessageListener | OutgoingMessageListener; export type IncomingOptionsListener = (event: IncomingOptionsEvent) => void; export type OutgoingOptionsListener = (event: OutgoingOptionsEvent) => void; export type OptionsListener = IncomingOptionsListener | OutgoingOptionsListener; -export type SipEventListener = (event: { event: T; request: IncomingRequest; }) => void -export type SipSubscribeListener = (event: { event: T; request: IncomingRequest; }) => void +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type SipEventListener = (event: { + event: T; + request: IncomingRequest; +}) => void; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type SipSubscribeListener = (event: { + event: T; + request: IncomingRequest; +}) => void; export interface UAEventMap { - connecting: ConnectingListener; - connected: ConnectedListener; - disconnected: DisconnectedListener; - registered: RegisteredListener; - unregistered: UnRegisteredListener; - registrationFailed: RegistrationFailedListener; - registrationExpiring: RegistrationExpiringListener; - newRTCSession: RTCSessionListener; - newMessage: MessageListener; - sipEvent: SipEventListener; - newSubscribe: SipSubscribeListener; - newOptions: OptionsListener; + connecting: ConnectingListener; + connected: ConnectedListener; + disconnected: DisconnectedListener; + registered: RegisteredListener; + unregistered: UnRegisteredListener; + registrationFailed: RegistrationFailedListener; + registrationExpiring: RegistrationExpiringListener; + newRTCSession: RTCSessionListener; + newMessage: MessageListener; + sipEvent: SipEventListener; + newSubscribe: SipSubscribeListener; + newOptions: OptionsListener; } export interface UAContactOptions { - anonymous?: boolean; - outbound?: boolean; + anonymous?: boolean; + outbound?: boolean; } export interface UAContact { - pub_gruu?: string, - temp_gruu?: string, - uri?: string; + pub_gruu?: string; + temp_gruu?: string; + uri?: string; - toString(options?: UAContactOptions): string + toString(options?: UAContactOptions): string; } export interface RequestParams { - from_uri: URI; - from_display_name?: string; - from_tag: string; - to_uri: URI; - to_display_name?: string; - to_tag?: string; - call_id: string; - cseq: number; + from_uri: URI; + from_display_name?: string; + from_tag: string; + to_uri: URI; + to_display_name?: string; + to_tag?: string; + call_id: string; + cseq: number; } export interface SubscriberParams { - from_uri: URI; - from_display_name?: string; - to_uri: URI; - to_display_name?: string; + from_uri: URI; + from_display_name?: string; + to_uri: URI; + to_display_name?: string; } export interface SubscriberOptions { - expires?: number; - contentType?: string; - allowEvents?: string; - params?: SubscriberParams; - extraHeaders?: string[]; + expires?: number; + contentType?: string; + allowEvents?: string; + params?: SubscriberParams; + extraHeaders?: string[]; } export interface NotifierOptions { - allowEvents?: string; - extraHeaders?: string[]; - pending?: boolean; + allowEvents?: string; + extraHeaders?: string[]; + pending?: boolean; } declare enum UAStatus { - // UA status codes. - STATUS_INIT = 0, - STATUS_READY = 1, - STATUS_USER_CLOSED = 2, - STATUS_NOT_READY = 3, - // UA error codes. - CONFIGURATION_ERROR = 1, - NETWORK_ERROR = 2 + // UA status codes. + STATUS_INIT = 0, + STATUS_READY = 1, + STATUS_USER_CLOSED = 2, + STATUS_NOT_READY = 3, + // UA error codes. + // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values + CONFIGURATION_ERROR = 1, + // eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values + NETWORK_ERROR = 2, } export class UA extends EventEmitter { - static get C(): typeof UAStatus; + static get C(): typeof UAStatus; - constructor(configuration: UAConfiguration); + constructor(configuration: UAConfiguration); - get C(): typeof UAStatus; + get C(): typeof UAStatus; - get status(): UAStatus; + get status(): UAStatus; - get contact(): UAContact; + get contact(): UAContact; - start(): void; + start(): void; - stop(): void; + stop(): void; - register(): void; + register(): void; - unregister(options?: UnRegisterOptions): void; + unregister(options?: UnRegisterOptions): void; - registrator(): Registrator; + registrator(): Registrator; - call(target: string, options?: CallOptions): RTCSession; + call(target: string, options?: CallOptions): RTCSession; - sendMessage(target: string | URI, body: string, options?: SendMessageOptions): Message; + sendMessage( + target: string | URI, + body: string, + options?: SendMessageOptions + ): Message; - subscribe(target: string, eventName: string, accept: string, options?: SubscriberOptions): Subscriber; + subscribe( + target: string, + eventName: string, + accept: string, + options?: SubscriberOptions + ): Subscriber; - notify( subscribe: IncomingRequest, contentType: string, options?: NotifierOptions): Notifier; + notify( + subscribe: IncomingRequest, + contentType: string, + options?: NotifierOptions + ): Notifier; - terminateSessions(options?: TerminateOptions): void; + terminateSessions(options?: TerminateOptions): void; - isRegistered(): boolean; + isRegistered(): boolean; - isConnected(): boolean; + isConnected(): boolean; - get(parameter: T): UAConfiguration[T]; + get(parameter: T): UAConfiguration[T]; - set(parameter: T, value: UAConfiguration[T]): boolean; + set( + parameter: T, + value: UAConfiguration[T] + ): boolean; - on(type: T, listener: UAEventMap[T]): this; + on(type: T, listener: UAEventMap[T]): this; } diff --git a/src/UA.js b/src/UA.js index 066df6ce..687ff37a 100644 --- a/src/UA.js +++ b/src/UA.js @@ -1,3 +1,5 @@ +/* eslint-disable no-invalid-this */ + const EventEmitter = require('events').EventEmitter; const Logger = require('./Logger'); const JsSIP_C = require('./Constants'); @@ -20,15 +22,15 @@ const config = require('./Config'); const logger = new Logger('UA'); const C = { - // UA status codes. - STATUS_INIT : 0, - STATUS_READY : 1, - STATUS_USER_CLOSED : 2, - STATUS_NOT_READY : 3, - - // UA error codes. - CONFIGURATION_ERROR : 1, - NETWORK_ERROR : 2 + // UA status codes. + STATUS_INIT: 0, + STATUS_READY: 1, + STATUS_USER_CLOSED: 2, + STATUS_NOT_READY: 3, + + // UA error codes. + CONFIGURATION_ERROR: 1, + NETWORK_ERROR: 2, }; /** @@ -38,1004 +40,923 @@ const C = { * @throws {JsSIP.Exceptions.ConfigurationError} If a configuration parameter is invalid. * @throws {TypeError} If no configuration is given. */ -module.exports = class UA extends EventEmitter -{ - // Expose C object. - static get C() - { - return C; - } - - constructor(configuration) - { - // Check configuration argument. - if (!configuration) - { - throw new TypeError('Not enough arguments'); - } - - // Hide sensitive information. - const sensitiveKeys = [ 'password', 'ha1', 'authorization_jwt' ]; - - logger.debug('new() [configuration:%o]', - Object.entries(configuration).filter(([ key ]) => !sensitiveKeys.includes(key)) - ); - - super(); - - this._cache = { - credentials : {} - }; - - this._configuration = Object.assign({}, config.settings); - this._dynConfiguration = {}; - this._dialogs = {}; - - // User actions outside any session/dialog (MESSAGE/OPTIONS). - this._applicants = {}; - - this._sessions = {}; - this._transport = null; - this._contact = null; - this._status = C.STATUS_INIT; - this._error = null; - this._transactions = { - nist : {}, - nict : {}, - ist : {}, - ict : {} - }; - - // Custom UA empty object for high level use. - this._data = {}; - - this._closeTimer = null; - - // Load configuration. - try - { - this._loadConfig(configuration); - } - catch (error) - { - this._status = C.STATUS_NOT_READY; - this._error = C.CONFIGURATION_ERROR; - throw error; - } - - // Initialize registrator. - this._registrator = new Registrator(this); - } - - get C() - { - return C; - } - - get status() - { - return this._status; - } - - get contact() - { - return this._contact; - } - - get configuration() - { - return this._configuration; - } - - get transport() - { - return this._transport; - } - - // ================= - // High Level API - // ================= - - /** - * Connect to the server if status = STATUS_INIT. - * Resume UA after being closed. - */ - start() - { - logger.debug('start()'); - - if (this._status === C.STATUS_INIT) - { - this._transport.connect(); - } - else if (this._status === C.STATUS_USER_CLOSED) - { - logger.debug('restarting UA'); - - // Disconnect. - if (this._closeTimer !== null) - { - clearTimeout(this._closeTimer); - this._closeTimer = null; - this._transport.disconnect(); - } - - // Reconnect. - this._status = C.STATUS_INIT; - this._transport.connect(); - } - else if (this._status === C.STATUS_READY) - { - logger.debug('UA is in READY status, not restarted'); - } - else - { - logger.debug('ERROR: connection is down, Auto-Recovery system is trying to reconnect'); - } - - // Set dynamic configuration. - this._dynConfiguration.register = this._configuration.register; - } - - /** - * Register. - */ - register() - { - logger.debug('register()'); - - this._dynConfiguration.register = true; - this._registrator.register(); - } - - /** - * Unregister. - */ - unregister(options) - { - logger.debug('unregister()'); - - this._dynConfiguration.register = false; - this._registrator.unregister(options); - } - - /** - * Get the Registrator instance. - */ - registrator() - { - return this._registrator; - } - - /** - * Registration state. - */ - isRegistered() - { - return this._registrator.registered; - } - - /** - * Connection state. - */ - isConnected() - { - return this._transport.isConnected(); - } - - /** - * Make an outgoing call. - * - * -param {String} target - * -param {Object} [options] - * - * -throws {TypeError} - * - */ - call(target, options) - { - logger.debug('call()'); - - const session = new RTCSession(this); - - session.connect(target, options); - - return session; - } - - /** - * Send a message. - * - * -param {String} target - * -param {String} body - * -param {Object} [options] - * - * -throws {TypeError} - * - */ - sendMessage(target, body, options) - { - logger.debug('sendMessage()'); - - const message = new Message(this); - - message.send(target, body, options); - - return message; - } - - /** - * Create subscriber instance - */ - subscribe(target, eventName, accept, options) - { - logger.debug('subscribe()'); - - return new Subscriber(this, target, eventName, accept, options); - } - - /** - * Create notifier instance - */ - notify(subscribe, contentType, options) - { - logger.debug('notify()'); - - return new Notifier(this, subscribe, contentType, options); - } - - /** - * Send a SIP OPTIONS. - * - * -param {String} target - * -param {String} [body] - * -param {Object} [options] - * - * -throws {TypeError} - * - */ - sendOptions(target, body, options) - { - logger.debug('sendOptions()'); - - const message = new Options(this); - - message.send(target, body, options); - - return message; - } - - /** - * Terminate ongoing sessions. - */ - terminateSessions(options) - { - logger.debug('terminateSessions()'); - - for (const idx in this._sessions) - { - if (!this._sessions[idx].isEnded()) - { - this._sessions[idx].terminate(options); - } - } - } - - /** - * Gracefully close. - * - */ - stop() - { - logger.debug('stop()'); - - // Remove dynamic settings. - this._dynConfiguration = {}; - - if (this._status === C.STATUS_USER_CLOSED) - { - logger.debug('UA already closed'); - - return; - } - - // Close registrator. - this._registrator.close(); - - // If there are session wait a bit so CANCEL/BYE can be sent and their responses received. - const num_sessions = Object.keys(this._sessions).length; - - // Run _terminate_ on every Session. - for (const session in this._sessions) - { - if (Object.prototype.hasOwnProperty.call(this._sessions, session)) - { - logger.debug(`closing session ${session}`); - try { this._sessions[session].terminate(); } - // eslint-disable-next-line no-unused-vars - catch (error) {} - } - } - - // Run _close_ on every applicant. - for (const applicant in this._applicants) - { - if (Object.prototype.hasOwnProperty.call(this._applicants, applicant)) - try { this._applicants[applicant].close(); } - // eslint-disable-next-line no-unused-vars - catch (error) {} - } - - this._status = C.STATUS_USER_CLOSED; - - const num_transactions = - Object.keys(this._transactions.nict).length + - Object.keys(this._transactions.nist).length + - Object.keys(this._transactions.ict).length + - Object.keys(this._transactions.ist).length; - - if (num_transactions === 0 && num_sessions === 0) - { - this._transport.disconnect(); - } - else - { - this._closeTimer = setTimeout(() => - { - this._closeTimer = null; - this._transport.disconnect(); - }, 2000); - } - } - - /** - * Normalice a string into a valid SIP request URI - * -param {String} target - * -returns {JsSIP.URI|undefined} - */ - normalizeTarget(target) - { - return Utils.normalizeTarget(target, this._configuration.hostport_params); - } - - /** - * Allow retrieving configuration and autogenerated fields in runtime. - */ - get(parameter) - { - switch (parameter) - { - case 'authorization_user': - return this._configuration.authorization_user; - - case 'realm': - return this._configuration.realm; - - case 'ha1': - return this._configuration.ha1; - - case 'authorization_jwt': - return this._configuration.authorization_jwt; - - default: - logger.warn('get() | cannot get "%s" parameter in runtime', parameter); - - return undefined; - } - } - - /** - * Allow configuration changes in runtime. - * Returns true if the parameter could be set. - */ - set(parameter, value) - { - switch (parameter) - { - case 'authorization_user': { - this._configuration.authorization_user = String(value); - break; - } - - case 'password': { - this._configuration.password = String(value); - break; - } - - case 'realm': { - this._configuration.realm = String(value); - break; - } - - case 'ha1': { - this._configuration.ha1 = String(value); - // Delete the plain SIP password. - this._configuration.password = null; - break; - } - - case 'authorization_jwt': { - this._configuration.authorization_jwt = String(value); - break; - } - - case 'display_name': { - this._configuration.display_name = value; - break; - } - - case 'extra_headers': { - this._configuration.extra_headers = value; - break; - } - - default: - logger.warn('set() | cannot set "%s" parameter in runtime', parameter); - - return false; - } - - return true; - } - - // ========================== - // Event Handlers. - // ========================== - - /** - * new Transaction - */ - newTransaction(transaction) - { - this._transactions[transaction.type][transaction.id] = transaction; - this.emit('newTransaction', { - transaction - }); - } - - /** - * Transaction destroyed. - */ - destroyTransaction(transaction) - { - delete this._transactions[transaction.type][transaction.id]; - this.emit('transactionDestroyed', { - transaction - }); - } - - /** - * new Dialog - */ - newDialog(dialog) - { - this._dialogs[dialog.id] = dialog; - } - - /** - * Dialog destroyed. - */ - destroyDialog(dialog) - { - delete this._dialogs[dialog.id]; - } - - /** - * new Message - */ - newMessage(message, data) - { - this._applicants[message] = message; - this.emit('newMessage', data); - } - - /** - * new Options - */ - newOptions(message, data) - { - this._applicants[message] = message; - this.emit('newOptions', data); - } - - /** - * Message destroyed. - */ - destroyMessage(message) - { - delete this._applicants[message]; - } - - /** - * new RTCSession - */ - newRTCSession(session, data) - { - this._sessions[session.id] = session; - this.emit('newRTCSession', data); - } - - /** - * RTCSession destroyed. - */ - destroyRTCSession(session) - { - delete this._sessions[session.id]; - } - - /** - * Registered - */ - registered(data) - { - this.emit('registered', data); - } - - /** - * Unregistered - */ - unregistered(data) - { - this.emit('unregistered', data); - } - - /** - * Registration Failed - */ - registrationFailed(data) - { - this.emit('registrationFailed', data); - } - - // ========================= - // ReceiveRequest. - // ========================= - - /** - * Request reception - */ - receiveRequest(request) - { - const method = request.method; - - // Check that request URI points to us. - if (request.ruri.user !== this._configuration.uri.user && - request.ruri.user !== this._contact.uri.user) - { - logger.debug('Request-URI does not point to us'); - if (request.method !== JsSIP_C.ACK) - { - request.reply_sl(404); - } - - return; - } - - // Check request URI scheme. - if (request.ruri.scheme === JsSIP_C.SIPS) - { - request.reply_sl(416); - - return; - } - - // Check transaction. - if (Transactions.checkTransaction(this, request)) - { - return; - } - - // Create the server transaction. - if (method === JsSIP_C.INVITE) - { - /* eslint-disable no-new */ - new Transactions.InviteServerTransaction(this, this._transport, request); - /* eslint-enable no-new */ - } - else if (method !== JsSIP_C.ACK && method !== JsSIP_C.CANCEL) - { - /* eslint-disable no-new */ - new Transactions.NonInviteServerTransaction(this, this._transport, request); - /* eslint-enable no-new */ - } - - /* RFC3261 12.2.2 - * Requests that do not change in any way the state of a dialog may be - * received within a dialog (for example, an OPTIONS request). - * They are processed as if they had been received outside the dialog. - */ - if (method === JsSIP_C.OPTIONS) - { - if (this.listeners('newOptions').length === 0) - { - request.reply(200); - - return; - } - - const message = new Options(this); - - message.init_incoming(request); - } - else if (method === JsSIP_C.MESSAGE) - { - if (this.listeners('newMessage').length === 0) - { - request.reply(405); - - return; - } - const message = new Message(this); - - message.init_incoming(request); - } - else if (method === JsSIP_C.SUBSCRIBE) - { - if (this.listeners('newSubscribe').length === 0) - { - request.reply(405); - - return; - } - } - else if (method === JsSIP_C.INVITE) - { - // Initial INVITE. - if (!request.to_tag && this.listeners('newRTCSession').length === 0) - { - request.reply(405); - - return; - } - } - - let dialog; - let session; - - // Initial Request. - if (!request.to_tag) - { - switch (method) - { - case JsSIP_C.INVITE: - if (window.RTCPeerConnection) - { // TODO - if (request.hasHeader('replaces')) - { - const replaces = request.replaces; - - dialog = this._findDialog( - replaces.call_id, replaces.from_tag, replaces.to_tag); - if (dialog) - { - session = dialog.owner; - if (!session.isEnded()) - { - session.receiveRequest(request); - } - else - { - request.reply(603); - } - } - else - { - request.reply(481); - } - } - else - { - session = new RTCSession(this); - session.init_incoming(request); - } - } - else - { - logger.warn('INVITE received but WebRTC is not supported'); - request.reply(488); - } - break; - case JsSIP_C.BYE: - // Out of dialog BYE received. - request.reply(481); - break; - case JsSIP_C.CANCEL: - session = this._findSession(request); - if (session) - { - session.receiveRequest(request); - } - else - { - logger.debug('received CANCEL request for a non existent session'); - } - break; - case JsSIP_C.ACK: - /* Absorb it. - * ACK request without a corresponding Invite Transaction - * and without To tag. - */ - break; - case JsSIP_C.NOTIFY: - // Receive new sip event. - this.emit('sipEvent', { - event : request.event, - request - }); - request.reply(200); - break; - case JsSIP_C.SUBSCRIBE: - Notifier.init_incoming(request, () => - { - this.emit('newSubscribe', { - event : request.event, - request - }); - }); - break; - default: - request.reply(405); - break; - } - } - // In-dialog request. - else - { - dialog = this._findDialog(request.call_id, request.from_tag, request.to_tag); - - if (dialog) - { - dialog.receiveRequest(request); - } - else if (method === JsSIP_C.NOTIFY) - { - session = this._findSession(request); - if (session) - { - session.receiveRequest(request); - } - else - { - logger.debug('received NOTIFY request for a non existent subscription'); - request.reply(481, 'Subscription does not exist'); - } - } - - /* RFC3261 12.2.2 - * Request with to tag, but no matching dialog found. - * Exception: ACK for an Invite request for which a dialog has not - * been created. - */ - else if (method !== JsSIP_C.ACK) - { - request.reply(481); - } - } - } - - // ================= - // Utils. - // ================= - - /** - * Get the session to which the request belongs to, if any. - */ - _findSession({ call_id, from_tag, to_tag }) - { - const sessionIDa = call_id + from_tag; - const sessionA = this._sessions[sessionIDa]; - const sessionIDb = call_id + to_tag; - const sessionB = this._sessions[sessionIDb]; - - if (sessionA) - { - return sessionA; - } - else if (sessionB) - { - return sessionB; - } - else - { - return null; - } - } - - /** - * Get the dialog to which the request belongs to, if any. - */ - _findDialog(call_id, from_tag, to_tag) - { - let id = call_id + from_tag + to_tag; - let dialog = this._dialogs[id]; - - if (dialog) - { - return dialog; - } - else - { - id = call_id + to_tag + from_tag; - dialog = this._dialogs[id]; - if (dialog) - { - return dialog; - } - else - { - return null; - } - } - } - - _loadConfig(configuration) - { - // Check and load the given configuration. - // This can throw. - config.load(this._configuration, configuration); - - // Post Configuration Process. - - // Allow passing 0 number as display_name. - if (this._configuration.display_name === 0) - { - this._configuration.display_name = '0'; - } - - // Instance-id for GRUU. - if (!this._configuration.instance_id) - { - this._configuration.instance_id = Utils.newUUID(); - } - - // Jssip_id instance parameter. Static random tag of length 5. - this._configuration.jssip_id = Utils.createRandomToken(5); - - // String containing this._configuration.uri without scheme and user. - const hostport_params = this._configuration.uri.clone(); - - hostport_params.user = null; - this._configuration.hostport_params = hostport_params.toString().replace(/^sip:/i, ''); - - // Transport. - try - { - this._transport = new Transport(this._configuration.sockets, { - // Recovery options. - max_interval : this._configuration.connection_recovery_max_interval, - min_interval : this._configuration.connection_recovery_min_interval - }); - - // Transport event callbacks. - this._transport.onconnecting = onTransportConnecting.bind(this); - this._transport.onconnect = onTransportConnect.bind(this); - this._transport.ondisconnect = onTransportDisconnect.bind(this); - this._transport.ondata = onTransportData.bind(this); - } - catch (error) - { - logger.warn(error); - throw new Exceptions.ConfigurationError('sockets', this._configuration.sockets); - } - - // Remove sockets instance from configuration object. - delete this._configuration.sockets; - - // Check whether authorization_user is explicitly defined. - // Take 'this._configuration.uri.user' value if not. - if (!this._configuration.authorization_user) - { - this._configuration.authorization_user = this._configuration.uri.user; - } - - // If no 'registrar_server' is set use the 'uri' value without user portion and - // without URI params/headers. - if (!this._configuration.registrar_server) - { - const registrar_server = this._configuration.uri.clone(); - - registrar_server.user = null; - registrar_server.clearParams(); - registrar_server.clearHeaders(); - this._configuration.registrar_server = registrar_server; - } - - // User no_answer_timeout. - this._configuration.no_answer_timeout *= 1000; - - // Via Host. - if (this._configuration.contact_uri) - { - this._configuration.via_host = this._configuration.contact_uri.host; - } - - // Contact URI. - else - { - this._configuration.contact_uri = new URI('sip', Utils.createRandomToken(8), this._configuration.via_host, null, { transport: 'ws' }); - } - - this._contact = { - pub_gruu : null, - temp_gruu : null, - uri : this._configuration.contact_uri, - toString(options = {}) - { - const anonymous = options.anonymous || null; - const outbound = options.outbound || null; - let contact = '<'; - - if (anonymous) - { - contact += this.temp_gruu || 'sip:anonymous@anonymous.invalid;transport=ws'; - } - else - { - contact += this.pub_gruu || this.uri.toString(); - } - - if (outbound && (anonymous ? !this.temp_gruu : !this.pub_gruu)) - { - contact += ';ob'; - } - - contact += '>'; - - return contact; - } - }; - - // Seal the configuration. - const writable_parameters = [ - 'authorization_user', 'password', 'realm', 'ha1', 'authorization_jwt', 'display_name', 'register', 'extra_headers' - ]; - - for (const parameter in this._configuration) - { - if (Object.prototype.hasOwnProperty.call(this._configuration, parameter)) - { - if (writable_parameters.indexOf(parameter) !== -1) - { - Object.defineProperty(this._configuration, parameter, { - writable : true, - configurable : false - }); - } - else - { - Object.defineProperty(this._configuration, parameter, { - writable : false, - configurable : false - }); - } - } - } - - logger.debug('configuration parameters after validation:'); - for (const parameter in this._configuration) - { - // Only show the user user configurable parameters. - if (Object.prototype.hasOwnProperty.call(config.settings, parameter)) - { - switch (parameter) - { - case 'uri': - case 'registrar_server': - logger.debug(`- ${parameter}: ${this._configuration[parameter]}`); - break; - case 'password': - case 'ha1': - case 'authorization_jwt': - logger.debug(`- ${parameter}: NOT SHOWN`); - break; - default: - logger.debug(`- ${parameter}: ${JSON.stringify(this._configuration[parameter])}`); - } - } - } - - return; - } +module.exports = class UA extends EventEmitter { + // Expose C object. + static get C() { + return C; + } + + constructor(configuration) { + // Check configuration argument. + if (!configuration) { + throw new TypeError('Not enough arguments'); + } + + // Hide sensitive information. + const sensitiveKeys = ['password', 'ha1', 'authorization_jwt']; + + logger.debug( + 'new() [configuration:%o]', + Object.entries(configuration).filter( + ([key]) => !sensitiveKeys.includes(key) + ) + ); + + super(); + + this._cache = { + credentials: {}, + }; + + this._configuration = Object.assign({}, config.settings); + this._dynConfiguration = {}; + this._dialogs = {}; + + // User actions outside any session/dialog (MESSAGE/OPTIONS). + this._applicants = {}; + + this._sessions = {}; + this._transport = null; + this._contact = null; + this._status = C.STATUS_INIT; + this._error = null; + this._transactions = { + nist: {}, + nict: {}, + ist: {}, + ict: {}, + }; + + // Custom UA empty object for high level use. + this._data = {}; + + this._closeTimer = null; + + // Load configuration. + try { + this._loadConfig(configuration); + } catch (error) { + this._status = C.STATUS_NOT_READY; + this._error = C.CONFIGURATION_ERROR; + throw error; + } + + // Initialize registrator. + this._registrator = new Registrator(this); + } + + get C() { + return C; + } + + get status() { + return this._status; + } + + get contact() { + return this._contact; + } + + get configuration() { + return this._configuration; + } + + get transport() { + return this._transport; + } + + // ================= + // High Level API + // ================= + + /** + * Connect to the server if status = STATUS_INIT. + * Resume UA after being closed. + */ + start() { + logger.debug('start()'); + + if (this._status === C.STATUS_INIT) { + this._transport.connect(); + } else if (this._status === C.STATUS_USER_CLOSED) { + logger.debug('restarting UA'); + + // Disconnect. + if (this._closeTimer !== null) { + clearTimeout(this._closeTimer); + this._closeTimer = null; + this._transport.disconnect(); + } + + // Reconnect. + this._status = C.STATUS_INIT; + this._transport.connect(); + } else if (this._status === C.STATUS_READY) { + logger.debug('UA is in READY status, not restarted'); + } else { + logger.debug( + 'ERROR: connection is down, Auto-Recovery system is trying to reconnect' + ); + } + + // Set dynamic configuration. + this._dynConfiguration.register = this._configuration.register; + } + + /** + * Register. + */ + register() { + logger.debug('register()'); + + this._dynConfiguration.register = true; + this._registrator.register(); + } + + /** + * Unregister. + */ + unregister(options) { + logger.debug('unregister()'); + + this._dynConfiguration.register = false; + this._registrator.unregister(options); + } + + /** + * Get the Registrator instance. + */ + registrator() { + return this._registrator; + } + + /** + * Registration state. + */ + isRegistered() { + return this._registrator.registered; + } + + /** + * Connection state. + */ + isConnected() { + return this._transport.isConnected(); + } + + /** + * Make an outgoing call. + * + * -param {String} target + * -param {Object} [options] + * + * -throws {TypeError} + * + */ + call(target, options) { + logger.debug('call()'); + + const session = new RTCSession(this); + + session.connect(target, options); + + return session; + } + + /** + * Send a message. + * + * -param {String} target + * -param {String} body + * -param {Object} [options] + * + * -throws {TypeError} + * + */ + sendMessage(target, body, options) { + logger.debug('sendMessage()'); + + const message = new Message(this); + + message.send(target, body, options); + + return message; + } + + /** + * Create subscriber instance + */ + subscribe(target, eventName, accept, options) { + logger.debug('subscribe()'); + + return new Subscriber(this, target, eventName, accept, options); + } + + /** + * Create notifier instance + */ + notify(subscribe, contentType, options) { + logger.debug('notify()'); + + return new Notifier(this, subscribe, contentType, options); + } + + /** + * Send a SIP OPTIONS. + * + * -param {String} target + * -param {String} [body] + * -param {Object} [options] + * + * -throws {TypeError} + * + */ + sendOptions(target, body, options) { + logger.debug('sendOptions()'); + + const message = new Options(this); + + message.send(target, body, options); + + return message; + } + + /** + * Terminate ongoing sessions. + */ + terminateSessions(options) { + logger.debug('terminateSessions()'); + + for (const idx in this._sessions) { + if (!this._sessions[idx].isEnded()) { + this._sessions[idx].terminate(options); + } + } + } + + /** + * Gracefully close. + * + */ + stop() { + logger.debug('stop()'); + + // Remove dynamic settings. + this._dynConfiguration = {}; + + if (this._status === C.STATUS_USER_CLOSED) { + logger.debug('UA already closed'); + + return; + } + + // Close registrator. + this._registrator.close(); + + // If there are session wait a bit so CANCEL/BYE can be sent and their responses received. + const num_sessions = Object.keys(this._sessions).length; + + // Run _terminate_ on every Session. + for (const session in this._sessions) { + if (Object.prototype.hasOwnProperty.call(this._sessions, session)) { + logger.debug(`closing session ${session}`); + try { + this._sessions[session].terminate(); + } catch (error) {} + } + } + + // Run _close_ on every applicant. + for (const applicant in this._applicants) { + if (Object.prototype.hasOwnProperty.call(this._applicants, applicant)) { + try { + this._applicants[applicant].close(); + } catch (error) {} + } + } + + this._status = C.STATUS_USER_CLOSED; + + const num_transactions = + Object.keys(this._transactions.nict).length + + Object.keys(this._transactions.nist).length + + Object.keys(this._transactions.ict).length + + Object.keys(this._transactions.ist).length; + + if (num_transactions === 0 && num_sessions === 0) { + this._transport.disconnect(); + } else { + this._closeTimer = setTimeout(() => { + this._closeTimer = null; + this._transport.disconnect(); + }, 2000); + } + } + + /** + * Normalice a string into a valid SIP request URI + * -param {String} target + * -returns {JsSIP.URI|undefined} + */ + normalizeTarget(target) { + return Utils.normalizeTarget(target, this._configuration.hostport_params); + } + + /** + * Allow retrieving configuration and autogenerated fields in runtime. + */ + get(parameter) { + switch (parameter) { + case 'authorization_user': { + return this._configuration.authorization_user; + } + + case 'realm': { + return this._configuration.realm; + } + + case 'ha1': { + return this._configuration.ha1; + } + + case 'authorization_jwt': { + return this._configuration.authorization_jwt; + } + + default: { + logger.warn('get() | cannot get "%s" parameter in runtime', parameter); + + return undefined; + } + } + } + + /** + * Allow configuration changes in runtime. + * Returns true if the parameter could be set. + */ + set(parameter, value) { + switch (parameter) { + case 'authorization_user': { + this._configuration.authorization_user = String(value); + break; + } + + case 'password': { + this._configuration.password = String(value); + break; + } + + case 'realm': { + this._configuration.realm = String(value); + break; + } + + case 'ha1': { + this._configuration.ha1 = String(value); + // Delete the plain SIP password. + this._configuration.password = null; + break; + } + + case 'authorization_jwt': { + this._configuration.authorization_jwt = String(value); + break; + } + + case 'display_name': { + this._configuration.display_name = value; + break; + } + + case 'extra_headers': { + this._configuration.extra_headers = value; + break; + } + + default: { + logger.warn('set() | cannot set "%s" parameter in runtime', parameter); + + return false; + } + } + + return true; + } + + // ========================== + // Event Handlers. + // ========================== + + /** + * new Transaction + */ + newTransaction(transaction) { + this._transactions[transaction.type][transaction.id] = transaction; + this.emit('newTransaction', { + transaction, + }); + } + + /** + * Transaction destroyed. + */ + destroyTransaction(transaction) { + delete this._transactions[transaction.type][transaction.id]; + this.emit('transactionDestroyed', { + transaction, + }); + } + + /** + * new Dialog + */ + newDialog(dialog) { + this._dialogs[dialog.id] = dialog; + } + + /** + * Dialog destroyed. + */ + destroyDialog(dialog) { + delete this._dialogs[dialog.id]; + } + + /** + * new Message + */ + newMessage(message, data) { + this._applicants[message] = message; + this.emit('newMessage', data); + } + + /** + * new Options + */ + newOptions(message, data) { + this._applicants[message] = message; + this.emit('newOptions', data); + } + + /** + * Message destroyed. + */ + destroyMessage(message) { + delete this._applicants[message]; + } + + /** + * new RTCSession + */ + newRTCSession(session, data) { + this._sessions[session.id] = session; + this.emit('newRTCSession', data); + } + + /** + * RTCSession destroyed. + */ + destroyRTCSession(session) { + delete this._sessions[session.id]; + } + + /** + * Registered + */ + registered(data) { + this.emit('registered', data); + } + + /** + * Unregistered + */ + unregistered(data) { + this.emit('unregistered', data); + } + + /** + * Registration Failed + */ + registrationFailed(data) { + this.emit('registrationFailed', data); + } + + // ========================= + // ReceiveRequest. + // ========================= + + /** + * Request reception + */ + receiveRequest(request) { + const method = request.method; + + // Check that request URI points to us. + if ( + request.ruri.user !== this._configuration.uri.user && + request.ruri.user !== this._contact.uri.user + ) { + logger.debug('Request-URI does not point to us'); + if (request.method !== JsSIP_C.ACK) { + request.reply_sl(404); + } + + return; + } + + // Check request URI scheme. + if (request.ruri.scheme === JsSIP_C.SIPS) { + request.reply_sl(416); + + return; + } + + // Check transaction. + if (Transactions.checkTransaction(this, request)) { + return; + } + + // Create the server transaction. + if (method === JsSIP_C.INVITE) { + /* eslint-disable no-new */ + new Transactions.InviteServerTransaction(this, this._transport, request); + /* eslint-enable no-new */ + } else if (method !== JsSIP_C.ACK && method !== JsSIP_C.CANCEL) { + /* eslint-disable no-new */ + new Transactions.NonInviteServerTransaction( + this, + this._transport, + request + ); + /* eslint-enable no-new */ + } + + /* RFC3261 12.2.2 + * Requests that do not change in any way the state of a dialog may be + * received within a dialog (for example, an OPTIONS request). + * They are processed as if they had been received outside the dialog. + */ + if (method === JsSIP_C.OPTIONS) { + if (this.listeners('newOptions').length === 0) { + request.reply(200); + + return; + } + + const message = new Options(this); + + message.init_incoming(request); + } else if (method === JsSIP_C.MESSAGE) { + if (this.listeners('newMessage').length === 0) { + request.reply(405); + + return; + } + const message = new Message(this); + + message.init_incoming(request); + } else if (method === JsSIP_C.SUBSCRIBE) { + if (this.listeners('newSubscribe').length === 0) { + request.reply(405); + + return; + } + } else if (method === JsSIP_C.INVITE) { + // Initial INVITE. + if (!request.to_tag && this.listeners('newRTCSession').length === 0) { + request.reply(405); + + return; + } + } + + let dialog; + let session; + + // Initial Request. + if (!request.to_tag) { + switch (method) { + case JsSIP_C.INVITE: { + // eslint-disable-next-line no-undef + if (window.RTCPeerConnection) { + // TODO + if (request.hasHeader('replaces')) { + const replaces = request.replaces; + + dialog = this._findDialog( + replaces.call_id, + replaces.from_tag, + replaces.to_tag + ); + if (dialog) { + session = dialog.owner; + if (!session.isEnded()) { + session.receiveRequest(request); + } else { + request.reply(603); + } + } else { + request.reply(481); + } + } else { + session = new RTCSession(this); + session.init_incoming(request); + } + } else { + logger.warn('INVITE received but WebRTC is not supported'); + request.reply(488); + } + break; + } + case JsSIP_C.BYE: { + // Out of dialog BYE received. + request.reply(481); + break; + } + case JsSIP_C.CANCEL: { + session = this._findSession(request); + if (session) { + session.receiveRequest(request); + } else { + logger.debug('received CANCEL request for a non existent session'); + } + break; + } + case JsSIP_C.ACK: { + /* Absorb it. + * ACK request without a corresponding Invite Transaction + * and without To tag. + */ + break; + } + case JsSIP_C.NOTIFY: { + // Receive new sip event. + this.emit('sipEvent', { + event: request.event, + request, + }); + request.reply(200); + break; + } + case JsSIP_C.SUBSCRIBE: { + Notifier.init_incoming(request, () => { + this.emit('newSubscribe', { + event: request.event, + request, + }); + }); + break; + } + default: { + request.reply(405); + break; + } + } + } + // In-dialog request. + else { + dialog = this._findDialog( + request.call_id, + request.from_tag, + request.to_tag + ); + + if (dialog) { + dialog.receiveRequest(request); + } else if (method === JsSIP_C.NOTIFY) { + session = this._findSession(request); + if (session) { + session.receiveRequest(request); + } else { + logger.debug( + 'received NOTIFY request for a non existent subscription' + ); + request.reply(481, 'Subscription does not exist'); + } + } else if (method !== JsSIP_C.ACK) { + /* RFC3261 12.2.2 + * Request with to tag, but no matching dialog found. + * Exception: ACK for an Invite request for which a dialog has not + * been created. + */ + request.reply(481); + } + } + } + + // ================= + // Utils. + // ================= + + /** + * Get the session to which the request belongs to, if any. + */ + _findSession({ call_id, from_tag, to_tag }) { + const sessionIDa = call_id + from_tag; + const sessionA = this._sessions[sessionIDa]; + const sessionIDb = call_id + to_tag; + const sessionB = this._sessions[sessionIDb]; + + if (sessionA) { + return sessionA; + } else if (sessionB) { + return sessionB; + } else { + return null; + } + } + + /** + * Get the dialog to which the request belongs to, if any. + */ + _findDialog(call_id, from_tag, to_tag) { + let id = call_id + from_tag + to_tag; + let dialog = this._dialogs[id]; + + if (dialog) { + return dialog; + } else { + id = call_id + to_tag + from_tag; + dialog = this._dialogs[id]; + if (dialog) { + return dialog; + } else { + return null; + } + } + } + + _loadConfig(configuration) { + // Check and load the given configuration. + // This can throw. + config.load(this._configuration, configuration); + + // Post Configuration Process. + + // Allow passing 0 number as display_name. + if (this._configuration.display_name === 0) { + this._configuration.display_name = '0'; + } + + // Instance-id for GRUU. + if (!this._configuration.instance_id) { + this._configuration.instance_id = Utils.newUUID(); + } + + // Jssip_id instance parameter. Static random tag of length 5. + this._configuration.jssip_id = Utils.createRandomToken(5); + + // String containing this._configuration.uri without scheme and user. + const hostport_params = this._configuration.uri.clone(); + + hostport_params.user = null; + this._configuration.hostport_params = hostport_params + .toString() + .replace(/^sip:/i, ''); + + // Transport. + try { + this._transport = new Transport(this._configuration.sockets, { + // Recovery options. + max_interval: this._configuration.connection_recovery_max_interval, + min_interval: this._configuration.connection_recovery_min_interval, + }); + + // Transport event callbacks. + this._transport.onconnecting = onTransportConnecting.bind(this); + this._transport.onconnect = onTransportConnect.bind(this); + this._transport.ondisconnect = onTransportDisconnect.bind(this); + this._transport.ondata = onTransportData.bind(this); + } catch (error) { + logger.warn(error); + throw new Exceptions.ConfigurationError( + 'sockets', + this._configuration.sockets + ); + } + + // Remove sockets instance from configuration object. + delete this._configuration.sockets; + + // Check whether authorization_user is explicitly defined. + // Take 'this._configuration.uri.user' value if not. + if (!this._configuration.authorization_user) { + this._configuration.authorization_user = this._configuration.uri.user; + } + + // If no 'registrar_server' is set use the 'uri' value without user portion and + // without URI params/headers. + if (!this._configuration.registrar_server) { + const registrar_server = this._configuration.uri.clone(); + + registrar_server.user = null; + registrar_server.clearParams(); + registrar_server.clearHeaders(); + this._configuration.registrar_server = registrar_server; + } + + // User no_answer_timeout. + this._configuration.no_answer_timeout *= 1000; + + // Via Host. + if (this._configuration.contact_uri) { + this._configuration.via_host = this._configuration.contact_uri.host; + } + + // Contact URI. + else { + this._configuration.contact_uri = new URI( + 'sip', + Utils.createRandomToken(8), + this._configuration.via_host, + null, + { transport: 'ws' } + ); + } + + this._contact = { + pub_gruu: null, + temp_gruu: null, + uri: this._configuration.contact_uri, + toString(options = {}) { + const anonymous = options.anonymous || null; + const outbound = options.outbound || null; + let contact = '<'; + + if (anonymous) { + contact += + this.temp_gruu || 'sip:anonymous@anonymous.invalid;transport=ws'; + } else { + contact += this.pub_gruu || this.uri.toString(); + } + + if (outbound && (anonymous ? !this.temp_gruu : !this.pub_gruu)) { + contact += ';ob'; + } + + contact += '>'; + + return contact; + }, + }; + + // Seal the configuration. + const writable_parameters = [ + 'authorization_user', + 'password', + 'realm', + 'ha1', + 'authorization_jwt', + 'display_name', + 'register', + 'extra_headers', + ]; + + for (const parameter in this._configuration) { + if ( + Object.prototype.hasOwnProperty.call(this._configuration, parameter) + ) { + if (writable_parameters.indexOf(parameter) !== -1) { + Object.defineProperty(this._configuration, parameter, { + writable: true, + configurable: false, + }); + } else { + Object.defineProperty(this._configuration, parameter, { + writable: false, + configurable: false, + }); + } + } + } + + logger.debug('configuration parameters after validation:'); + for (const parameter in this._configuration) { + // Only show the user user configurable parameters. + if (Object.prototype.hasOwnProperty.call(config.settings, parameter)) { + switch (parameter) { + case 'uri': + case 'registrar_server': { + logger.debug(`- ${parameter}: ${this._configuration[parameter]}`); + break; + } + case 'password': + case 'ha1': + case 'authorization_jwt': { + logger.debug(`- ${parameter}: NOT SHOWN`); + break; + } + default: { + logger.debug( + `- ${parameter}: ${JSON.stringify(this._configuration[parameter])}` + ); + } + } + } + } + + return; + } }; /** @@ -1043,117 +964,103 @@ module.exports = class UA extends EventEmitter */ // Transport connecting event. -function onTransportConnecting(data) -{ - this.emit('connecting', data); +function onTransportConnecting(data) { + this.emit('connecting', data); } // Transport connected event. -function onTransportConnect(data) -{ - if (this._status === C.STATUS_USER_CLOSED) - { - return; - } - - this._status = C.STATUS_READY; - this._error = null; - - this.emit('connected', data); - - if (this._dynConfiguration.register) - { - this._registrator.register(); - } +function onTransportConnect(data) { + if (this._status === C.STATUS_USER_CLOSED) { + return; + } + + this._status = C.STATUS_READY; + this._error = null; + + this.emit('connected', data); + + if (this._dynConfiguration.register) { + this._registrator.register(); + } } // Transport disconnected event. -function onTransportDisconnect(data) -{ - // Run _onTransportError_ callback on every client transaction using _transport_. - const client_transactions = [ 'nict', 'ict', 'nist', 'ist' ]; - - for (const type of client_transactions) - { - for (const id in this._transactions[type]) - { - if (Object.prototype.hasOwnProperty.call(this._transactions[type], id)) - { - this._transactions[type][id].onTransportError(); - } - } - } - - this.emit('disconnected', data); - - // Call registrator _onTransportClosed_. - this._registrator.onTransportClosed(); - - if (this._status !== C.STATUS_USER_CLOSED) - { - this._status = C.STATUS_NOT_READY; - this._error = C.NETWORK_ERROR; - } +function onTransportDisconnect(data) { + // Run _onTransportError_ callback on every client transaction using _transport_. + const client_transactions = ['nict', 'ict', 'nist', 'ist']; + + for (const type of client_transactions) { + for (const id in this._transactions[type]) { + if (Object.prototype.hasOwnProperty.call(this._transactions[type], id)) { + this._transactions[type][id].onTransportError(); + } + } + } + + this.emit('disconnected', data); + + // Call registrator _onTransportClosed_. + this._registrator.onTransportClosed(); + + if (this._status !== C.STATUS_USER_CLOSED) { + this._status = C.STATUS_NOT_READY; + this._error = C.NETWORK_ERROR; + } } // Transport data event. -function onTransportData(data) -{ - const transport = data.transport; - let message = data.message; - - message = Parser.parseMessage(message, this); - - if (!message) - { - return; - } - - if (this._status === C.STATUS_USER_CLOSED && - message instanceof SIPMessage.IncomingRequest) - { - return; - } - - // Do some sanity check. - if (!sanityCheck(message, this, transport)) - { - return; - } - - if (message instanceof SIPMessage.IncomingRequest) - { - message.transport = transport; - this.receiveRequest(message); - } - else if (message instanceof SIPMessage.IncomingResponse) - { - /* Unike stated in 18.1.2, if a response does not match - * any transaction, it is discarded here and no passed to the core - * in order to be discarded there. - */ - - let transaction; - - switch (message.method) - { - case JsSIP_C.INVITE: - transaction = this._transactions.ict[message.via_branch]; - if (transaction) - { - transaction.receiveResponse(message); - } - break; - case JsSIP_C.ACK: - // Just in case ;-). - break; - default: - transaction = this._transactions.nict[message.via_branch]; - if (transaction) - { - transaction.receiveResponse(message); - } - break; - } - } +function onTransportData(data) { + const transport = data.transport; + let message = data.message; + + message = Parser.parseMessage(message, this); + + if (!message) { + return; + } + + if ( + this._status === C.STATUS_USER_CLOSED && + message instanceof SIPMessage.IncomingRequest + ) { + return; + } + + // Do some sanity check. + if (!sanityCheck(message, this, transport)) { + return; + } + + if (message instanceof SIPMessage.IncomingRequest) { + message.transport = transport; + this.receiveRequest(message); + } else if (message instanceof SIPMessage.IncomingResponse) { + /* Unike stated in 18.1.2, if a response does not match + * any transaction, it is discarded here and no passed to the core + * in order to be discarded there. + */ + + let transaction; + + switch (message.method) { + case JsSIP_C.INVITE: { + transaction = this._transactions.ict[message.via_branch]; + if (transaction) { + transaction.receiveResponse(message); + } + break; + } + case JsSIP_C.ACK: { + // Just in case ;-). + break; + } + default: { + transaction = this._transactions.nict[message.via_branch]; + if (transaction) { + transaction.receiveResponse(message); + } + break; + } + } + } } diff --git a/src/URI.d.ts b/src/URI.d.ts index 66d3a096..05fcd0db 100644 --- a/src/URI.d.ts +++ b/src/URI.d.ts @@ -1,4 +1,4 @@ -import {Grammar} from './Grammar' +import { Grammar } from './Grammar'; export type URIScheme = 'sip' | string; @@ -7,38 +7,45 @@ export type Parameters = Record; export type Headers = Record; export class URI { - scheme: URIScheme - user: string - host: string - port: number + scheme: URIScheme; + user: string; + host: string; + port: number; - constructor(scheme: URIScheme, user: string, host: string, port?: number, parameters?: Parameters, headers?: Headers); + constructor( + scheme: URIScheme, + user: string, + host: string, + port?: number, + parameters?: Parameters, + headers?: Headers + ); - setParam(key: string, value?: string): void; + setParam(key: string, value?: string): void; - getParam(key: string): T; + getParam(key: string): T; - hasParam(key: string): boolean; + hasParam(key: string): boolean; - deleteParam(key: string): void; + deleteParam(key: string): void; - clearParams(): void; + clearParams(): void; - setHeader(key: string, value: string | string[]): void; + setHeader(key: string, value: string | string[]): void; - getHeader(key: string): string[]; + getHeader(key: string): string[]; - hasHeader(key: string): boolean; + hasHeader(key: string): boolean; - deleteHeader(key: string): void; + deleteHeader(key: string): void; - clearHeaders(): void; + clearHeaders(): void; - clone(): this; + clone(): this; - toString(): string; + toString(): string; - toAor(): string; + toAor(): string; - static parse(uri: string): Grammar | undefined; + static parse(uri: string): Grammar | undefined; } diff --git a/src/URI.js b/src/URI.js index b8ac6fc1..8e629a96 100644 --- a/src/URI.js +++ b/src/URI.js @@ -11,254 +11,214 @@ const Grammar = require('./Grammar'); * -param {Object} [headers] * */ -module.exports = class URI -{ - /** - * Parse the given string and returns a JsSIP.URI instance or undefined if - * it is an invalid URI. - */ - static parse(uri) - { - uri = Grammar.parse(uri, 'SIP_URI'); - - if (uri !== -1) - { - return uri; - } - else - { - return undefined; - } - } - - constructor(scheme, user, host, port, parameters = {}, headers = {}) - { - // Checks. - if (!host) - { - throw new TypeError('missing or invalid "host" parameter'); - } - - // Initialize parameters. - this._parameters = {}; - this._headers = {}; - - this._scheme = scheme || JsSIP_C.SIP; - this._user = user; - this._host = host; - this._port = port; - - for (const param in parameters) - { - if (Object.prototype.hasOwnProperty.call(parameters, param)) - { - this.setParam(param, parameters[param]); - } - } - - for (const header in headers) - { - if (Object.prototype.hasOwnProperty.call(headers, header)) - { - this.setHeader(header, headers[header]); - } - } - } - - get scheme() - { - return this._scheme; - } - - set scheme(value) - { - this._scheme = value.toLowerCase(); - } - - get user() - { - return this._user; - } - - set user(value) - { - this._user = value; - } - - get host() - { - return this._host; - } - - set host(value) - { - this._host = value.toLowerCase(); - } - - get port() - { - return this._port; - } - - set port(value) - { - this._port = value === 0 ? value : (parseInt(value, 10) || null); - } - - setParam(key, value) - { - if (key) - { - this._parameters[key.toLowerCase()] = (typeof value === 'undefined' || value === null) ? null : value.toString(); - } - } - - getParam(key) - { - if (key) - { - return this._parameters[key.toLowerCase()]; - } - } - - hasParam(key) - { - if (key) - { - return (this._parameters.hasOwnProperty(key.toLowerCase()) && true) || false; - } - } - - deleteParam(parameter) - { - parameter = parameter.toLowerCase(); - if (this._parameters.hasOwnProperty(parameter)) - { - const value = this._parameters[parameter]; - - delete this._parameters[parameter]; - - return value; - } - } - - clearParams() - { - this._parameters = {}; - } - - setHeader(name, value) - { - this._headers[Utils.headerize(name)] = (Array.isArray(value)) ? value : [ value ]; - } - - getHeader(name) - { - if (name) - { - return this._headers[Utils.headerize(name)]; - } - } - - hasHeader(name) - { - if (name) - { - return (this._headers.hasOwnProperty(Utils.headerize(name)) && true) || false; - } - } - - deleteHeader(header) - { - header = Utils.headerize(header); - if (this._headers.hasOwnProperty(header)) - { - const value = this._headers[header]; - - delete this._headers[header]; - - return value; - } - } - - clearHeaders() - { - this._headers = {}; - } - - clone() - { - return new URI( - this._scheme, - this._user, - this._host, - this._port, - JSON.parse(JSON.stringify(this._parameters)), - JSON.parse(JSON.stringify(this._headers))); - } - - toString() - { - const headers = []; - - let uri = `${this._scheme}:`; - - if (this._user) - { - uri += `${Utils.escapeUser(this._user)}@`; - } - uri += this._host; - if (this._port || this._port === 0) - { - uri += `:${this._port}`; - } - - for (const parameter in this._parameters) - { - if (Object.prototype.hasOwnProperty.call(this._parameters, parameter)) - { - uri += `;${parameter}`; - - if (this._parameters[parameter] !== null) - { - uri += `=${this._parameters[parameter]}`; - } - } - } - - for (const header in this._headers) - { - if (Object.prototype.hasOwnProperty.call(this._headers, header)) - { - for (const item of this._headers[header]) - { - headers.push(`${header}=${item}`); - } - } - } - - if (headers.length > 0) - { - uri += `?${headers.join('&')}`; - } - - return uri; - } - - toAor(show_port) - { - let aor = `${this._scheme}:`; - - if (this._user) - { - aor += `${Utils.escapeUser(this._user)}@`; - } - aor += this._host; - if (show_port && (this._port || this._port === 0)) - { - aor += `:${this._port}`; - } - - return aor; - } +module.exports = class URI { + /** + * Parse the given string and returns a JsSIP.URI instance or undefined if + * it is an invalid URI. + */ + static parse(uri) { + uri = Grammar.parse(uri, 'SIP_URI'); + + if (uri !== -1) { + return uri; + } else { + return undefined; + } + } + + constructor(scheme, user, host, port, parameters = {}, headers = {}) { + // Checks. + if (!host) { + throw new TypeError('missing or invalid "host" parameter'); + } + + // Initialize parameters. + this._parameters = {}; + this._headers = {}; + + this._scheme = scheme || JsSIP_C.SIP; + this._user = user; + this._host = host; + this._port = port; + + for (const param in parameters) { + if (Object.prototype.hasOwnProperty.call(parameters, param)) { + this.setParam(param, parameters[param]); + } + } + + for (const header in headers) { + if (Object.prototype.hasOwnProperty.call(headers, header)) { + this.setHeader(header, headers[header]); + } + } + } + + get scheme() { + return this._scheme; + } + + set scheme(value) { + this._scheme = value.toLowerCase(); + } + + get user() { + return this._user; + } + + set user(value) { + this._user = value; + } + + get host() { + return this._host; + } + + set host(value) { + this._host = value.toLowerCase(); + } + + get port() { + return this._port; + } + + set port(value) { + this._port = value === 0 ? value : parseInt(value, 10) || null; + } + + setParam(key, value) { + if (key) { + this._parameters[key.toLowerCase()] = + typeof value === 'undefined' || value === null + ? null + : value.toString(); + } + } + + getParam(key) { + if (key) { + return this._parameters[key.toLowerCase()]; + } + } + + hasParam(key) { + if (key) { + return ( + (this._parameters.hasOwnProperty(key.toLowerCase()) && true) || false + ); + } + } + + deleteParam(parameter) { + parameter = parameter.toLowerCase(); + if (this._parameters.hasOwnProperty(parameter)) { + const value = this._parameters[parameter]; + + delete this._parameters[parameter]; + + return value; + } + } + + clearParams() { + this._parameters = {}; + } + + setHeader(name, value) { + this._headers[Utils.headerize(name)] = Array.isArray(value) + ? value + : [value]; + } + + getHeader(name) { + if (name) { + return this._headers[Utils.headerize(name)]; + } + } + + hasHeader(name) { + if (name) { + return ( + (this._headers.hasOwnProperty(Utils.headerize(name)) && true) || false + ); + } + } + + deleteHeader(header) { + header = Utils.headerize(header); + if (this._headers.hasOwnProperty(header)) { + const value = this._headers[header]; + + delete this._headers[header]; + + return value; + } + } + + clearHeaders() { + this._headers = {}; + } + + clone() { + return new URI( + this._scheme, + this._user, + this._host, + this._port, + JSON.parse(JSON.stringify(this._parameters)), + JSON.parse(JSON.stringify(this._headers)) + ); + } + + toString() { + const headers = []; + + let uri = `${this._scheme}:`; + + if (this._user) { + uri += `${Utils.escapeUser(this._user)}@`; + } + uri += this._host; + if (this._port || this._port === 0) { + uri += `:${this._port}`; + } + + for (const parameter in this._parameters) { + if (Object.prototype.hasOwnProperty.call(this._parameters, parameter)) { + uri += `;${parameter}`; + + if (this._parameters[parameter] !== null) { + uri += `=${this._parameters[parameter]}`; + } + } + } + + for (const header in this._headers) { + if (Object.prototype.hasOwnProperty.call(this._headers, header)) { + for (const item of this._headers[header]) { + headers.push(`${header}=${item}`); + } + } + } + + if (headers.length > 0) { + uri += `?${headers.join('&')}`; + } + + return uri; + } + + toAor(show_port) { + let aor = `${this._scheme}:`; + + if (this._user) { + aor += `${Utils.escapeUser(this._user)}@`; + } + aor += this._host; + if (show_port && (this._port || this._port === 0)) { + aor += `:${this._port}`; + } + + return aor; + } }; diff --git a/src/Utils.d.ts b/src/Utils.d.ts index 3e73f884..051a04c5 100644 --- a/src/Utils.d.ts +++ b/src/Utils.d.ts @@ -1,5 +1,5 @@ -import {URI} from './URI' -import {causes} from './Constants' +import { URI } from './URI'; +import { causes } from './Constants'; export function str_utf8_length(str: string): number; @@ -9,6 +9,7 @@ export function isDecimal(num: unknown): num is number; export function isEmpty(value: unknown): boolean; +// eslint-disable-next-line @typescript-eslint/no-explicit-any export function hasMethods(obj: any, ...methodNames: string[]): boolean; export function newTag(): string; @@ -19,7 +20,10 @@ export function hostType(host: string): string; export function escapeUser(user: string): string; -export function normalizeTarget(target: URI | string, domain?: string): URI | undefined; +export function normalizeTarget( + target: URI | string, + domain?: string +): URI | undefined; export function headerize(str: string): string; diff --git a/src/Utils.js b/src/Utils.js index 065873e9..1ed5aac3 100644 --- a/src/Utils.js +++ b/src/Utils.js @@ -2,551 +2,494 @@ const JsSIP_C = require('./Constants'); const URI = require('./URI'); const Grammar = require('./Grammar'); -exports.str_utf8_length = (string) => unescape(encodeURIComponent(string)).length; +exports.str_utf8_length = string => unescape(encodeURIComponent(string)).length; // Used by 'hasMethods'. -const isFunction = exports.isFunction = (fn) => -{ - if (fn !== undefined) - { - return (Object.prototype.toString.call(fn) === '[object Function]')? true : false; - } - else - { - return false; - } +const isFunction = (exports.isFunction = fn => { + if (fn !== undefined) { + return Object.prototype.toString.call(fn) === '[object Function]' + ? true + : false; + } else { + return false; + } +}); + +exports.isString = str => { + if (str !== undefined) { + return Object.prototype.toString.call(str) === '[object String]' + ? true + : false; + } else { + return false; + } }; -exports.isString = (str) => -{ - if (str !== undefined) - { - return (Object.prototype.toString.call(str) === '[object String]')? true : false; - } - else - { - return false; - } -}; - -exports.isDecimal = (num) => !isNaN(num) && (parseFloat(num) === parseInt(num, 10)); +exports.isDecimal = num => !isNaN(num) && parseFloat(num) === parseInt(num, 10); -exports.isEmpty = (value) => -{ - return (value === null || - value === '' || - value === undefined || - (Array.isArray(value) && value.length === 0) || - (typeof(value) === 'number' && isNaN(value))); +exports.isEmpty = value => { + return ( + value === null || + value === '' || + value === undefined || + (Array.isArray(value) && value.length === 0) || + (typeof value === 'number' && isNaN(value)) + ); }; -exports.hasMethods = function(obj, ...methodNames) -{ - for (const methodName of methodNames) - { - if (isFunction(obj[methodName])) - { - return false; - } - } - - return true; +exports.hasMethods = function (obj, ...methodNames) { + for (const methodName of methodNames) { + if (isFunction(obj[methodName])) { + return false; + } + } + + return true; }; // Used by 'newTag'. -const createRandomToken = exports.createRandomToken = (size, base = 32) => -{ - let i, r, token = ''; +const createRandomToken = (exports.createRandomToken = (size, base = 32) => { + let i, + r, + token = ''; - for (i=0; i < size; i++) - { - r = (Math.random() * base) | 0; - token += r.toString(base); - } + for (i = 0; i < size; i++) { + r = (Math.random() * base) | 0; + token += r.toString(base); + } - return token; -}; + return token; +}); exports.newTag = () => createRandomToken(10); // https://stackoverflow.com/users/109538/broofa. -exports.newUUID = () => -{ - const UUID = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => - { - const r = Math.random()*16|0, v = c === 'x' ? r : ((r&0x3)|0x8); - +exports.newUUID = () => { + const UUID = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { + const r = (Math.random() * 16) | 0, + v = c === 'x' ? r : (r & 0x3) | 0x8; - return v.toString(16); - }); + return v.toString(16); + }); - return UUID; + return UUID; }; -exports.hostType = (host) => -{ - if (!host) - { - return; - } - else - { - host = Grammar.parse(host, 'host'); - if (host !== -1) - { - return host.host_type; - } - } +exports.hostType = host => { + if (!host) { + return; + } else { + host = Grammar.parse(host, 'host'); + if (host !== -1) { + return host.host_type; + } + } }; /** -* Hex-escape a SIP URI user. -* Don't hex-escape ':' (%3A), '+' (%2B), '?' (%3F"), '/' (%2F). -* -* Used by 'normalizeTarget'. -*/ -const escapeUser = exports.escapeUser = (user) => - encodeURIComponent(decodeURIComponent(user)) - .replace(/%3A/ig, ':') - .replace(/%2B/ig, '+') - .replace(/%3F/ig, '?') - .replace(/%2F/ig, '/'); + * Hex-escape a SIP URI user. + * Don't hex-escape ':' (%3A), '+' (%2B), '?' (%3F"), '/' (%2F). + * + * Used by 'normalizeTarget'. + */ +const escapeUser = (exports.escapeUser = user => + encodeURIComponent(decodeURIComponent(user)) + .replace(/%3A/gi, ':') + .replace(/%2B/gi, '+') + .replace(/%3F/gi, '?') + .replace(/%2F/gi, '/')); /** -* Normalize SIP URI. -* NOTE: It does not allow a SIP URI without username. -* Accepts 'sip', 'sips' and 'tel' URIs and convert them into 'sip'. -* Detects the domain part (if given) and properly hex-escapes the user portion. -* If the user portion has only 'tel' number symbols the user portion is clean of 'tel' visual separators. -*/ -exports.normalizeTarget = (target, domain) => -{ - // If no target is given then raise an error. - if (!target) - { - return; - // If a URI instance is given then return it. - } - else if (target instanceof URI) - { - return target; - - // If a string is given split it by '@': - // - Last fragment is the desired domain. - // - Otherwise append the given domain argument. - } - else if (typeof target === 'string') - { - const target_array = target.split('@'); - let target_user; - let target_domain; - - switch (target_array.length) - { - case 1: - if (!domain) - { - return; - } - target_user = target; - target_domain = domain; - break; - case 2: - target_user = target_array[0]; - target_domain = target_array[1]; - break; - default: - target_user = target_array.slice(0, target_array.length-1).join('@'); - target_domain = target_array[target_array.length-1]; - } - - // Remove the URI scheme (if present). - target_user = target_user.replace(/^(sips?|tel):/i, ''); - - // Remove 'tel' visual separators if the user portion just contains 'tel' number symbols. - if (/^[-.()]*\+?[0-9\-.()]+$/.test(target_user)) - { - target_user = target_user.replace(/[-.()]/g, ''); - } - - // Build the complete SIP URI. - target = `${JsSIP_C.SIP}:${escapeUser(target_user)}@${target_domain}`; - - // Finally parse the resulting URI. - let uri; - - if ((uri = URI.parse(target))) - { - return uri; - } - else - { - return; - } - } - else - { - return; - } + * Normalize SIP URI. + * NOTE: It does not allow a SIP URI without username. + * Accepts 'sip', 'sips' and 'tel' URIs and convert them into 'sip'. + * Detects the domain part (if given) and properly hex-escapes the user portion. + * If the user portion has only 'tel' number symbols the user portion is clean of 'tel' visual separators. + */ +exports.normalizeTarget = (target, domain) => { + // If no target is given then raise an error. + if (!target) { + return; + // If a URI instance is given then return it. + } else if (target instanceof URI) { + return target; + + // If a string is given split it by '@': + // - Last fragment is the desired domain. + // - Otherwise append the given domain argument. + } else if (typeof target === 'string') { + const target_array = target.split('@'); + let target_user; + let target_domain; + + switch (target_array.length) { + case 1: { + if (!domain) { + return; + } + target_user = target; + target_domain = domain; + break; + } + case 2: { + target_user = target_array[0]; + target_domain = target_array[1]; + break; + } + default: { + target_user = target_array.slice(0, target_array.length - 1).join('@'); + target_domain = target_array[target_array.length - 1]; + } + } + + // Remove the URI scheme (if present). + target_user = target_user.replace(/^(sips?|tel):/i, ''); + + // Remove 'tel' visual separators if the user portion just contains 'tel' number symbols. + if (/^[-.()]*\+?[0-9\-.()]+$/.test(target_user)) { + target_user = target_user.replace(/[-.()]/g, ''); + } + + // Build the complete SIP URI. + target = `${JsSIP_C.SIP}:${escapeUser(target_user)}@${target_domain}`; + + // Finally parse the resulting URI. + let uri; + + if ((uri = URI.parse(target))) { + return uri; + } else { + return; + } + } else { + return; + } }; -exports.headerize = (string) => -{ - const exceptions = { - 'Call-Id' : 'Call-ID', - 'Cseq' : 'CSeq', - 'Www-Authenticate' : 'WWW-Authenticate' - }; - - const name = string.toLowerCase() - .replace(/_/g, '-') - .split('-'); - let hname = ''; - const parts = name.length; - let part; - - for (part = 0; part < parts; part++) - { - if (part !== 0) - { - hname +='-'; - } - hname += name[part].charAt(0).toUpperCase()+name[part].substring(1); - } - if (exceptions[hname]) - { - hname = exceptions[hname]; - } - - return hname; +exports.headerize = string => { + const exceptions = { + 'Call-Id': 'Call-ID', + Cseq: 'CSeq', + 'Www-Authenticate': 'WWW-Authenticate', + }; + + const name = string.toLowerCase().replace(/_/g, '-').split('-'); + let hname = ''; + const parts = name.length; + let part; + + for (part = 0; part < parts; part++) { + if (part !== 0) { + hname += '-'; + } + hname += name[part].charAt(0).toUpperCase() + name[part].substring(1); + } + if (exceptions[hname]) { + hname = exceptions[hname]; + } + + return hname; }; -exports.sipErrorCause = (status_code) => -{ - for (const cause in JsSIP_C.SIP_ERROR_CAUSES) - { - if (JsSIP_C.SIP_ERROR_CAUSES[cause].indexOf(status_code) !== -1) - { - return JsSIP_C.causes[cause]; - } - } - - return JsSIP_C.causes.SIP_FAILURE_CODE; +exports.sipErrorCause = status_code => { + for (const cause in JsSIP_C.SIP_ERROR_CAUSES) { + if (JsSIP_C.SIP_ERROR_CAUSES[cause].indexOf(status_code) !== -1) { + return JsSIP_C.causes[cause]; + } + } + + return JsSIP_C.causes.SIP_FAILURE_CODE; }; /** -* Generate a random Test-Net IP (https://tools.ietf.org/html/rfc5735) -*/ -exports.getRandomTestNetIP = () => -{ - function getOctet(from, to) - { - return Math.floor((Math.random() * (to-from+1)) + from); - } - - return `192.0.2.${getOctet(1, 254)}`; + * Generate a random Test-Net IP (https://tools.ietf.org/html/rfc5735) + */ +exports.getRandomTestNetIP = () => { + function getOctet(from, to) { + return Math.floor(Math.random() * (to - from + 1) + from); + } + + return `192.0.2.${getOctet(1, 254)}`; }; // MD5 (Message-Digest Algorithm) https://www.webtoolkit.info. -exports.calculateMD5 = (string) => -{ - function rotateLeft(lValue, iShiftBits) - { - return (lValue<>>(32-iShiftBits)); - } - - function addUnsigned(lX, lY) - { - const lX8 = (lX & 0x80000000); - const lY8 = (lY & 0x80000000); - const lX4 = (lX & 0x40000000); - const lY4 = (lY & 0x40000000); - const lResult = (lX & 0x3FFFFFFF)+(lY & 0x3FFFFFFF); - - if (lX4 & lY4) - { - return (lResult ^ 0x80000000 ^ lX8 ^ lY8); - } - if (lX4 | lY4) - { - if (lResult & 0x40000000) - { - return (lResult ^ 0xC0000000 ^ lX8 ^ lY8); - } - else - { - return (lResult ^ 0x40000000 ^ lX8 ^ lY8); - } - } - else - { - return (lResult ^ lX8 ^ lY8); - } - } - - function doF(x, y, z) - { - return (x & y) | ((~x) & z); - } - - function doG(x, y, z) - { - return (x & z) | (y & (~z)); - } - - function doH(x, y, z) - { - return (x ^ y ^ z); - } - - function doI(x, y, z) - { - return (y ^ (x | (~z))); - } - - function doFF(a, b, c, d, x, s, ac) - { - a = addUnsigned(a, addUnsigned(addUnsigned(doF(b, c, d), x), ac)); - - return addUnsigned(rotateLeft(a, s), b); - } - - function doGG(a, b, c, d, x, s, ac) - { - a = addUnsigned(a, addUnsigned(addUnsigned(doG(b, c, d), x), ac)); - - return addUnsigned(rotateLeft(a, s), b); - } - - function doHH(a, b, c, d, x, s, ac) - { - a = addUnsigned(a, addUnsigned(addUnsigned(doH(b, c, d), x), ac)); - - return addUnsigned(rotateLeft(a, s), b); - } - - function doII(a, b, c, d, x, s, ac) - { - a = addUnsigned(a, addUnsigned(addUnsigned(doI(b, c, d), x), ac)); - - return addUnsigned(rotateLeft(a, s), b); - } - - function convertToWordArray(str) - { - let lWordCount; - const lMessageLength = str.length; - const lNumberOfWords_temp1=lMessageLength + 8; - const lNumberOfWords_temp2=(lNumberOfWords_temp1-(lNumberOfWords_temp1 % 64))/64; - const lNumberOfWords = (lNumberOfWords_temp2+1)*16; - const lWordArray = new Array(lNumberOfWords-1); - let lBytePosition = 0; - let lByteCount = 0; - - while (lByteCount < lMessageLength) - { - lWordCount = (lByteCount-(lByteCount % 4))/4; - lBytePosition = (lByteCount % 4)*8; - lWordArray[lWordCount] = (lWordArray[lWordCount] | - (str.charCodeAt(lByteCount)<>>29; - - return lWordArray; - } - - function wordToHex(lValue) - { - let wordToHexValue='', wordToHexValue_temp='', lByte, lCount; - - for (lCount = 0; lCount<=3; lCount++) - { - lByte = (lValue>>>(lCount*8)) & 255; - wordToHexValue_temp = `0${lByte.toString(16)}`; - wordToHexValue = wordToHexValue + - wordToHexValue_temp.substr(wordToHexValue_temp.length-2, 2); - } - - return wordToHexValue; - } - - function utf8Encode(str) - { - let utftext = ''; - - for (let n = 0; n < str.length; n++) - { - const c = str.charCodeAt(n); - - if (c < 128) - { - utftext += String.fromCharCode(c); - } - else if ((c > 127) && (c < 2048)) - { - utftext += String.fromCharCode((c >> 6) | 192); - utftext += String.fromCharCode((c & 63) | 128); - } - else - { - utftext += String.fromCharCode((c >> 12) | 224); - utftext += String.fromCharCode(((c >> 6) & 63) | 128); - utftext += String.fromCharCode((c & 63) | 128); - } - } - - return utftext; - } - - let x=[]; - let k, AA, BB, CC, DD, a, b, c, d; - const S11=7, S12=12, S13=17, S14=22; - const S21=5, S22=9, S23=14, S24=20; - const S31=4, S32=11, S33=16, S34=23; - const S41=6, S42=10, S43=15, S44=21; - - string = utf8Encode(string); - - x = convertToWordArray(string); - - a = 0x67452301; b = 0xEFCDAB89; c = 0x98BADCFE; d = 0x10325476; - - for (k=0; k { + function rotateLeft(lValue, iShiftBits) { + return (lValue << iShiftBits) | (lValue >>> (32 - iShiftBits)); + } + + function addUnsigned(lX, lY) { + const lX8 = lX & 0x80000000; + const lY8 = lY & 0x80000000; + const lX4 = lX & 0x40000000; + const lY4 = lY & 0x40000000; + const lResult = (lX & 0x3fffffff) + (lY & 0x3fffffff); + + if (lX4 & lY4) { + return lResult ^ 0x80000000 ^ lX8 ^ lY8; + } + if (lX4 | lY4) { + if (lResult & 0x40000000) { + return lResult ^ 0xc0000000 ^ lX8 ^ lY8; + } else { + return lResult ^ 0x40000000 ^ lX8 ^ lY8; + } + } else { + return lResult ^ lX8 ^ lY8; + } + } + + function doF(x, y, z) { + return (x & y) | (~x & z); + } + + function doG(x, y, z) { + return (x & z) | (y & ~z); + } + + function doH(x, y, z) { + return x ^ y ^ z; + } + + function doI(x, y, z) { + return y ^ (x | ~z); + } + + function doFF(a, b, c, d, x, s, ac) { + a = addUnsigned(a, addUnsigned(addUnsigned(doF(b, c, d), x), ac)); + + return addUnsigned(rotateLeft(a, s), b); + } + + function doGG(a, b, c, d, x, s, ac) { + a = addUnsigned(a, addUnsigned(addUnsigned(doG(b, c, d), x), ac)); + + return addUnsigned(rotateLeft(a, s), b); + } + + function doHH(a, b, c, d, x, s, ac) { + a = addUnsigned(a, addUnsigned(addUnsigned(doH(b, c, d), x), ac)); + + return addUnsigned(rotateLeft(a, s), b); + } + + function doII(a, b, c, d, x, s, ac) { + a = addUnsigned(a, addUnsigned(addUnsigned(doI(b, c, d), x), ac)); + + return addUnsigned(rotateLeft(a, s), b); + } + + function convertToWordArray(str) { + let lWordCount; + const lMessageLength = str.length; + const lNumberOfWords_temp1 = lMessageLength + 8; + const lNumberOfWords_temp2 = + (lNumberOfWords_temp1 - (lNumberOfWords_temp1 % 64)) / 64; + const lNumberOfWords = (lNumberOfWords_temp2 + 1) * 16; + const lWordArray = new Array(lNumberOfWords - 1); + let lBytePosition = 0; + let lByteCount = 0; + + while (lByteCount < lMessageLength) { + lWordCount = (lByteCount - (lByteCount % 4)) / 4; + lBytePosition = (lByteCount % 4) * 8; + lWordArray[lWordCount] = + lWordArray[lWordCount] | (str.charCodeAt(lByteCount) << lBytePosition); + lByteCount++; + } + lWordCount = (lByteCount - (lByteCount % 4)) / 4; + lBytePosition = (lByteCount % 4) * 8; + lWordArray[lWordCount] = lWordArray[lWordCount] | (0x80 << lBytePosition); + lWordArray[lNumberOfWords - 2] = lMessageLength << 3; + lWordArray[lNumberOfWords - 1] = lMessageLength >>> 29; + + return lWordArray; + } + + function wordToHex(lValue) { + let wordToHexValue = '', + wordToHexValue_temp = '', + lByte, + lCount; + + for (lCount = 0; lCount <= 3; lCount++) { + lByte = (lValue >>> (lCount * 8)) & 255; + wordToHexValue_temp = `0${lByte.toString(16)}`; + wordToHexValue = + wordToHexValue + + wordToHexValue_temp.substr(wordToHexValue_temp.length - 2, 2); + } + + return wordToHexValue; + } + + function utf8Encode(str) { + let utftext = ''; + + for (let n = 0; n < str.length; n++) { + const c = str.charCodeAt(n); + + if (c < 128) { + utftext += String.fromCharCode(c); + } else if (c > 127 && c < 2048) { + utftext += String.fromCharCode((c >> 6) | 192); + utftext += String.fromCharCode((c & 63) | 128); + } else { + utftext += String.fromCharCode((c >> 12) | 224); + utftext += String.fromCharCode(((c >> 6) & 63) | 128); + utftext += String.fromCharCode((c & 63) | 128); + } + } + + return utftext; + } + + let x = []; + let k, AA, BB, CC, DD, a, b, c, d; + const S11 = 7, + S12 = 12, + S13 = 17, + S14 = 22; + const S21 = 5, + S22 = 9, + S23 = 14, + S24 = 20; + const S31 = 4, + S32 = 11, + S33 = 16, + S34 = 23; + const S41 = 6, + S42 = 10, + S43 = 15, + S44 = 21; + + string = utf8Encode(string); + + x = convertToWordArray(string); + + a = 0x67452301; + b = 0xefcdab89; + c = 0x98badcfe; + d = 0x10325476; + + for (k = 0; k < x.length; k += 16) { + AA = a; + BB = b; + CC = c; + DD = d; + a = doFF(a, b, c, d, x[k + 0], S11, 0xd76aa478); + d = doFF(d, a, b, c, x[k + 1], S12, 0xe8c7b756); + c = doFF(c, d, a, b, x[k + 2], S13, 0x242070db); + b = doFF(b, c, d, a, x[k + 3], S14, 0xc1bdceee); + a = doFF(a, b, c, d, x[k + 4], S11, 0xf57c0faf); + d = doFF(d, a, b, c, x[k + 5], S12, 0x4787c62a); + c = doFF(c, d, a, b, x[k + 6], S13, 0xa8304613); + b = doFF(b, c, d, a, x[k + 7], S14, 0xfd469501); + a = doFF(a, b, c, d, x[k + 8], S11, 0x698098d8); + d = doFF(d, a, b, c, x[k + 9], S12, 0x8b44f7af); + c = doFF(c, d, a, b, x[k + 10], S13, 0xffff5bb1); + b = doFF(b, c, d, a, x[k + 11], S14, 0x895cd7be); + a = doFF(a, b, c, d, x[k + 12], S11, 0x6b901122); + d = doFF(d, a, b, c, x[k + 13], S12, 0xfd987193); + c = doFF(c, d, a, b, x[k + 14], S13, 0xa679438e); + b = doFF(b, c, d, a, x[k + 15], S14, 0x49b40821); + a = doGG(a, b, c, d, x[k + 1], S21, 0xf61e2562); + d = doGG(d, a, b, c, x[k + 6], S22, 0xc040b340); + c = doGG(c, d, a, b, x[k + 11], S23, 0x265e5a51); + b = doGG(b, c, d, a, x[k + 0], S24, 0xe9b6c7aa); + a = doGG(a, b, c, d, x[k + 5], S21, 0xd62f105d); + d = doGG(d, a, b, c, x[k + 10], S22, 0x2441453); + c = doGG(c, d, a, b, x[k + 15], S23, 0xd8a1e681); + b = doGG(b, c, d, a, x[k + 4], S24, 0xe7d3fbc8); + a = doGG(a, b, c, d, x[k + 9], S21, 0x21e1cde6); + d = doGG(d, a, b, c, x[k + 14], S22, 0xc33707d6); + c = doGG(c, d, a, b, x[k + 3], S23, 0xf4d50d87); + b = doGG(b, c, d, a, x[k + 8], S24, 0x455a14ed); + a = doGG(a, b, c, d, x[k + 13], S21, 0xa9e3e905); + d = doGG(d, a, b, c, x[k + 2], S22, 0xfcefa3f8); + c = doGG(c, d, a, b, x[k + 7], S23, 0x676f02d9); + b = doGG(b, c, d, a, x[k + 12], S24, 0x8d2a4c8a); + a = doHH(a, b, c, d, x[k + 5], S31, 0xfffa3942); + d = doHH(d, a, b, c, x[k + 8], S32, 0x8771f681); + c = doHH(c, d, a, b, x[k + 11], S33, 0x6d9d6122); + b = doHH(b, c, d, a, x[k + 14], S34, 0xfde5380c); + a = doHH(a, b, c, d, x[k + 1], S31, 0xa4beea44); + d = doHH(d, a, b, c, x[k + 4], S32, 0x4bdecfa9); + c = doHH(c, d, a, b, x[k + 7], S33, 0xf6bb4b60); + b = doHH(b, c, d, a, x[k + 10], S34, 0xbebfbc70); + a = doHH(a, b, c, d, x[k + 13], S31, 0x289b7ec6); + d = doHH(d, a, b, c, x[k + 0], S32, 0xeaa127fa); + c = doHH(c, d, a, b, x[k + 3], S33, 0xd4ef3085); + b = doHH(b, c, d, a, x[k + 6], S34, 0x4881d05); + a = doHH(a, b, c, d, x[k + 9], S31, 0xd9d4d039); + d = doHH(d, a, b, c, x[k + 12], S32, 0xe6db99e5); + c = doHH(c, d, a, b, x[k + 15], S33, 0x1fa27cf8); + b = doHH(b, c, d, a, x[k + 2], S34, 0xc4ac5665); + a = doII(a, b, c, d, x[k + 0], S41, 0xf4292244); + d = doII(d, a, b, c, x[k + 7], S42, 0x432aff97); + c = doII(c, d, a, b, x[k + 14], S43, 0xab9423a7); + b = doII(b, c, d, a, x[k + 5], S44, 0xfc93a039); + a = doII(a, b, c, d, x[k + 12], S41, 0x655b59c3); + d = doII(d, a, b, c, x[k + 3], S42, 0x8f0ccc92); + c = doII(c, d, a, b, x[k + 10], S43, 0xffeff47d); + b = doII(b, c, d, a, x[k + 1], S44, 0x85845dd1); + a = doII(a, b, c, d, x[k + 8], S41, 0x6fa87e4f); + d = doII(d, a, b, c, x[k + 15], S42, 0xfe2ce6e0); + c = doII(c, d, a, b, x[k + 6], S43, 0xa3014314); + b = doII(b, c, d, a, x[k + 13], S44, 0x4e0811a1); + a = doII(a, b, c, d, x[k + 4], S41, 0xf7537e82); + d = doII(d, a, b, c, x[k + 11], S42, 0xbd3af235); + c = doII(c, d, a, b, x[k + 2], S43, 0x2ad7d2bb); + b = doII(b, c, d, a, x[k + 9], S44, 0xeb86d391); + a = addUnsigned(a, AA); + b = addUnsigned(b, BB); + c = addUnsigned(c, CC); + d = addUnsigned(d, DD); + } + + const temp = wordToHex(a) + wordToHex(b) + wordToHex(c) + wordToHex(d); + + return temp.toLowerCase(); }; -exports.closeMediaStream = (stream) => -{ - if (!stream) - { - return; - } - - // Latest spec states that MediaStream has no stop() method and instead must - // call stop() on every MediaStreamTrack. - try - { - let tracks; - - if (stream.getTracks) - { - tracks = stream.getTracks(); - for (const track of tracks) - { - track.stop(); - } - } - else - { - tracks = stream.getAudioTracks(); - for (const track of tracks) - { - track.stop(); - } - tracks = stream.getVideoTracks(); - for (const track of tracks) - { - track.stop(); - } - } - } - // eslint-disable-next-line no-unused-vars - catch (error) - { - // Deprecated by the spec, but still in use. - // NOTE: In Temasys IE plugin stream.stop is a callable 'object'. - if (typeof stream.stop === 'function' || typeof stream.stop === 'object') - { - stream.stop(); - } - } +exports.closeMediaStream = stream => { + if (!stream) { + return; + } + + // Latest spec states that MediaStream has no stop() method and instead must + // call stop() on every MediaStreamTrack. + try { + let tracks; + + if (stream.getTracks) { + tracks = stream.getTracks(); + for (const track of tracks) { + track.stop(); + } + } else { + tracks = stream.getAudioTracks(); + for (const track of tracks) { + track.stop(); + } + tracks = stream.getVideoTracks(); + for (const track of tracks) { + track.stop(); + } + } + } catch (error) { + // Deprecated by the spec, but still in use. + // NOTE: In Temasys IE plugin stream.stop is a callable 'object'. + if (typeof stream.stop === 'function' || typeof stream.stop === 'object') { + stream.stop(); + } + } }; -exports.cloneArray = (array) => -{ - return (array && array.slice()) || []; +exports.cloneArray = array => { + return (array && array.slice()) || []; }; -exports.cloneObject = (obj, fallback = {}) => -{ - return (obj && Object.assign({}, obj)) || fallback; +exports.cloneObject = (obj, fallback = {}) => { + return (obj && Object.assign({}, obj)) || fallback; }; diff --git a/src/WebSocketInterface.d.ts b/src/WebSocketInterface.d.ts index 581b7128..4e37a166 100644 --- a/src/WebSocketInterface.d.ts +++ b/src/WebSocketInterface.d.ts @@ -1,5 +1,5 @@ import { Socket } from './Socket'; export class WebSocketInterface extends Socket { - constructor(url: string) + constructor(url: string); } diff --git a/src/WebSocketInterface.js b/src/WebSocketInterface.js index 8d9de815..9dc582e3 100644 --- a/src/WebSocketInterface.js +++ b/src/WebSocketInterface.js @@ -3,175 +3,142 @@ const Grammar = require('./Grammar'); const logger = new Logger('WebSocketInterface'); -module.exports = class WebSocketInterface -{ - constructor(url) - { - logger.debug('new() [url:"%s"]', url); - - this._url = url; - this._sip_uri = null; - this._via_transport = null; - this._ws = null; - - const parsed_url = Grammar.parse(url, 'absoluteURI'); - - if (parsed_url === -1) - { - logger.warn(`invalid WebSocket URI: ${url}`); - throw new TypeError(`Invalid argument: ${url}`); - } - else if (parsed_url.scheme !== 'wss' && parsed_url.scheme !== 'ws') - { - logger.warn(`invalid WebSocket URI scheme: ${parsed_url.scheme}`); - throw new TypeError(`Invalid argument: ${url}`); - } - else - { - this._sip_uri = `sip:${parsed_url.host}${parsed_url.port ? `:${parsed_url.port}` : ''};transport=ws`; - this._via_transport = parsed_url.scheme.toUpperCase(); - } - } - - get via_transport() - { - return this._via_transport; - } - - set via_transport(value) - { - this._via_transport = value.toUpperCase(); - } - - get sip_uri() - { - return this._sip_uri; - } - - get url() - { - return this._url; - } - - connect() - { - logger.debug('connect()'); - - if (this.isConnected()) - { - logger.debug(`WebSocket ${this._url} is already connected`); - - return; - } - else if (this.isConnecting()) - { - logger.debug(`WebSocket ${this._url} is connecting`); - - return; - } - - if (this._ws) - { - this.disconnect(); - } - - logger.debug(`connecting to WebSocket ${this._url}`); - - try - { - this._ws = new WebSocket(this._url, 'sip'); - - this._ws.binaryType = 'arraybuffer'; - - this._ws.onopen = this._onOpen.bind(this); - this._ws.onclose = this._onClose.bind(this); - this._ws.onmessage = this._onMessage.bind(this); - this._ws.onerror = this._onError.bind(this); - } - catch (error) - { - this._onError(error); - } - } - - disconnect() - { - logger.debug('disconnect()'); - - if (this._ws) - { - // Unbind websocket event callbacks. - this._ws.onopen = () => {}; - this._ws.onclose = () => {}; - this._ws.onmessage = () => {}; - this._ws.onerror = () => {}; - - this._ws.close(); - this._ws = null; - } - } - - send(message) - { - logger.debug('send()'); - - if (this.isConnected()) - { - this._ws.send(message); - - return true; - } - else - { - logger.warn('unable to send message, WebSocket is not open'); - - return false; - } - } - - isConnected() - { - return this._ws && this._ws.readyState === this._ws.OPEN; - } - - isConnecting() - { - return this._ws && this._ws.readyState === this._ws.CONNECTING; - } - - - /** - * WebSocket Event Handlers - */ - - _onOpen() - { - logger.debug(`WebSocket ${this._url} connected`); - - this.onconnect(); - } - - _onClose({ wasClean, code, reason }) - { - logger.debug(`WebSocket ${this._url} closed`); - - if (wasClean === false) - { - logger.debug('WebSocket abrupt disconnection'); - } - - this.ondisconnect(!wasClean, code, reason); - } - - _onMessage({ data }) - { - logger.debug('received WebSocket message'); - - this.ondata(data); - } - - _onError(e) - { - logger.warn(`WebSocket ${this._url} error: `, e); - } +module.exports = class WebSocketInterface { + constructor(url) { + logger.debug('new() [url:"%s"]', url); + + this._url = url; + this._sip_uri = null; + this._via_transport = null; + this._ws = null; + + const parsed_url = Grammar.parse(url, 'absoluteURI'); + + if (parsed_url === -1) { + logger.warn(`invalid WebSocket URI: ${url}`); + throw new TypeError(`Invalid argument: ${url}`); + } else if (parsed_url.scheme !== 'wss' && parsed_url.scheme !== 'ws') { + logger.warn(`invalid WebSocket URI scheme: ${parsed_url.scheme}`); + throw new TypeError(`Invalid argument: ${url}`); + } else { + this._sip_uri = `sip:${parsed_url.host}${parsed_url.port ? `:${parsed_url.port}` : ''};transport=ws`; + this._via_transport = parsed_url.scheme.toUpperCase(); + } + } + + get via_transport() { + return this._via_transport; + } + + set via_transport(value) { + this._via_transport = value.toUpperCase(); + } + + get sip_uri() { + return this._sip_uri; + } + + get url() { + return this._url; + } + + connect() { + logger.debug('connect()'); + + if (this.isConnected()) { + logger.debug(`WebSocket ${this._url} is already connected`); + + return; + } else if (this.isConnecting()) { + logger.debug(`WebSocket ${this._url} is connecting`); + + return; + } + + if (this._ws) { + this.disconnect(); + } + + logger.debug(`connecting to WebSocket ${this._url}`); + + try { + this._ws = new WebSocket(this._url, 'sip'); + + this._ws.binaryType = 'arraybuffer'; + + this._ws.onopen = this._onOpen.bind(this); + this._ws.onclose = this._onClose.bind(this); + this._ws.onmessage = this._onMessage.bind(this); + this._ws.onerror = this._onError.bind(this); + } catch (error) { + this._onError(error); + } + } + + disconnect() { + logger.debug('disconnect()'); + + if (this._ws) { + // Unbind websocket event callbacks. + this._ws.onopen = () => {}; + this._ws.onclose = () => {}; + this._ws.onmessage = () => {}; + this._ws.onerror = () => {}; + + this._ws.close(); + this._ws = null; + } + } + + send(message) { + logger.debug('send()'); + + if (this.isConnected()) { + this._ws.send(message); + + return true; + } else { + logger.warn('unable to send message, WebSocket is not open'); + + return false; + } + } + + isConnected() { + return this._ws && this._ws.readyState === this._ws.OPEN; + } + + isConnecting() { + return this._ws && this._ws.readyState === this._ws.CONNECTING; + } + + /** + * WebSocket Event Handlers + */ + + _onOpen() { + logger.debug(`WebSocket ${this._url} connected`); + + this.onconnect(); + } + + _onClose({ wasClean, code, reason }) { + logger.debug(`WebSocket ${this._url} closed`); + + if (wasClean === false) { + logger.debug('WebSocket abrupt disconnection'); + } + + this.ondisconnect(!wasClean, code, reason); + } + + _onMessage({ data }) { + logger.debug('received WebSocket message'); + + this.ondata(data); + } + + _onError(e) { + logger.warn(`WebSocket ${this._url} error: `, e); + } }; diff --git a/src/sanityCheck.js b/src/sanityCheck.js index b843048e..ef3d6611 100644 --- a/src/sanityCheck.js +++ b/src/sanityCheck.js @@ -6,68 +6,53 @@ const Utils = require('./Utils'); const logger = new Logger('sanityCheck'); // Checks for requests and responses. -const all = [ minimumHeaders ]; +const all = [minimumHeaders]; // Checks for requests. const requests = [ - rfc3261_8_2_2_1, - rfc3261_16_3_4, - rfc3261_18_3_request, - rfc3261_8_2_2_2 + rfc3261_8_2_2_1, + rfc3261_16_3_4, + rfc3261_18_3_request, + rfc3261_8_2_2_2, ]; // Checks for responses. -const responses = [ - rfc3261_8_1_3_3, - rfc3261_18_3_response -]; +const responses = [rfc3261_8_1_3_3, rfc3261_18_3_response]; // local variables. let message; let ua; let transport; -module.exports = (m, u, t) => -{ - message = m; - ua = u; - transport = t; - - for (const check of all) - { - if (check() === false) - { - return false; - } - } - - if (message instanceof SIPMessage.IncomingRequest) - { - for (const check of requests) - { - if (check() === false) - { - return false; - } - } - } - - else if (message instanceof SIPMessage.IncomingResponse) - { - for (const check of responses) - { - if (check() === false) - { - return false; - } - } - } - - // Everything is OK. - return true; +module.exports = (m, u, t) => { + message = m; + ua = u; + transport = t; + + for (const check of all) { + if (check() === false) { + return false; + } + } + + if (message instanceof SIPMessage.IncomingRequest) { + for (const check of requests) { + if (check() === false) { + return false; + } + } + } else if (message instanceof SIPMessage.IncomingResponse) { + for (const check of responses) { + if (check() === false) { + return false; + } + } + } + + // Everything is OK. + return true; }; - /* * Sanity Check for incoming Messages * @@ -88,181 +73,169 @@ module.exports = (m, u, t) => */ // Sanity Check functions for requests. -function rfc3261_8_2_2_1() -{ - if (message.s('to').uri.scheme !== 'sip') - { - reply(416); - - return false; - } +function rfc3261_8_2_2_1() { + if (message.s('to').uri.scheme !== 'sip') { + reply(416); + + return false; + } } -function rfc3261_16_3_4() -{ - if (!message.to_tag) - { - if (message.call_id.substr(0, 5) === ua.configuration.jssip_id) - { - reply(482); - - return false; - } - } +function rfc3261_16_3_4() { + if (!message.to_tag) { + if (message.call_id.substr(0, 5) === ua.configuration.jssip_id) { + reply(482); + + return false; + } + } } -function rfc3261_18_3_request() -{ - const len = Utils.str_utf8_length(message.body); - const contentLength = message.getHeader('content-length'); +function rfc3261_18_3_request() { + const len = Utils.str_utf8_length(message.body); + const contentLength = message.getHeader('content-length'); - if (len < contentLength) - { - reply(400); + if (len < contentLength) { + reply(400); - return false; - } + return false; + } } -function rfc3261_8_2_2_2() -{ - const fromTag = message.from_tag; - const call_id = message.call_id; - const cseq = message.cseq; - let tr; - - // Accept any in-dialog request. - if (message.to_tag) - { - return; - } - - // INVITE request. - if (message.method === JsSIP_C.INVITE) - { - // If the branch matches the key of any IST then assume it is a retransmission - // and ignore the INVITE. - // TODO: we should reply the last response. - if (ua._transactions.ist[message.via_branch]) - { - return false; - } - // Otherwise check whether it is a merged request. - else - { - for (const transaction in ua._transactions.ist) - { - if (Object.prototype.hasOwnProperty.call(ua._transactions.ist, transaction)) - { - tr = ua._transactions.ist[transaction]; - if (tr.request.from_tag === fromTag && - tr.request.call_id === call_id && - tr.request.cseq === cseq) - { - reply(482); - - return false; - } - } - } - } - } - - // Non INVITE request. - - // If the branch matches the key of any NIST then assume it is a retransmission - // and ignore the request. - // TODO: we should reply the last response. - else if (ua._transactions.nist[message.via_branch]) - { - return false; - } - - // Otherwise check whether it is a merged request. - else - { - for (const transaction in ua._transactions.nist) - { - if (Object.prototype.hasOwnProperty.call(ua._transactions.nist, transaction)) - { - tr = ua._transactions.nist[transaction]; - if (tr.request.from_tag === fromTag && - tr.request.call_id === call_id && - tr.request.cseq === cseq) - { - reply(482); - - return false; - } - } - } - } +function rfc3261_8_2_2_2() { + const fromTag = message.from_tag; + const call_id = message.call_id; + const cseq = message.cseq; + let tr; + + // Accept any in-dialog request. + if (message.to_tag) { + return; + } + + // INVITE request. + if (message.method === JsSIP_C.INVITE) { + // If the branch matches the key of any IST then assume it is a retransmission + // and ignore the INVITE. + // TODO: we should reply the last response. + if (ua._transactions.ist[message.via_branch]) { + return false; + } + // Otherwise check whether it is a merged request. + else { + for (const transaction in ua._transactions.ist) { + if ( + Object.prototype.hasOwnProperty.call( + ua._transactions.ist, + transaction + ) + ) { + tr = ua._transactions.ist[transaction]; + if ( + tr.request.from_tag === fromTag && + tr.request.call_id === call_id && + tr.request.cseq === cseq + ) { + reply(482); + + return false; + } + } + } + } + } + + // Non INVITE request. + + // If the branch matches the key of any NIST then assume it is a retransmission + // and ignore the request. + // TODO: we should reply the last response. + else if (ua._transactions.nist[message.via_branch]) { + return false; + } + + // Otherwise check whether it is a merged request. + else { + for (const transaction in ua._transactions.nist) { + if ( + Object.prototype.hasOwnProperty.call(ua._transactions.nist, transaction) + ) { + tr = ua._transactions.nist[transaction]; + if ( + tr.request.from_tag === fromTag && + tr.request.call_id === call_id && + tr.request.cseq === cseq + ) { + reply(482); + + return false; + } + } + } + } } // Sanity Check functions for responses. -function rfc3261_8_1_3_3() -{ - if (message.getHeaders('via').length > 1) - { - logger.debug('more than one Via header field present in the response, dropping the response'); - - return false; - } +function rfc3261_8_1_3_3() { + if (message.getHeaders('via').length > 1) { + logger.debug( + 'more than one Via header field present in the response, dropping the response' + ); + + return false; + } } -function rfc3261_18_3_response() -{ - const len = Utils.str_utf8_length(message.body), contentLength = message.getHeader('content-length'); +function rfc3261_18_3_response() { + const len = Utils.str_utf8_length(message.body), + contentLength = message.getHeader('content-length'); - if (len < contentLength) - { - logger.debug('message body length is lower than the value in Content-Length header field, dropping the response'); + if (len < contentLength) { + logger.debug( + 'message body length is lower than the value in Content-Length header field, dropping the response' + ); - return false; - } + return false; + } } // Sanity Check functions for requests and responses. -function minimumHeaders() -{ - const mandatoryHeaders = [ 'from', 'to', 'call_id', 'cseq', 'via' ]; - - for (const header of mandatoryHeaders) - { - if (!message.hasHeader(header)) - { - logger.debug(`missing mandatory header field : ${header}, dropping the response`); - - return false; - } - } +function minimumHeaders() { + const mandatoryHeaders = ['from', 'to', 'call_id', 'cseq', 'via']; + + for (const header of mandatoryHeaders) { + if (!message.hasHeader(header)) { + logger.debug( + `missing mandatory header field : ${header}, dropping the response` + ); + + return false; + } + } } // Reply. -function reply(status_code) -{ - const vias = message.getHeaders('via'); +function reply(status_code) { + const vias = message.getHeaders('via'); - let to; - let response = `SIP/2.0 ${status_code} ${JsSIP_C.REASON_PHRASE[status_code]}\r\n`; + let to; + let response = `SIP/2.0 ${status_code} ${JsSIP_C.REASON_PHRASE[status_code]}\r\n`; - for (const via of vias) - { - response += `Via: ${via}\r\n`; - } + for (const via of vias) { + response += `Via: ${via}\r\n`; + } - to = message.getHeader('To'); + to = message.getHeader('To'); - if (!message.to_tag) - { - to += `;tag=${Utils.newTag()}`; - } + if (!message.to_tag) { + to += `;tag=${Utils.newTag()}`; + } - response += `To: ${to}\r\n`; - response += `From: ${message.getHeader('From')}\r\n`; - response += `Call-ID: ${message.call_id}\r\n`; - response += `CSeq: ${message.cseq} ${message.method}\r\n`; - response += '\r\n'; + response += `To: ${to}\r\n`; + response += `From: ${message.getHeader('From')}\r\n`; + response += `Call-ID: ${message.call_id}\r\n`; + response += `CSeq: ${message.cseq} ${message.method}\r\n`; + response += '\r\n'; - transport.send(response); + transport.send(response); } diff --git a/test/include/common.js b/test/include/common.js index 10336863..85a90947 100644 --- a/test/include/common.js +++ b/test/include/common.js @@ -1,20 +1,18 @@ /* eslint no-console: 0*/ // Show uncaught errors. -process.on('uncaughtException', function(error) -{ - console.error('uncaught exception:'); - console.error(error.stack); - process.exit(1); +process.on('uncaughtException', function (error) { + console.error('uncaught exception:'); + console.error(error.stack); + process.exit(1); }); // Define global.WebSocket. -global.WebSocket = function() -{ - this.close = function() {}; +global.WebSocket = function () { + this.close = function () {}; }; // Define global.navigator for bowser module. global.navigator = { - userAgent : '' + userAgent: '', }; diff --git a/test/include/loopSocket.js b/test/include/loopSocket.js index 9a92d9a2..c2a68dcd 100644 --- a/test/include/loopSocket.js +++ b/test/include/loopSocket.js @@ -1,48 +1,42 @@ // LoopSocket send message itself. // Used P2P logic: message call-id is modified in each leg. -module.exports = class LoopSocket -{ - constructor() - { - this.url = 'ws://localhost:12345'; - this.via_transport = 'WS'; - this.sip_uri = 'sip:localhost:12345;transport=ws'; - } - - connect() - { - setTimeout(() => { this.onconnect(); }, 0); - } - - disconnect() - { - } - - send(message) - { - const message2 = this._modifyCallId(message); - - setTimeout(() => { this.ondata(message2); }, 0); - - return true; - } - - // Call-ID: add or drop word '_second'. - _modifyCallId(message) - { - const ixBegin = message.indexOf('Call-ID'); - const ixEnd = message.indexOf('\r', ixBegin); - let callId = message.substring(ixBegin+9, ixEnd); - - if (callId.endsWith('_second')) - { - callId = callId.substring(0, callId.length - 7); - } - else - { - callId += '_second'; - } - - return `${message.substring(0, ixBegin)}Call-ID: ${callId}${message.substring(ixEnd)}`; - } +module.exports = class LoopSocket { + constructor() { + this.url = 'ws://localhost:12345'; + this.via_transport = 'WS'; + this.sip_uri = 'sip:localhost:12345;transport=ws'; + } + + connect() { + setTimeout(() => { + this.onconnect(); + }, 0); + } + + disconnect() {} + + send(message) { + const message2 = this._modifyCallId(message); + + setTimeout(() => { + this.ondata(message2); + }, 0); + + return true; + } + + // Call-ID: add or drop word '_second'. + _modifyCallId(message) { + const ixBegin = message.indexOf('Call-ID'); + const ixEnd = message.indexOf('\r', ixBegin); + let callId = message.substring(ixBegin + 9, ixEnd); + + if (callId.endsWith('_second')) { + callId = callId.substring(0, callId.length - 7); + } else { + callId += '_second'; + } + + return `${message.substring(0, ixBegin)}Call-ID: ${callId}${message.substring(ixEnd)}`; + } }; diff --git a/test/include/testUA.js b/test/include/testUA.js index 71caf670..2e426a55 100644 --- a/test/include/testUA.js +++ b/test/include/testUA.js @@ -1,54 +1,54 @@ module.exports = { - SOCKET_DESCRIPTION : { - 'via_transport' : 'WS', - 'sip_uri' : 'sip:localhost:12345;transport=ws', - 'url' : 'ws://localhost:12345' - }, + SOCKET_DESCRIPTION: { + via_transport: 'WS', + sip_uri: 'sip:localhost:12345;transport=ws', + url: 'ws://localhost:12345', + }, - UA_CONFIGURATION : { - uri : 'sip:f%61keUA@jssip.net', - password : '1234ññññ', - display_name : 'Fake UA ð→€ł !!!', - authorization_user : 'fakeUA', - instance_id : 'uuid:8f1fa16a-1165-4a96-8341-785b1ef24f12', - registrar_server : 'registrar.jssip.NET:6060;TRansport=TCP', - register_expires : 600, - register : false, - connection_recovery_min_interval : 2, - connection_recovery_max_interval : 30, - use_preloaded_route : true, - no_answer_timeout : 60000, - session_timers : true - }, + UA_CONFIGURATION: { + uri: 'sip:f%61keUA@jssip.net', + password: '1234ññññ', + display_name: 'Fake UA ð→€ł !!!', + authorization_user: 'fakeUA', + instance_id: 'uuid:8f1fa16a-1165-4a96-8341-785b1ef24f12', + registrar_server: 'registrar.jssip.NET:6060;TRansport=TCP', + register_expires: 600, + register: false, + connection_recovery_min_interval: 2, + connection_recovery_max_interval: 30, + use_preloaded_route: true, + no_answer_timeout: 60000, + session_timers: true, + }, - UA_CONFIGURATION_AFTER_START : { - uri : 'sip:fakeUA@jssip.net', - password : '1234ññññ', - display_name : 'Fake UA ð→€ł !!!', - authorization_user : 'fakeUA', - instance_id : '8f1fa16a-1165-4a96-8341-785b1ef24f12', // Without 'uuid:'. - registrar_server : 'sip:registrar.jssip.net:6060;transport=tcp', - register_expires : 600, - register : false, - use_preloaded_route : true, - no_answer_timeout : 60000 * 1000, // Internally converted to miliseconds. - session_timers : true - }, + UA_CONFIGURATION_AFTER_START: { + uri: 'sip:fakeUA@jssip.net', + password: '1234ññññ', + display_name: 'Fake UA ð→€ł !!!', + authorization_user: 'fakeUA', + instance_id: '8f1fa16a-1165-4a96-8341-785b1ef24f12', // Without 'uuid:'. + registrar_server: 'sip:registrar.jssip.net:6060;transport=tcp', + register_expires: 600, + register: false, + use_preloaded_route: true, + no_answer_timeout: 60000 * 1000, // Internally converted to miliseconds. + session_timers: true, + }, - UA_TRANSPORT_AFTER_START : { - 'sockets' : [ { - 'socket' : { - 'via_transport' : 'WS', - 'sip_uri' : 'sip:localhost:12345;transport=ws', - 'url' : 'ws://localhost:12345' - }, - 'weight' : 0 - } ], - 'recovery_options' : { - 'min_interval' : 2, - 'max_interval' : 30 - } - } + UA_TRANSPORT_AFTER_START: { + sockets: [ + { + socket: { + via_transport: 'WS', + sip_uri: 'sip:localhost:12345;transport=ws', + url: 'ws://localhost:12345', + }, + weight: 0, + }, + ], + recovery_options: { + min_interval: 2, + max_interval: 30, + }, + }, }; - - diff --git a/test/test-UA-no-WebRTC.js b/test/test-UA-no-WebRTC.js index 840d5eb4..9ef14fab 100644 --- a/test/test-UA-no-WebRTC.js +++ b/test/test-UA-no-WebRTC.js @@ -4,81 +4,99 @@ require('./include/common'); const testUA = require('./include/testUA'); const JsSIP = require('../'); - -describe('UA No WebRTC', () => -{ - - test('UA wrong configuration', () => - { - expect(() => new JsSIP.UA({ 'lalala': 'lololo' })).toThrow(JsSIP.Exceptions.ConfigurationError); - }); - - test('UA no WS connection', () => - { - const config = testUA.UA_CONFIGURATION; - const wsSocket = new JsSIP.WebSocketInterface(testUA.SOCKET_DESCRIPTION.url); - - config.sockets = wsSocket; - - const ua = new JsSIP.UA(config); - - expect(ua instanceof (JsSIP.UA)).toBeTruthy(); - - ua.start(); - - expect(ua.contact.toString()).toBe(``); - expect(ua.contact.toString({ outbound: false, anonymous: false, foo: true })).toBe(``); - expect(ua.contact.toString({ outbound: true })).toBe(``); - expect(ua.contact.toString({ anonymous: true })).toBe(''); - expect(ua.contact.toString({ anonymous: true, outbound: true })).toBe(''); - - for (const parameter in testUA.UA_CONFIGURATION_AFTER_START) - { - if (Object.prototype.hasOwnProperty.call( - testUA.UA_CONFIGURATION_AFTER_START, parameter)) - { - switch (parameter) - { - case 'uri': - case 'registrar_server': - // eslint-disable-next-line jest/no-conditional-expect - expect(ua.configuration[parameter].toString()).toBe(testUA.UA_CONFIGURATION_AFTER_START[parameter], `testing parameter ${parameter}`); - break; - case 'sockets': - console.warn('IGNORE SOCKETS'); - break; - default: - // eslint-disable-next-line jest/no-conditional-expect - expect(ua.configuration[parameter]).toBe(testUA.UA_CONFIGURATION_AFTER_START[parameter], `testing parameter ${parameter}`); - } - } - } - - const transport = testUA.UA_TRANSPORT_AFTER_START; - const sockets = transport.sockets; - const socket = sockets[0].socket; - - expect(sockets.length).toEqual(ua.transport.sockets.length); - expect(sockets[0].weight).toEqual(ua.transport.sockets[0].weight); - expect(socket.via_transport).toEqual(ua.transport.via_transport); - expect(socket.sip_uri).toEqual(ua.transport.sip_uri); - expect(socket.url).toEqual(ua.transport.url); - - expect(transport.recovery_options).toEqual(ua.transport.recovery_options); - - ua.sendMessage('test', 'FAIL WITH CONNECTION_ERROR PLEASE', { - eventHandlers : { - failed : function(e) - { - expect(e.cause).toEqual(JsSIP.C.causes.CONNECTION_ERROR); - } - } - }); - - expect( - () => ua.sendMessage('sip:ibc@iñaki.ðđß', 'FAIL WITH INVALID_TARGET PLEASE') - ).toThrow(); - - ua.stop(); - }); +describe('UA No WebRTC', () => { + test('UA wrong configuration', () => { + expect(() => new JsSIP.UA({ lalala: 'lololo' })).toThrow( + JsSIP.Exceptions.ConfigurationError + ); + }); + + test('UA no WS connection', () => { + const config = testUA.UA_CONFIGURATION; + const wsSocket = new JsSIP.WebSocketInterface( + testUA.SOCKET_DESCRIPTION.url + ); + + config.sockets = wsSocket; + + const ua = new JsSIP.UA(config); + + expect(ua instanceof JsSIP.UA).toBeTruthy(); + + ua.start(); + + expect(ua.contact.toString()).toBe( + `` + ); + expect( + ua.contact.toString({ outbound: false, anonymous: false, foo: true }) + ).toBe( + `` + ); + expect(ua.contact.toString({ outbound: true })).toBe( + `` + ); + expect(ua.contact.toString({ anonymous: true })).toBe( + '' + ); + expect(ua.contact.toString({ anonymous: true, outbound: true })).toBe( + '' + ); + + for (const parameter in testUA.UA_CONFIGURATION_AFTER_START) { + if ( + Object.prototype.hasOwnProperty.call( + testUA.UA_CONFIGURATION_AFTER_START, + parameter + ) + ) { + switch (parameter) { + case 'uri': + case 'registrar_server': { + expect(ua.configuration[parameter].toString()).toBe( + testUA.UA_CONFIGURATION_AFTER_START[parameter], + `testing parameter ${parameter}` + ); + break; + } + case 'sockets': { + console.warn('IGNORE SOCKETS'); + break; + } + default: { + expect(ua.configuration[parameter]).toBe( + testUA.UA_CONFIGURATION_AFTER_START[parameter], + `testing parameter ${parameter}` + ); + } + } + } + } + + const transport = testUA.UA_TRANSPORT_AFTER_START; + const sockets = transport.sockets; + const socket = sockets[0].socket; + + expect(sockets.length).toEqual(ua.transport.sockets.length); + expect(sockets[0].weight).toEqual(ua.transport.sockets[0].weight); + expect(socket.via_transport).toEqual(ua.transport.via_transport); + expect(socket.sip_uri).toEqual(ua.transport.sip_uri); + expect(socket.url).toEqual(ua.transport.url); + + expect(transport.recovery_options).toEqual(ua.transport.recovery_options); + + ua.sendMessage('test', 'FAIL WITH CONNECTION_ERROR PLEASE', { + eventHandlers: { + failed: function (e) { + expect(e.cause).toEqual(JsSIP.C.causes.CONNECTION_ERROR); + }, + }, + }); + + expect(() => + ua.sendMessage('sip:ibc@iñaki.ðđß', 'FAIL WITH INVALID_TARGET PLEASE') + ).toThrow(); + + ua.stop(); + }); }); diff --git a/test/test-UA-subscriber-notifier.js b/test/test-UA-subscriber-notifier.js index 62b5979f..96729dbe 100644 --- a/test/test-UA-subscriber-notifier.js +++ b/test/test-UA-subscriber-notifier.js @@ -2,185 +2,174 @@ require('./include/common'); const JsSIP = require('../'); const LoopSocket = require('./include/loopSocket'); -describe('subscriber/notifier communication', () => -{ - test('should handle subscriber/notifier communication', () => new Promise((resolve) => - { - let eventSequence = 0; - - const TARGET = 'ikq'; - const REQUEST_URI = 'sip:ikq@example.com'; - const CONTACT_URI = 'sip:ikq@abcdefabcdef.invalid;transport=ws'; - const SUBSCRIBE_ACCEPT = 'application/text, text/plain'; - const EVENT_NAME = 'weather'; - const CONTENT_TYPE = 'text/plain'; - const WEATHER_REQUEST = 'Please report the weather condition'; - const WEATHER_REPORT = '+20..+24°C, no precipitation, light wind'; - - /** - * @param {JsSIP.UA} ua - */ - function createSubscriber(ua) - { - const options = { - expires : 3600, - contentType : CONTENT_TYPE, - params : null - }; - - const subscriber = ua.subscribe(TARGET, EVENT_NAME, SUBSCRIBE_ACCEPT, options); - - subscriber.on('active', () => - { - // 'receive notify with subscription-state: active' - expect(++eventSequence).toBe(6); - }); - - subscriber.on('notify', (isFinal, notify, body, contType) => - { - eventSequence++; - // 'receive notify' - expect(eventSequence === 7 || eventSequence === 11).toBe(true); - - expect(notify.method).toBe('NOTIFY'); - expect(notify.getHeader('contact')).toBe(`<${CONTACT_URI}>`); // 'notify contact' - expect(body).toBe(WEATHER_REPORT); // 'notify body' - expect(contType).toBe(CONTENT_TYPE); // 'notify content-type' - - const subsState = notify.parseHeader('subscription-state').state; - - expect(subsState === 'pending' || subsState === 'active' || subsState === 'terminated').toBe(true); // 'notify subscription-state' - - // After receiving the first notify, send un-subscribe. - if (eventSequence === 7) - { - ++eventSequence; // 'send un-subscribe' - - subscriber.terminate(WEATHER_REQUEST); - } - }); - - subscriber.on('terminated', (terminationCode, reason, retryAfter) => - { - expect(++eventSequence).toBe(12); // 'subscriber terminated' - expect(terminationCode).toBe(subscriber.C.FINAL_NOTIFY_RECEIVED); - expect(reason).toBeUndefined(); - expect(retryAfter).toBeUndefined(); - - ua.stop(); - }); - - subscriber.on('accepted', () => - { - expect(++eventSequence).toBe(5); // 'initial subscribe accepted' - }); - - expect(++eventSequence).toBe(2); // 'send subscribe' - - subscriber.subscribe(WEATHER_REQUEST); - } - - /** - * @param {JsSIP.UA} ua - */ - function createNotifier(ua, subscribe) - { - const notifier = ua.notify(subscribe, CONTENT_TYPE, { pending: false }); - - // Receive subscribe (includes initial) - notifier.on('subscribe', (isUnsubscribe, subs, body, contType) => - { - expect(subscribe.method).toBe('SUBSCRIBE'); - expect(subscribe.getHeader('contact')).toBe(`<${CONTACT_URI}>`); // 'subscribe contact' - expect(subscribe.getHeader('accept')).toBe(SUBSCRIBE_ACCEPT); // 'subscribe accept' - expect(body).toBe(WEATHER_REQUEST); // 'subscribe body' - expect(contType).toBe(CONTENT_TYPE); // 'subscribe content-type' - - expect(++eventSequence).toBe(isUnsubscribe ? 9 : 4); - if (isUnsubscribe) - { - // 'send final notify' - notifier.terminate(WEATHER_REPORT); - } - else - { - // 'send notify' - notifier.notify(WEATHER_REPORT); - } - }); - - // Example only. Never reached. - notifier.on('expired', () => - { - notifier.terminate(WEATHER_REPORT, 'timeout'); - }); - - notifier.on('terminated', () => - { - expect(++eventSequence).toBe(10); // 'notifier terminated' - }); - - notifier.start(); - } - - // Start JsSIP UA with loop socket. - const config = - { - sockets : new LoopSocket(), // message sending itself, with modified Call-ID - uri : REQUEST_URI, - contact_uri : CONTACT_URI, - register : false - }; - - const ua = new JsSIP.UA(config); - - // Uncomment to see SIP communication - // JsSIP.debug.enable('JsSIP:*'); - - ua.on('newSubscribe', (e) => - { - expect(++eventSequence).toBe(3); // 'receive initial subscribe' - - const subs = e.request; - const ev = subs.parseHeader('event'); - - expect(subs.ruri.toString()).toBe(REQUEST_URI); // 'initial subscribe uri' - expect(ev.event).toBe(EVENT_NAME); // 'subscribe event' - - if (ev.event !== EVENT_NAME) - { - subs.reply(489); // "Bad Event" - - return; - } - - const accepts = subs.getHeaders('accept'); - const canUse = accepts && accepts.some((v) => v.includes(CONTENT_TYPE)); - - expect(canUse).toBe(true); // 'notifier can use subscribe accept header' - - if (!canUse) - { - subs.reply(406); // "Not Acceptable" - - return; - } - - createNotifier(ua, subs); - }); - - ua.on('connected', () => - { - expect(++eventSequence).toBe(1); // 'socket connected' - - createSubscriber(ua); - }); - - ua.on('disconnected', () => - { - resolve(); - }); - - ua.start(); - })); +describe('subscriber/notifier communication', () => { + test('should handle subscriber/notifier communication', () => + new Promise(resolve => { + let eventSequence = 0; + + const TARGET = 'ikq'; + const REQUEST_URI = 'sip:ikq@example.com'; + const CONTACT_URI = 'sip:ikq@abcdefabcdef.invalid;transport=ws'; + const SUBSCRIBE_ACCEPT = 'application/text, text/plain'; + const EVENT_NAME = 'weather'; + const CONTENT_TYPE = 'text/plain'; + const WEATHER_REQUEST = 'Please report the weather condition'; + const WEATHER_REPORT = '+20..+24°C, no precipitation, light wind'; + + /** + * @param {JsSIP.UA} ua + */ + function createSubscriber(ua) { + const options = { + expires: 3600, + contentType: CONTENT_TYPE, + params: null, + }; + + const subscriber = ua.subscribe( + TARGET, + EVENT_NAME, + SUBSCRIBE_ACCEPT, + options + ); + + subscriber.on('active', () => { + // 'receive notify with subscription-state: active' + expect(++eventSequence).toBe(6); + }); + + subscriber.on('notify', (isFinal, notify, body, contType) => { + eventSequence++; + // 'receive notify' + expect(eventSequence === 7 || eventSequence === 11).toBe(true); + + expect(notify.method).toBe('NOTIFY'); + expect(notify.getHeader('contact')).toBe(`<${CONTACT_URI}>`); // 'notify contact' + expect(body).toBe(WEATHER_REPORT); // 'notify body' + expect(contType).toBe(CONTENT_TYPE); // 'notify content-type' + + const subsState = notify.parseHeader('subscription-state').state; + + expect( + subsState === 'pending' || + subsState === 'active' || + subsState === 'terminated' + ).toBe(true); // 'notify subscription-state' + + // After receiving the first notify, send un-subscribe. + if (eventSequence === 7) { + ++eventSequence; // 'send un-subscribe' + + subscriber.terminate(WEATHER_REQUEST); + } + }); + + subscriber.on('terminated', (terminationCode, reason, retryAfter) => { + expect(++eventSequence).toBe(12); // 'subscriber terminated' + expect(terminationCode).toBe(subscriber.C.FINAL_NOTIFY_RECEIVED); + expect(reason).toBeUndefined(); + expect(retryAfter).toBeUndefined(); + + ua.stop(); + }); + + subscriber.on('accepted', () => { + expect(++eventSequence).toBe(5); // 'initial subscribe accepted' + }); + + expect(++eventSequence).toBe(2); // 'send subscribe' + + subscriber.subscribe(WEATHER_REQUEST); + } + + /** + * @param {JsSIP.UA} ua + */ + function createNotifier(ua, subscribe) { + const notifier = ua.notify(subscribe, CONTENT_TYPE, { pending: false }); + + // Receive subscribe (includes initial) + notifier.on('subscribe', (isUnsubscribe, subs, body, contType) => { + expect(subscribe.method).toBe('SUBSCRIBE'); + expect(subscribe.getHeader('contact')).toBe(`<${CONTACT_URI}>`); // 'subscribe contact' + expect(subscribe.getHeader('accept')).toBe(SUBSCRIBE_ACCEPT); // 'subscribe accept' + expect(body).toBe(WEATHER_REQUEST); // 'subscribe body' + expect(contType).toBe(CONTENT_TYPE); // 'subscribe content-type' + + expect(++eventSequence).toBe(isUnsubscribe ? 9 : 4); + if (isUnsubscribe) { + // 'send final notify' + notifier.terminate(WEATHER_REPORT); + } else { + // 'send notify' + notifier.notify(WEATHER_REPORT); + } + }); + + // Example only. Never reached. + notifier.on('expired', () => { + notifier.terminate(WEATHER_REPORT, 'timeout'); + }); + + notifier.on('terminated', () => { + expect(++eventSequence).toBe(10); // 'notifier terminated' + }); + + notifier.start(); + } + + // Start JsSIP UA with loop socket. + const config = { + sockets: new LoopSocket(), // message sending itself, with modified Call-ID + uri: REQUEST_URI, + contact_uri: CONTACT_URI, + register: false, + }; + + const ua = new JsSIP.UA(config); + + // Uncomment to see SIP communication + // JsSIP.debug.enable('JsSIP:*'); + + ua.on('newSubscribe', e => { + expect(++eventSequence).toBe(3); // 'receive initial subscribe' + + const subs = e.request; + const ev = subs.parseHeader('event'); + + expect(subs.ruri.toString()).toBe(REQUEST_URI); // 'initial subscribe uri' + expect(ev.event).toBe(EVENT_NAME); // 'subscribe event' + + if (ev.event !== EVENT_NAME) { + subs.reply(489); // "Bad Event" + + return; + } + + const accepts = subs.getHeaders('accept'); + const canUse = accepts && accepts.some(v => v.includes(CONTENT_TYPE)); + + expect(canUse).toBe(true); // 'notifier can use subscribe accept header' + + if (!canUse) { + subs.reply(406); // "Not Acceptable" + + return; + } + + createNotifier(ua, subs); + }); + + ua.on('connected', () => { + expect(++eventSequence).toBe(1); // 'socket connected' + + createSubscriber(ua); + }); + + ua.on('disconnected', () => { + resolve(); + }); + + ua.start(); + })); }); diff --git a/test/test-classes.js b/test/test-classes.js index 02d9002b..71f0f8ee 100644 --- a/test/test-classes.js +++ b/test/test-classes.js @@ -1,156 +1,152 @@ require('./include/common'); const JsSIP = require('../'); -describe('URI Tests', () => -{ - test('new URI', () => - { - const uri = new JsSIP.URI(null, 'alice', 'jssip.net', 6060); +describe('URI Tests', () => { + test('new URI', () => { + const uri = new JsSIP.URI(null, 'alice', 'jssip.net', 6060); - expect(uri.scheme).toBe('sip'); - expect(uri.user).toBe('alice'); - expect(uri.host).toBe('jssip.net'); - expect(uri.port).toBe(6060); - expect(uri.toString()).toBe('sip:alice@jssip.net:6060'); - expect(uri.toAor()).toBe('sip:alice@jssip.net'); - expect(uri.toAor(false)).toBe('sip:alice@jssip.net'); - expect(uri.toAor(true)).toBe('sip:alice@jssip.net:6060'); - - uri.scheme = 'SIPS'; - expect(uri.scheme).toBe('sips'); - expect(uri.toAor()).toBe('sips:alice@jssip.net'); - uri.scheme = 'sip'; - - uri.user = 'Iñaki ðđ'; - expect(uri.user).toBe('Iñaki ðđ'); - expect(uri.toString()).toBe('sip:I%C3%B1aki%20%C3%B0%C4%91@jssip.net:6060'); - expect(uri.toAor()).toBe('sip:I%C3%B1aki%20%C3%B0%C4%91@jssip.net'); - - uri.user = '%61lice'; - expect(uri.toAor()).toBe('sip:alice@jssip.net'); - - uri.user = null; - expect(uri.user).toBeNull(); - expect(uri.toAor()).toBe('sip:jssip.net'); - uri.user = 'alice'; - - expect(() => - { - uri.host = null; - }).toThrow(TypeError); - - expect(() => - { - uri.host = { bar: 'foo' }; - }).toThrow(TypeError); - - expect(uri.host).toBe('jssip.net'); - - uri.host = 'VERSATICA.com'; - expect(uri.host).toBe('versatica.com'); - uri.host = 'jssip.net'; - - uri.port = null; - expect(uri.port).toBeNull(); - - uri.port = undefined; - expect(uri.port).toBeNull(); - - uri.port = 'ABCD'; // Should become null. - expect(uri.toString()).toBe('sip:alice@jssip.net'); - - uri.port = '123ABCD'; // Should become 123. - expect(uri.toString()).toBe('sip:alice@jssip.net:123'); - - uri.port = 0; - expect(uri.port).toBe(0); - expect(uri.toString()).toBe('sip:alice@jssip.net:0'); - uri.port = null; - - expect(uri.hasParam('foo')).toBe(false); - - uri.setParam('Foo', null); - expect(uri.hasParam('FOO')).toBe(true); - - uri.setParam('Baz', 123); - expect(uri.getParam('baz')).toBe('123'); - expect(uri.toString()).toBe('sip:alice@jssip.net;foo;baz=123'); - - uri.setParam('zero', 0); - expect(uri.hasParam('ZERO')).toBe(true); - expect(uri.getParam('ZERO')).toBe('0'); - expect(uri.toString()).toBe('sip:alice@jssip.net;foo;baz=123;zero=0'); - expect(uri.deleteParam('ZERO')).toBe('0'); - - expect(uri.deleteParam('baZ')).toBe('123'); - expect(uri.deleteParam('NOO')).toBeUndefined(); - expect(uri.toString()).toBe('sip:alice@jssip.net;foo'); - - uri.clearParams(); - expect(uri.toString()).toBe('sip:alice@jssip.net'); - - expect(uri.hasHeader('foo')).toBe(false); - - uri.setHeader('Foo', 'LALALA'); - expect(uri.hasHeader('FOO')).toBe(true); - expect(uri.getHeader('FOO')).toEqual([ 'LALALA' ]); - expect(uri.toString()).toBe('sip:alice@jssip.net?Foo=LALALA'); - - uri.setHeader('bAz', [ 'ABC-1', 'ABC-2' ]); - expect(uri.getHeader('baz')).toEqual([ 'ABC-1', 'ABC-2' ]); - expect(uri.toString()).toBe('sip:alice@jssip.net?Foo=LALALA&Baz=ABC-1&Baz=ABC-2'); - - expect(uri.deleteHeader('baZ')).toEqual([ 'ABC-1', 'ABC-2' ]); - expect(uri.deleteHeader('NOO')).toBeUndefined(); + expect(uri.scheme).toBe('sip'); + expect(uri.user).toBe('alice'); + expect(uri.host).toBe('jssip.net'); + expect(uri.port).toBe(6060); + expect(uri.toString()).toBe('sip:alice@jssip.net:6060'); + expect(uri.toAor()).toBe('sip:alice@jssip.net'); + expect(uri.toAor(false)).toBe('sip:alice@jssip.net'); + expect(uri.toAor(true)).toBe('sip:alice@jssip.net:6060'); - uri.clearHeaders(); - expect(uri.toString()).toBe('sip:alice@jssip.net'); + uri.scheme = 'SIPS'; + expect(uri.scheme).toBe('sips'); + expect(uri.toAor()).toBe('sips:alice@jssip.net'); + uri.scheme = 'sip'; - const uri2 = uri.clone(); + uri.user = 'Iñaki ðđ'; + expect(uri.user).toBe('Iñaki ðđ'); + expect(uri.toString()).toBe('sip:I%C3%B1aki%20%C3%B0%C4%91@jssip.net:6060'); + expect(uri.toAor()).toBe('sip:I%C3%B1aki%20%C3%B0%C4%91@jssip.net'); - expect(uri2.toString()).toBe(uri.toString()); - uri2.user = 'popo'; - expect(uri2.user).toBe('popo'); - expect(uri.user).toBe('alice'); - }); + uri.user = '%61lice'; + expect(uri.toAor()).toBe('sip:alice@jssip.net'); + + uri.user = null; + expect(uri.user).toBeNull(); + expect(uri.toAor()).toBe('sip:jssip.net'); + uri.user = 'alice'; + + expect(() => { + uri.host = null; + }).toThrow(TypeError); + + expect(() => { + uri.host = { bar: 'foo' }; + }).toThrow(TypeError); + + expect(uri.host).toBe('jssip.net'); + + uri.host = 'VERSATICA.com'; + expect(uri.host).toBe('versatica.com'); + uri.host = 'jssip.net'; + + uri.port = null; + expect(uri.port).toBeNull(); + + uri.port = undefined; + expect(uri.port).toBeNull(); + + uri.port = 'ABCD'; // Should become null. + expect(uri.toString()).toBe('sip:alice@jssip.net'); + + uri.port = '123ABCD'; // Should become 123. + expect(uri.toString()).toBe('sip:alice@jssip.net:123'); + + uri.port = 0; + expect(uri.port).toBe(0); + expect(uri.toString()).toBe('sip:alice@jssip.net:0'); + uri.port = null; + + expect(uri.hasParam('foo')).toBe(false); + + uri.setParam('Foo', null); + expect(uri.hasParam('FOO')).toBe(true); + + uri.setParam('Baz', 123); + expect(uri.getParam('baz')).toBe('123'); + expect(uri.toString()).toBe('sip:alice@jssip.net;foo;baz=123'); + + uri.setParam('zero', 0); + expect(uri.hasParam('ZERO')).toBe(true); + expect(uri.getParam('ZERO')).toBe('0'); + expect(uri.toString()).toBe('sip:alice@jssip.net;foo;baz=123;zero=0'); + expect(uri.deleteParam('ZERO')).toBe('0'); + + expect(uri.deleteParam('baZ')).toBe('123'); + expect(uri.deleteParam('NOO')).toBeUndefined(); + expect(uri.toString()).toBe('sip:alice@jssip.net;foo'); + + uri.clearParams(); + expect(uri.toString()).toBe('sip:alice@jssip.net'); + + expect(uri.hasHeader('foo')).toBe(false); + + uri.setHeader('Foo', 'LALALA'); + expect(uri.hasHeader('FOO')).toBe(true); + expect(uri.getHeader('FOO')).toEqual(['LALALA']); + expect(uri.toString()).toBe('sip:alice@jssip.net?Foo=LALALA'); + + uri.setHeader('bAz', ['ABC-1', 'ABC-2']); + expect(uri.getHeader('baz')).toEqual(['ABC-1', 'ABC-2']); + expect(uri.toString()).toBe( + 'sip:alice@jssip.net?Foo=LALALA&Baz=ABC-1&Baz=ABC-2' + ); + + expect(uri.deleteHeader('baZ')).toEqual(['ABC-1', 'ABC-2']); + expect(uri.deleteHeader('NOO')).toBeUndefined(); + + uri.clearHeaders(); + expect(uri.toString()).toBe('sip:alice@jssip.net'); + + const uri2 = uri.clone(); + + expect(uri2.toString()).toBe(uri.toString()); + uri2.user = 'popo'; + expect(uri2.user).toBe('popo'); + expect(uri.user).toBe('alice'); + }); }); -describe('NameAddr Tests', () => -{ - test('new NameAddr', () => - { - const uri = new JsSIP.URI('sip', 'alice', 'jssip.net'); - const name = new JsSIP.NameAddrHeader(uri, 'Alice æßð'); +describe('NameAddr Tests', () => { + test('new NameAddr', () => { + const uri = new JsSIP.URI('sip', 'alice', 'jssip.net'); + const name = new JsSIP.NameAddrHeader(uri, 'Alice æßð'); - expect(name.display_name).toBe('Alice æßð'); - expect(name.toString()).toBe('"Alice æßð" '); + expect(name.display_name).toBe('Alice æßð'); + expect(name.toString()).toBe('"Alice æßð" '); - name.display_name = null; - expect(name.toString()).toBe(''); + name.display_name = null; + expect(name.toString()).toBe(''); - name.display_name = 0; - expect(name.toString()).toBe('"0" '); + name.display_name = 0; + expect(name.toString()).toBe('"0" '); - name.display_name = ''; - expect(name.toString()).toBe(''); + name.display_name = ''; + expect(name.toString()).toBe(''); - name.setParam('Foo', null); - expect(name.hasParam('FOO')).toBe(true); + name.setParam('Foo', null); + expect(name.hasParam('FOO')).toBe(true); - name.setParam('Baz', 123); - expect(name.getParam('baz')).toBe('123'); - expect(name.toString()).toBe(';foo;baz=123'); + name.setParam('Baz', 123); + expect(name.getParam('baz')).toBe('123'); + expect(name.toString()).toBe(';foo;baz=123'); - expect(name.deleteParam('bAz')).toBe('123'); + expect(name.deleteParam('bAz')).toBe('123'); - name.clearParams(); - expect(name.toString()).toBe(''); + name.clearParams(); + expect(name.toString()).toBe(''); - const name2 = name.clone(); + const name2 = name.clone(); - expect(name2.toString()).toBe(name.toString()); - name2.display_name = '@ł€'; - expect(name2.display_name).toBe('@ł€'); - expect(name.user).toBeUndefined(); - }); + expect(name2.toString()).toBe(name.toString()); + name2.display_name = '@ł€'; + expect(name2.display_name).toBe('@ł€'); + expect(name.user).toBeUndefined(); + }); }); diff --git a/test/test-digestAuthentication.js b/test/test-digestAuthentication.js index 83d4a46a..7acf27c2 100644 --- a/test/test-digestAuthentication.js +++ b/test/test-digestAuthentication.js @@ -4,149 +4,134 @@ const DigestAuthentication = require('../src/DigestAuthentication.js'); // Results of this tests originally obtained from RFC 2617 and: // 'https://pernau.at/kd/sipdigest.php' -describe('DigestAuthentication', () => -{ - test('parse no auth testrealm@host.com -RFC 2617-', () => - { - const method = 'GET'; - const ruri = '/dir/index.html'; - const cnonce = '0a4f113b'; - const credentials = - { - username : 'Mufasa', - password : 'Circle Of Life', - realm : 'testrealm@host.com', - ha1 : null - }; - const challenge = - { - algorithm : 'MD5', - realm : 'testrealm@host.com', - nonce : 'dcd98b7102dd2f0e8b11d0f600bfb0c093', - opaque : '5ccc069c403ebaf9f0171e9517f40e41', - stale : null, - qop : 'auth' - }; - - const digest = new DigestAuthentication(credentials); - - digest.authenticate({ method, ruri }, challenge, cnonce); - - expect(digest._response).toBe('6629fae49393a05397450978507c4ef1'); - }); - - test('digest authenticate qop = null', () => - { - const method = 'REGISTER'; - const ruri = 'sip:testrealm@host.com'; - const credentials = { - username : 'testuser', - password : 'testpassword', - realm : 'testrealm@host.com', - ha1 : null - }; - const challenge = - { - algorithm : 'MD5', - realm : 'testrealm@host.com', - nonce : '5a071f75353f667787615249c62dcc7b15a4828f', - opaque : null, - stale : null, - qop : null - }; - - const digest = new DigestAuthentication(credentials); - - digest.authenticate({ method, ruri }, challenge); - - expect(digest._response).toBe('f99e05f591f147facbc94ff23b4b1dee'); - }); - - test('digest authenticate qop = auth', () => - { - const method = 'REGISTER'; - const ruri = 'sip:testrealm@host.com'; - const cnonce = '0a4f113b'; - const credentials = - { - username : 'testuser', - password : 'testpassword', - realm : 'testrealm@host.com', - ha1 : null - }; - const challenge = - { - algorithm : 'MD5', - realm : 'testrealm@host.com', - nonce : '5a071f75353f667787615249c62dcc7b15a4828f', - opaque : null, - stale : null, - qop : 'auth' - }; - - const digest = new DigestAuthentication(credentials); - - digest.authenticate({ method, ruri }, challenge, cnonce); - - expect(digest._response).toBe('a69b9c2ea0dea1437a21df6ddc9b05e4'); - }); - - test('digest authenticate qop = auth-int and empty body', () => - { - const method = 'REGISTER'; - const ruri = 'sip:testrealm@host.com'; - const cnonce = '0a4f113b'; - const credentials = - { - username : 'testuser', - password : 'testpassword', - realm : 'testrealm@host.com', - ha1 : null - }; - const challenge = - { - algorithm : 'MD5', - realm : 'testrealm@host.com', - nonce : '5a071f75353f667787615249c62dcc7b15a4828f', - opaque : null, - stale : null, - qop : 'auth-int' - }; - - const digest = new DigestAuthentication(credentials); - - digest.authenticate({ method, ruri }, challenge, cnonce); - - expect(digest._response).toBe('82b3cab8b1c4df404434db6a0581650c'); - }); - - test('digest authenticate qop = auth-int and non-empty body', () => - { - const method = 'REGISTER'; - const ruri = 'sip:testrealm@host.com'; - const body = 'TEST BODY'; - const cnonce = '0a4f113b'; - const credentials = - { - username : 'testuser', - password : 'testpassword', - realm : 'testrealm@host.com', - ha1 : null - }; - const challenge = - { - algorithm : 'MD5', - realm : 'testrealm@host.com', - nonce : '5a071f75353f667787615249c62dcc7b15a4828f', - opaque : null, - stale : null, - qop : 'auth-int' - }; - - const digest = new DigestAuthentication(credentials); - - digest.authenticate({ method, ruri, body }, challenge, cnonce); - - expect(digest._response).toBe('7bf0e9de3fbb5da121974509d617f532'); - }); +describe('DigestAuthentication', () => { + test('parse no auth testrealm@host.com -RFC 2617-', () => { + const method = 'GET'; + const ruri = '/dir/index.html'; + const cnonce = '0a4f113b'; + const credentials = { + username: 'Mufasa', + password: 'Circle Of Life', + realm: 'testrealm@host.com', + ha1: null, + }; + const challenge = { + algorithm: 'MD5', + realm: 'testrealm@host.com', + nonce: 'dcd98b7102dd2f0e8b11d0f600bfb0c093', + opaque: '5ccc069c403ebaf9f0171e9517f40e41', + stale: null, + qop: 'auth', + }; + + const digest = new DigestAuthentication(credentials); + + digest.authenticate({ method, ruri }, challenge, cnonce); + + expect(digest._response).toBe('6629fae49393a05397450978507c4ef1'); + }); + + test('digest authenticate qop = null', () => { + const method = 'REGISTER'; + const ruri = 'sip:testrealm@host.com'; + const credentials = { + username: 'testuser', + password: 'testpassword', + realm: 'testrealm@host.com', + ha1: null, + }; + const challenge = { + algorithm: 'MD5', + realm: 'testrealm@host.com', + nonce: '5a071f75353f667787615249c62dcc7b15a4828f', + opaque: null, + stale: null, + qop: null, + }; + + const digest = new DigestAuthentication(credentials); + + digest.authenticate({ method, ruri }, challenge); + + expect(digest._response).toBe('f99e05f591f147facbc94ff23b4b1dee'); + }); + + test('digest authenticate qop = auth', () => { + const method = 'REGISTER'; + const ruri = 'sip:testrealm@host.com'; + const cnonce = '0a4f113b'; + const credentials = { + username: 'testuser', + password: 'testpassword', + realm: 'testrealm@host.com', + ha1: null, + }; + const challenge = { + algorithm: 'MD5', + realm: 'testrealm@host.com', + nonce: '5a071f75353f667787615249c62dcc7b15a4828f', + opaque: null, + stale: null, + qop: 'auth', + }; + + const digest = new DigestAuthentication(credentials); + + digest.authenticate({ method, ruri }, challenge, cnonce); + + expect(digest._response).toBe('a69b9c2ea0dea1437a21df6ddc9b05e4'); + }); + + test('digest authenticate qop = auth-int and empty body', () => { + const method = 'REGISTER'; + const ruri = 'sip:testrealm@host.com'; + const cnonce = '0a4f113b'; + const credentials = { + username: 'testuser', + password: 'testpassword', + realm: 'testrealm@host.com', + ha1: null, + }; + const challenge = { + algorithm: 'MD5', + realm: 'testrealm@host.com', + nonce: '5a071f75353f667787615249c62dcc7b15a4828f', + opaque: null, + stale: null, + qop: 'auth-int', + }; + + const digest = new DigestAuthentication(credentials); + + digest.authenticate({ method, ruri }, challenge, cnonce); + + expect(digest._response).toBe('82b3cab8b1c4df404434db6a0581650c'); + }); + + test('digest authenticate qop = auth-int and non-empty body', () => { + const method = 'REGISTER'; + const ruri = 'sip:testrealm@host.com'; + const body = 'TEST BODY'; + const cnonce = '0a4f113b'; + const credentials = { + username: 'testuser', + password: 'testpassword', + realm: 'testrealm@host.com', + ha1: null, + }; + const challenge = { + algorithm: 'MD5', + realm: 'testrealm@host.com', + nonce: '5a071f75353f667787615249c62dcc7b15a4828f', + opaque: null, + stale: null, + qop: 'auth-int', + }; + + const digest = new DigestAuthentication(credentials); + + digest.authenticate({ method, ruri, body }, challenge, cnonce); + + expect(digest._response).toBe('7bf0e9de3fbb5da121974509d617f532'); + }); }); diff --git a/test/test-normalizeTarget.js b/test/test-normalizeTarget.js index 7d480007..add8e69b 100644 --- a/test/test-normalizeTarget.js +++ b/test/test-normalizeTarget.js @@ -1,63 +1,57 @@ require('./include/common'); const JsSIP = require('../'); - -describe('normalizeTarget', () => -{ - test('valid targets', () => - { - const domain = 'jssip.net'; - - function test_ok(given_data, expected) - { - const uri = JsSIP.Utils.normalizeTarget(given_data, domain); - - expect(uri instanceof (JsSIP.URI)).toBeTruthy(); - expect(uri.toString()).toEqual(expected); - } - - test_ok('%61lice', 'sip:alice@jssip.net'); - test_ok('ALICE', 'sip:ALICE@jssip.net'); - test_ok('alice@DOMAIN.com', 'sip:alice@domain.com'); - test_ok('iñaki', 'sip:i%C3%B1aki@jssip.net'); - test_ok('€€€', 'sip:%E2%82%AC%E2%82%AC%E2%82%AC@jssip.net'); - test_ok('iñaki@aliax.net', 'sip:i%C3%B1aki@aliax.net'); - test_ok('SIP:iñaki@aliax.net:7070', 'sip:i%C3%B1aki@aliax.net:7070'); - test_ok('SIPs:iñaki@aliax.net:7070', 'sip:i%C3%B1aki@aliax.net:7070'); - test_ok('ibc@gmail.com@aliax.net', 'sip:ibc%40gmail.com@aliax.net'); - test_ok('alice-1:passwd', 'sip:alice-1:passwd@jssip.net'); - test_ok('SIP:alice-2:passwd', 'sip:alice-2:passwd@jssip.net'); - test_ok('sips:alice-2:passwd', 'sip:alice-2:passwd@jssip.net'); - test_ok('alice-3:passwd@domain.COM', 'sip:alice-3:passwd@domain.com'); - test_ok('SIP:alice-4:passwd@domain.COM', 'sip:alice-4:passwd@domain.com'); - test_ok('sip:+1234@aliax.net', 'sip:+1234@aliax.net'); - test_ok('+999', 'sip:+999@jssip.net'); - test_ok('*999', 'sip:*999@jssip.net'); - test_ok('#999/?:1234', 'sip:%23999/?:1234@jssip.net'); - test_ok('tel:+12345678', 'sip:+12345678@jssip.net'); - test_ok('tel:(+34)-944-43-89', 'sip:+349444389@jssip.net'); - test_ok('+123.456.78-9', 'sip:+123456789@jssip.net'); - test_ok('+ALICE-123.456.78-9', 'sip:+ALICE-123.456.78-9@jssip.net'); - }); - - test('invalid targets', () => - { - const domain = 'jssip.net'; - - function test_error(given_data) - { - expect(JsSIP.Utils.normalizeTarget(given_data, domain)).toBe(undefined); - } - - test_error(null); - test_error(undefined); - test_error(NaN); - test_error(false); - test_error(true); - test_error(''); - test_error('ibc@iñaki.com'); - test_error('ibc@aliax.net;;;;;'); - - expect(JsSIP.Utils.normalizeTarget('alice')).toBe(undefined); - }); +describe('normalizeTarget', () => { + test('valid targets', () => { + const domain = 'jssip.net'; + + function test_ok(given_data, expected) { + const uri = JsSIP.Utils.normalizeTarget(given_data, domain); + + expect(uri instanceof JsSIP.URI).toBeTruthy(); + expect(uri.toString()).toEqual(expected); + } + + test_ok('%61lice', 'sip:alice@jssip.net'); + test_ok('ALICE', 'sip:ALICE@jssip.net'); + test_ok('alice@DOMAIN.com', 'sip:alice@domain.com'); + test_ok('iñaki', 'sip:i%C3%B1aki@jssip.net'); + test_ok('€€€', 'sip:%E2%82%AC%E2%82%AC%E2%82%AC@jssip.net'); + test_ok('iñaki@aliax.net', 'sip:i%C3%B1aki@aliax.net'); + test_ok('SIP:iñaki@aliax.net:7070', 'sip:i%C3%B1aki@aliax.net:7070'); + test_ok('SIPs:iñaki@aliax.net:7070', 'sip:i%C3%B1aki@aliax.net:7070'); + test_ok('ibc@gmail.com@aliax.net', 'sip:ibc%40gmail.com@aliax.net'); + test_ok('alice-1:passwd', 'sip:alice-1:passwd@jssip.net'); + test_ok('SIP:alice-2:passwd', 'sip:alice-2:passwd@jssip.net'); + test_ok('sips:alice-2:passwd', 'sip:alice-2:passwd@jssip.net'); + test_ok('alice-3:passwd@domain.COM', 'sip:alice-3:passwd@domain.com'); + test_ok('SIP:alice-4:passwd@domain.COM', 'sip:alice-4:passwd@domain.com'); + test_ok('sip:+1234@aliax.net', 'sip:+1234@aliax.net'); + test_ok('+999', 'sip:+999@jssip.net'); + test_ok('*999', 'sip:*999@jssip.net'); + test_ok('#999/?:1234', 'sip:%23999/?:1234@jssip.net'); + test_ok('tel:+12345678', 'sip:+12345678@jssip.net'); + test_ok('tel:(+34)-944-43-89', 'sip:+349444389@jssip.net'); + test_ok('+123.456.78-9', 'sip:+123456789@jssip.net'); + test_ok('+ALICE-123.456.78-9', 'sip:+ALICE-123.456.78-9@jssip.net'); + }); + + test('invalid targets', () => { + const domain = 'jssip.net'; + + function test_error(given_data) { + expect(JsSIP.Utils.normalizeTarget(given_data, domain)).toBe(undefined); + } + + test_error(null); + test_error(undefined); + test_error(NaN); + test_error(false); + test_error(true); + test_error(''); + test_error('ibc@iñaki.com'); + test_error('ibc@aliax.net;;;;;'); + + expect(JsSIP.Utils.normalizeTarget('alice')).toBe(undefined); + }); }); diff --git a/test/test-parser.js b/test/test-parser.js index 34cbef23..8970bbf4 100644 --- a/test/test-parser.js +++ b/test/test-parser.js @@ -3,395 +3,411 @@ const JsSIP = require('../'); const testUA = require('./include/testUA'); const Parser = require('../src/Parser'); +describe('parser', () => { + test('parse URI', () => { + const data = + 'SIP:%61liCE@versaTICA.Com:6060;TRansport=TCp;Foo=ABc;baz?X-Header-1=AaA1&X-Header-2=BbB&x-header-1=AAA2'; + const uri = JsSIP.URI.parse(data); + + // Parsed data. + expect(uri instanceof JsSIP.URI).toBeTruthy(); + expect(uri.scheme).toBe('sip'); + expect(uri.user).toBe('aliCE'); + expect(uri.host).toBe('versatica.com'); + expect(uri.port).toBe(6060); + expect(uri.hasParam('transport')).toBe(true); + expect(uri.hasParam('nooo')).toBe(false); + expect(uri.getParam('transport')).toBe('tcp'); + expect(uri.getParam('foo')).toBe('ABc'); + expect(uri.getParam('baz')).toBe(null); + expect(uri.getParam('nooo')).toBe(undefined); + expect(uri.getHeader('x-header-1')).toEqual(['AaA1', 'AAA2']); + expect(uri.getHeader('X-HEADER-2')).toEqual(['BbB']); + expect(uri.getHeader('nooo')).toBe(undefined); + expect(uri.toString()).toBe( + 'sip:aliCE@versatica.com:6060;transport=tcp;foo=ABc;baz?X-Header-1=AaA1&X-Header-1=AAA2&X-Header-2=BbB' + ); + expect(uri.toAor()).toBe('sip:aliCE@versatica.com'); + + // Alter data. + uri.user = 'Iñaki:PASSWD'; + expect(uri.user).toBe('Iñaki:PASSWD'); + expect(uri.deleteParam('foo')).toBe('ABc'); + expect(uri.deleteHeader('x-header-1')).toEqual(['AaA1', 'AAA2']); + expect(uri.toString()).toBe( + 'sip:I%C3%B1aki:PASSWD@versatica.com:6060;transport=tcp;baz?X-Header-2=BbB' + ); + expect(uri.toAor()).toBe('sip:I%C3%B1aki:PASSWD@versatica.com'); + uri.clearParams(); + uri.clearHeaders(); + uri.port = null; + expect(uri.toString()).toBe('sip:I%C3%B1aki:PASSWD@versatica.com'); + expect(uri.toAor()).toBe('sip:I%C3%B1aki:PASSWD@versatica.com'); + }); + + test('parse NameAddr', () => { + const data = + ' "Iñaki ðđøþ foo \\"bar\\" \\\\\\\\ \\\\ \\\\d \\\\\\\\d \\\\\' \\\\\\"sdf\\\\\\"" ' + + ';QWE=QWE;ASd'; + const name = JsSIP.NameAddrHeader.parse(data); + + // Parsed data. + expect(name instanceof JsSIP.NameAddrHeader).toBeTruthy(); + expect(name.display_name).toBe( + 'Iñaki ðđøþ foo "bar" \\\\ \\ \\d \\\\d \\\' \\"sdf\\"' + ); + expect(name.hasParam('qwe')).toBe(true); + expect(name.hasParam('asd')).toBe(true); + expect(name.hasParam('nooo')).toBe(false); + expect(name.getParam('qwe')).toBe('QWE'); + expect(name.getParam('asd')).toBe(null); + + const uri = name.uri; + + expect(uri instanceof JsSIP.URI).toBeTruthy(); + expect(uri.scheme).toBe('sip'); + expect(uri.user).toBe('aliCE'); + expect(uri.host).toBe('versatica.com'); + expect(uri.port).toBe(6060); + expect(uri.hasParam('transport')).toBe(true); + expect(uri.hasParam('nooo')).toBe(false); + expect(uri.getParam('transport')).toBe('tcp'); + expect(uri.getParam('foo')).toBe('ABc'); + expect(uri.getParam('baz')).toBe(null); + expect(uri.getParam('nooo')).toBe(undefined); + expect(uri.getHeader('x-header-1')).toEqual(['AaA1', 'AAA2']); + expect(uri.getHeader('X-HEADER-2')).toEqual(['BbB']); + expect(uri.getHeader('nooo')).toBe(undefined); + + // Alter data. + name.display_name = 'Foo Bar'; + expect(name.display_name).toBe('Foo Bar'); + name.display_name = null; + expect(name.display_name).toBe(null); + expect(name.toString()).toBe( + ';qwe=QWE;asd' + ); + uri.user = 'Iñaki:PASSWD'; + expect(uri.toAor()).toBe('sip:I%C3%B1aki:PASSWD@versatica.com'); + }); + + test('parse invalid NameAddr with non UTF-8 characters', () => { + const buffer = Buffer.from([0xc0]); + const data = `"${buffer.toString()}"`; + const name = JsSIP.NameAddrHeader.parse(data); + + // Parsed data. + expect(name instanceof JsSIP.NameAddrHeader).toBeTruthy(); + expect(name.display_name).toBe(buffer.toString()); + + const uri = name.uri; + + expect(uri instanceof JsSIP.URI).toBeTruthy(); + expect(uri.scheme).toBe('sip'); + expect(uri.user).toBe('foo'); + expect(uri.host).toBe('bar.com'); + expect(uri.port).toBe(undefined); + }); + + test('parse NameAddr with token display_name', () => { + const data = + 'Foo Foo Bar\tBaz;QWE=QWE;ASd'; + const name = JsSIP.NameAddrHeader.parse(data); + + // Parsed data. + expect(name instanceof JsSIP.NameAddrHeader).toBeTruthy(); + expect(name.display_name).toBe('Foo Foo Bar Baz'); + }); + + test('parse NameAddr with no space between DQUOTE and LAQUOT', () => { + const data = + '"Foo";QWE=QWE;ASd'; + const name = JsSIP.NameAddrHeader.parse(data); + + // Parsed data. + expect(name instanceof JsSIP.NameAddrHeader).toBeTruthy(); + expect(name.display_name).toBe('Foo'); + }); + + test('parse NameAddr with no display_name', () => { + const data = + ';QWE=QWE;ASd'; + const name = JsSIP.NameAddrHeader.parse(data); + + // Parsed data. + expect(name instanceof JsSIP.NameAddrHeader).toBeTruthy(); + expect(name.display_name).toBe(undefined); + }); + + test('parse multiple Contact', () => { + const data = + '"Iñaki @ł€" ;+sip.Instance="abCD", sip:bob@biloxi.COM;headerParam, '; + const contacts = JsSIP.Grammar.parse(data, 'Contact'); + + expect(contacts instanceof Array).toBeTruthy(); + expect(contacts.length).toBe(3); + const c1 = contacts[0].parsed; + const c2 = contacts[1].parsed; + const c3 = contacts[2].parsed; + + // Parsed data. + expect(c1 instanceof JsSIP.NameAddrHeader).toBeTruthy(); + expect(c1.display_name).toBe('Iñaki @ł€'); + expect(c1.hasParam('+sip.instance')).toBe(true); + expect(c1.hasParam('nooo')).toBe(false); + expect(c1.getParam('+SIP.instance')).toBe('"abCD"'); + expect(c1.getParam('nooo')).toBe(undefined); + expect(c1.uri instanceof JsSIP.URI).toBeTruthy(); + expect(c1.uri.scheme).toBe('sip'); + expect(c1.uri.user).toBe('+1234'); + expect(c1.uri.host).toBe('aliax.net'); + expect(c1.uri.port).toBe(undefined); + expect(c1.uri.getParam('transport')).toBe('ws'); + expect(c1.uri.getParam('foo')).toBe(undefined); + expect(c1.uri.getHeader('X-Header')).toBe(undefined); + expect(c1.toString()).toBe( + '"Iñaki @ł€" ;+sip.instance="abCD"' + ); + + // Alter data. + c1.display_name = '€€€'; + expect(c1.display_name).toBe('€€€'); + c1.uri.user = '+999'; + expect(c1.uri.user).toBe('+999'); + c1.setParam('+sip.instance', '"zxCV"'); + expect(c1.getParam('+SIP.instance')).toBe('"zxCV"'); + c1.setParam('New-Param', null); + expect(c1.hasParam('NEW-param')).toBe(true); + c1.uri.setParam('New-Param', null); + expect(c1.toString()).toBe( + '"€€€" ;+sip.instance="zxCV";new-param' + ); + + // Parsed data. + expect(c2 instanceof JsSIP.NameAddrHeader).toBeTruthy(); + expect(c2.display_name).toBe(undefined); + expect(c2.hasParam('HEADERPARAM')).toBe(true); + expect(c2.uri instanceof JsSIP.URI).toBeTruthy(); + expect(c2.uri.scheme).toBe('sip'); + expect(c2.uri.user).toBe('bob'); + expect(c2.uri.host).toBe('biloxi.com'); + expect(c2.uri.port).toBe(undefined); + expect(c2.uri.hasParam('headerParam')).toBe(false); + expect(c2.toString()).toBe(';headerparam'); + + // Alter data. + c2.display_name = '@ł€ĸłæß'; + expect(c2.toString()).toBe('"@ł€ĸłæß" ;headerparam'); + + // Parsed data. + expect(c3 instanceof JsSIP.NameAddrHeader).toBeTruthy(); + expect(c3.displayName).toBe(undefined); + expect(c3.uri instanceof JsSIP.URI).toBeTruthy(); + expect(c3.uri.scheme).toBe('sip'); + expect(c3.uri.user).toBe(undefined); + expect(c3.uri.host).toBe('domain.com'); + expect(c3.uri.port).toBe(5); + expect(c3.uri.hasParam('nooo')).toBe(false); + expect(c3.toString()).toBe(''); + + // Alter data. + c3.uri.setParam('newUriParam', 'zxCV'); + c3.setParam('newHeaderParam', 'zxCV'); + expect(c3.toString()).toBe( + ';newheaderparam=zxCV' + ); + }); + + test('parse Via', () => { + let data = + 'SIP / 3.0 \r\n / UDP [1:ab::FF]:6060 ;\r\n BRanch=1234;Param1=Foo;paRAM2;param3=Bar'; + let via = JsSIP.Grammar.parse(data, 'Via'); + + expect(via.protocol).toBe('SIP'); + expect(via.transport).toBe('UDP'); + expect(via.host).toBe('[1:ab::FF]'); + expect(via.host_type).toBe('IPv6'); + expect(via.port).toBe(6060); + expect(via.branch).toBe('1234'); + expect(via.params).toEqual({ + param1: 'Foo', + param2: undefined, + param3: 'Bar', + }); + + data = + 'SIP / 3.0 \r\n / UDP [1:ab::FF]:6060 ;\r\n BRanch=1234;rport=1111;Param1=Foo;paRAM2;param3=Bar'; + via = JsSIP.Grammar.parse(data, 'Via'); + + expect(via.protocol).toBe('SIP'); + expect(via.transport).toBe('UDP'); + expect(via.host).toBe('[1:ab::FF]'); + expect(via.host_type).toBe('IPv6'); + expect(via.port).toBe(6060); + expect(via.branch).toBe('1234'); + expect(via.rport).toBe(1111); + expect(via.params).toEqual({ + param1: 'Foo', + param2: undefined, + param3: 'Bar', + }); + + data = + 'SIP / 3.0 \r\n / UDP [1:ab::FF]:6060 ;\r\n BRanch=1234;rport;Param1=Foo;paRAM2;param3=Bar'; + via = JsSIP.Grammar.parse(data, 'Via'); + + expect(via.protocol).toBe('SIP'); + expect(via.transport).toBe('UDP'); + expect(via.host).toBe('[1:ab::FF]'); + expect(via.host_type).toBe('IPv6'); + expect(via.port).toBe(6060); + expect(via.branch).toBe('1234'); + expect(via.rport).toBe(undefined); + expect(via.params).toEqual({ + param1: 'Foo', + param2: undefined, + param3: 'Bar', + }); + }); + + test('parse CSeq', () => { + const data = '123456 CHICKEN'; + const cseq = JsSIP.Grammar.parse(data, 'CSeq'); + + expect(cseq.value).toBe(123456); + expect(cseq.method).toBe('CHICKEN'); + }); + + test('parse authentication challenge', () => { + const data = + 'Digest realm = "[1:ABCD::abc]", nonce = "31d0a89ed7781ce6877de5cb032bf114", qop="AUTH,autH-INt", algorithm = md5 , stale = TRUE , opaque = "00000188"'; + const auth = JsSIP.Grammar.parse(data, 'challenge'); + + expect(auth.realm).toBe('[1:ABCD::abc]'); + expect(auth.nonce).toBe('31d0a89ed7781ce6877de5cb032bf114'); + expect(auth.qop).toEqual(['auth', 'auth-int']); + expect(auth.algorithm).toBe('MD5'); + expect(auth.stale).toBe(true); + expect(auth.opaque).toBe('00000188'); + }); + + test('parse Event', () => { + const data = 'Presence;Param1=QWe;paraM2'; + const event = JsSIP.Grammar.parse(data, 'Event'); + + expect(event.event).toBe('presence'); + expect(event.params).toEqual({ param1: 'QWe', param2: undefined }); + }); + + test('parse Session-Expires', () => { + let data, session_expires; + + data = '180;refresher=uac'; + session_expires = JsSIP.Grammar.parse(data, 'Session_Expires'); + + expect(session_expires.expires).toBe(180); + expect(session_expires.refresher).toBe('uac'); + + data = '210 ; refresher = UAS ; foo = bar'; + session_expires = JsSIP.Grammar.parse(data, 'Session_Expires'); + + expect(session_expires.expires).toBe(210); + expect(session_expires.refresher).toBe('uas'); + }); + + test('parse Reason', () => { + let data, reason; + + data = 'SIP ; cause = 488 ; text = "Wrong SDP"'; + reason = JsSIP.Grammar.parse(data, 'Reason'); + + expect(reason.protocol).toBe('sip'); + expect(reason.cause).toBe(488); + expect(reason.text).toBe('Wrong SDP'); + + data = 'ISUP; cause=500 ; LALA = foo'; + reason = JsSIP.Grammar.parse(data, 'Reason'); + + expect(reason.protocol).toBe('isup'); + expect(reason.cause).toBe(500); + expect(reason.text).toBe(undefined); + expect(reason.params.lala).toBe('foo'); + }); + + test('parse host', () => { + let data, parsed; + + data = 'versatica.com'; + expect((parsed = JsSIP.Grammar.parse(data, 'host'))).not.toBe(-1); + expect(parsed.host_type).toBe('domain'); + + data = 'myhost123'; + expect((parsed = JsSIP.Grammar.parse(data, 'host'))).not.toBe(-1); + expect(parsed.host_type).toBe('domain'); + + data = '1.2.3.4'; + expect((parsed = JsSIP.Grammar.parse(data, 'host'))).not.toBe(-1); + expect(parsed.host_type).toBe('IPv4'); + + data = '[1:0:fF::432]'; + expect((parsed = JsSIP.Grammar.parse(data, 'host'))).not.toBe(-1); + expect(parsed.host_type).toBe('IPv6'); + + data = '1.2.3.444'; + expect((parsed = JsSIP.Grammar.parse(data, 'host'))).toBe(-1); + + data = 'iñaki.com'; + expect((parsed = JsSIP.Grammar.parse(data, 'host'))).toBe(-1); + + data = '1.2.3.bar.qwe-asd.foo'; + expect((parsed = JsSIP.Grammar.parse(data, 'host'))).not.toBe(-1); + expect(parsed.host_type).toBe('domain'); + + data = '1.2.3.4.bar.qwe-asd.foo'; + expect((parsed = JsSIP.Grammar.parse(data, 'host'))).not.toBe(-1); + expect(parsed.host_type).toBe('domain'); + }); + + test('parse Refer-To', () => { + let data, parsed; + + data = 'sip:alice@versatica.com'; + expect((parsed = JsSIP.Grammar.parse(data, 'Refer_To'))).not.toBe(-1); + expect(parsed.uri.scheme).toBe('sip'); + expect(parsed.uri.user).toBe('alice'); + expect(parsed.uri.host).toBe('versatica.com'); + + data = ''; + expect((parsed = JsSIP.Grammar.parse(data, 'Refer_To'))).not.toBe(-1); + expect(parsed.uri.scheme).toBe('sip'); + expect(parsed.uri.user).toBe('bob'); + expect(parsed.uri.host).toBe('versatica.com'); + expect(parsed.uri.hasHeader('Accept-Contact')).toBe(true); + }); + + test('parse Replaces', () => { + let parsed; -describe('parser', () => -{ - test('parse URI', () => - { - const data = 'SIP:%61liCE@versaTICA.Com:6060;TRansport=TCp;Foo=ABc;baz?X-Header-1=AaA1&X-Header-2=BbB&x-header-1=AAA2'; - const uri = JsSIP.URI.parse(data); - - // Parsed data. - expect(uri instanceof (JsSIP.URI)).toBeTruthy(); - expect(uri.scheme).toBe('sip'); - expect(uri.user).toBe('aliCE'); - expect(uri.host).toBe('versatica.com'); - expect(uri.port).toBe(6060); - expect(uri.hasParam('transport')).toBe(true); - expect(uri.hasParam('nooo')).toBe(false); - expect(uri.getParam('transport')).toBe('tcp'); - expect(uri.getParam('foo')).toBe('ABc'); - expect(uri.getParam('baz')).toBe(null); - expect(uri.getParam('nooo')).toBe(undefined); - expect(uri.getHeader('x-header-1')).toEqual([ 'AaA1', 'AAA2' ]); - expect(uri.getHeader('X-HEADER-2')).toEqual([ 'BbB' ]); - expect(uri.getHeader('nooo')).toBe(undefined); - expect(uri.toString()).toBe('sip:aliCE@versatica.com:6060;transport=tcp;foo=ABc;baz?X-Header-1=AaA1&X-Header-1=AAA2&X-Header-2=BbB'); - expect(uri.toAor()).toBe('sip:aliCE@versatica.com'); - - // Alter data. - uri.user = 'Iñaki:PASSWD'; - expect(uri.user).toBe('Iñaki:PASSWD'); - expect(uri.deleteParam('foo')).toBe('ABc'); - expect(uri.deleteHeader('x-header-1')).toEqual([ 'AaA1', 'AAA2' ]); - expect(uri.toString()).toBe('sip:I%C3%B1aki:PASSWD@versatica.com:6060;transport=tcp;baz?X-Header-2=BbB'); - expect(uri.toAor()).toBe('sip:I%C3%B1aki:PASSWD@versatica.com'); - uri.clearParams(); - uri.clearHeaders(); - uri.port = null; - expect(uri.toString()).toBe('sip:I%C3%B1aki:PASSWD@versatica.com'); - expect(uri.toAor()).toBe('sip:I%C3%B1aki:PASSWD@versatica.com'); - }); - - test('parse NameAddr', () => - { - const data = ' "Iñaki ðđøþ foo \\"bar\\" \\\\\\\\ \\\\ \\\\d \\\\\\\\d \\\\\' \\\\\\"sdf\\\\\\"" ' + - ';QWE=QWE;ASd'; - const name = JsSIP.NameAddrHeader.parse(data); - - // Parsed data. - expect(name instanceof (JsSIP.NameAddrHeader)).toBeTruthy(); - expect(name.display_name).toBe('Iñaki ðđøþ foo "bar" \\\\ \\ \\d \\\\d \\\' \\"sdf\\"'); - expect(name.hasParam('qwe')).toBe(true); - expect(name.hasParam('asd')).toBe(true); - expect(name.hasParam('nooo')).toBe(false); - expect(name.getParam('qwe')).toBe('QWE'); - expect(name.getParam('asd')).toBe(null); - - const uri = name.uri; - - expect(uri instanceof (JsSIP.URI)).toBeTruthy(); - expect(uri.scheme).toBe('sip'); - expect(uri.user).toBe('aliCE'); - expect(uri.host).toBe('versatica.com'); - expect(uri.port).toBe(6060); - expect(uri.hasParam('transport')).toBe(true); - expect(uri.hasParam('nooo')).toBe(false); - expect(uri.getParam('transport')).toBe('tcp'); - expect(uri.getParam('foo')).toBe('ABc'); - expect(uri.getParam('baz')).toBe(null); - expect(uri.getParam('nooo')).toBe(undefined); - expect(uri.getHeader('x-header-1')).toEqual([ 'AaA1', 'AAA2' ]); - expect(uri.getHeader('X-HEADER-2')).toEqual([ 'BbB' ]); - expect(uri.getHeader('nooo')).toBe(undefined); - - // Alter data. - name.display_name = 'Foo Bar'; - expect(name.display_name).toBe('Foo Bar'); - name.display_name = null; - expect(name.display_name).toBe(null); - expect(name.toString()).toBe(';qwe=QWE;asd'); - uri.user = 'Iñaki:PASSWD'; - expect(uri.toAor()).toBe('sip:I%C3%B1aki:PASSWD@versatica.com'); - }); - - test('parse invalid NameAddr with non UTF-8 characters', () => - { - const buffer = Buffer.from([ 0xC0 ]); - const data = `"${buffer.toString()}"` + - ''; - const name = JsSIP.NameAddrHeader.parse(data); - - // Parsed data. - expect(name instanceof (JsSIP.NameAddrHeader)).toBeTruthy(); - expect(name.display_name).toBe(buffer.toString()); - - const uri = name.uri; - - expect(uri instanceof (JsSIP.URI)).toBeTruthy(); - expect(uri.scheme).toBe('sip'); - expect(uri.user).toBe('foo'); - expect(uri.host).toBe('bar.com'); - expect(uri.port).toBe(undefined); - }); - - test('parse NameAddr with token display_name', () => - { - const data = 'Foo Foo Bar\tBaz;QWE=QWE;ASd'; - const name = JsSIP.NameAddrHeader.parse(data); - - // Parsed data. - expect(name instanceof (JsSIP.NameAddrHeader)).toBeTruthy(); - expect(name.display_name).toBe('Foo Foo Bar Baz'); - }); - - test('parse NameAddr with no space between DQUOTE and LAQUOT', () => - { - const data = '"Foo";QWE=QWE;ASd'; - const name = JsSIP.NameAddrHeader.parse(data); - - // Parsed data. - expect(name instanceof (JsSIP.NameAddrHeader)).toBeTruthy(); - expect(name.display_name).toBe('Foo'); - }); - - test('parse NameAddr with no display_name', () => - { - const data = ';QWE=QWE;ASd'; - const name = JsSIP.NameAddrHeader.parse(data); - - // Parsed data. - expect(name instanceof (JsSIP.NameAddrHeader)).toBeTruthy(); - expect(name.display_name).toBe(undefined); - }); - - test('parse multiple Contact', () => - { - const data = '"Iñaki @ł€" ;+sip.Instance="abCD", sip:bob@biloxi.COM;headerParam, '; - const contacts = JsSIP.Grammar.parse(data, 'Contact'); - - expect(contacts instanceof (Array)).toBeTruthy(); - expect(contacts.length).toBe(3); - const c1 = contacts[0].parsed; - const c2 = contacts[1].parsed; - const c3 = contacts[2].parsed; - - // Parsed data. - expect(c1 instanceof (JsSIP.NameAddrHeader)).toBeTruthy(); - expect(c1.display_name).toBe('Iñaki @ł€'); - expect(c1.hasParam('+sip.instance')).toBe(true); - expect(c1.hasParam('nooo')).toBe(false); - expect(c1.getParam('+SIP.instance')).toBe('"abCD"'); - expect(c1.getParam('nooo')).toBe(undefined); - expect(c1.uri instanceof (JsSIP.URI)).toBeTruthy(); - expect(c1.uri.scheme).toBe('sip'); - expect(c1.uri.user).toBe('+1234'); - expect(c1.uri.host).toBe('aliax.net'); - expect(c1.uri.port).toBe(undefined); - expect(c1.uri.getParam('transport')).toBe('ws'); - expect(c1.uri.getParam('foo')).toBe(undefined); - expect(c1.uri.getHeader('X-Header')).toBe(undefined); - expect(c1.toString()).toBe('"Iñaki @ł€" ;+sip.instance="abCD"'); - - // Alter data. - c1.display_name = '€€€'; - expect(c1.display_name).toBe('€€€'); - c1.uri.user = '+999'; - expect(c1.uri.user).toBe('+999'); - c1.setParam('+sip.instance', '"zxCV"'); - expect(c1.getParam('+SIP.instance')).toBe('"zxCV"'); - c1.setParam('New-Param', null); - expect(c1.hasParam('NEW-param')).toBe(true); - c1.uri.setParam('New-Param', null); - expect(c1.toString()).toBe('"€€€" ;+sip.instance="zxCV";new-param'); - - // Parsed data. - expect(c2 instanceof (JsSIP.NameAddrHeader)).toBeTruthy(); - expect(c2.display_name).toBe(undefined); - expect(c2.hasParam('HEADERPARAM')).toBe(true); - expect(c2.uri instanceof (JsSIP.URI)).toBeTruthy(); - expect(c2.uri.scheme).toBe('sip'); - expect(c2.uri.user).toBe('bob'); - expect(c2.uri.host).toBe('biloxi.com'); - expect(c2.uri.port).toBe(undefined); - expect(c2.uri.hasParam('headerParam')).toBe(false); - expect(c2.toString()).toBe(';headerparam'); - - // Alter data. - c2.display_name = '@ł€ĸłæß'; - expect(c2.toString()).toBe('"@ł€ĸłæß" ;headerparam'); - - // Parsed data. - expect(c3 instanceof (JsSIP.NameAddrHeader)).toBeTruthy(); - expect(c3.displayName).toBe(undefined); - expect(c3.uri instanceof (JsSIP.URI)).toBeTruthy(); - expect(c3.uri.scheme).toBe('sip'); - expect(c3.uri.user).toBe(undefined); - expect(c3.uri.host).toBe('domain.com'); - expect(c3.uri.port).toBe(5); - expect(c3.uri.hasParam('nooo')).toBe(false); - expect(c3.toString()).toBe(''); - - // Alter data. - c3.uri.setParam('newUriParam', 'zxCV'); - c3.setParam('newHeaderParam', 'zxCV'); - expect(c3.toString()).toBe(';newheaderparam=zxCV'); - }); - - test('parse Via', () => - { - let data = 'SIP / 3.0 \r\n / UDP [1:ab::FF]:6060 ;\r\n BRanch=1234;Param1=Foo;paRAM2;param3=Bar'; - let via = JsSIP.Grammar.parse(data, 'Via'); - - expect(via.protocol).toBe('SIP'); - expect(via.transport).toBe('UDP'); - expect(via.host).toBe('[1:ab::FF]'); - expect(via.host_type).toBe('IPv6'); - expect(via.port).toBe(6060); - expect(via.branch).toBe('1234'); - expect(via.params).toEqual({ param1: 'Foo', param2: undefined, param3: 'Bar' }); - - data = 'SIP / 3.0 \r\n / UDP [1:ab::FF]:6060 ;\r\n BRanch=1234;rport=1111;Param1=Foo;paRAM2;param3=Bar'; - via = JsSIP.Grammar.parse(data, 'Via'); - - expect(via.protocol).toBe('SIP'); - expect(via.transport).toBe('UDP'); - expect(via.host).toBe('[1:ab::FF]'); - expect(via.host_type).toBe('IPv6'); - expect(via.port).toBe(6060); - expect(via.branch).toBe('1234'); - expect(via.rport).toBe(1111); - expect(via.params).toEqual({ param1: 'Foo', param2: undefined, param3: 'Bar' }); - - data = 'SIP / 3.0 \r\n / UDP [1:ab::FF]:6060 ;\r\n BRanch=1234;rport;Param1=Foo;paRAM2;param3=Bar'; - via = JsSIP.Grammar.parse(data, 'Via'); - - expect(via.protocol).toBe('SIP'); - expect(via.transport).toBe('UDP'); - expect(via.host).toBe('[1:ab::FF]'); - expect(via.host_type).toBe('IPv6'); - expect(via.port).toBe(6060); - expect(via.branch).toBe('1234'); - expect(via.rport).toBe(undefined); - expect(via.params).toEqual({ param1: 'Foo', param2: undefined, param3: 'Bar' }); - }); - - test('parse CSeq', () => - { - const data = '123456 CHICKEN'; - const cseq = JsSIP.Grammar.parse(data, 'CSeq'); - - expect(cseq.value).toBe(123456); - expect(cseq.method).toBe('CHICKEN'); - }); - - test('parse authentication challenge', () => - { - const data = 'Digest realm = "[1:ABCD::abc]", nonce = "31d0a89ed7781ce6877de5cb032bf114", qop="AUTH,autH-INt", algorithm = md5 , stale = TRUE , opaque = "00000188"'; - const auth = JsSIP.Grammar.parse(data, 'challenge'); - - expect(auth.realm).toBe('[1:ABCD::abc]'); - expect(auth.nonce).toBe('31d0a89ed7781ce6877de5cb032bf114'); - expect(auth.qop).toEqual([ 'auth', 'auth-int' ]); - expect(auth.algorithm).toBe('MD5'); - expect(auth.stale).toBe(true); - expect(auth.opaque).toBe('00000188'); - }); - - test('parse Event', () => - { - const data = 'Presence;Param1=QWe;paraM2'; - const event = JsSIP.Grammar.parse(data, 'Event'); - - expect(event.event).toBe('presence'); - expect(event.params).toEqual({ param1: 'QWe', param2: undefined }); - }); - - test('parse Session-Expires', () => - { - let data, session_expires; - - data = '180;refresher=uac'; - session_expires = JsSIP.Grammar.parse(data, 'Session_Expires'); - - expect(session_expires.expires).toBe(180); - expect(session_expires.refresher).toBe('uac'); - - data = '210 ; refresher = UAS ; foo = bar'; - session_expires = JsSIP.Grammar.parse(data, 'Session_Expires'); - - expect(session_expires.expires).toBe(210); - expect(session_expires.refresher).toBe('uas'); - }); - - test('parse Reason', () => - { - let data, reason; - - data = 'SIP ; cause = 488 ; text = "Wrong SDP"'; - reason = JsSIP.Grammar.parse(data, 'Reason'); - - expect(reason.protocol).toBe('sip'); - expect(reason.cause).toBe(488); - expect(reason.text).toBe('Wrong SDP'); - - data = 'ISUP; cause=500 ; LALA = foo'; - reason = JsSIP.Grammar.parse(data, 'Reason'); - - expect(reason.protocol).toBe('isup'); - expect(reason.cause).toBe(500); - expect(reason.text).toBe(undefined); - expect(reason.params.lala).toBe('foo'); - }); - - test('parse host', () => - { - let data, parsed; - - data = 'versatica.com'; - expect((parsed = JsSIP.Grammar.parse(data, 'host'))).not.toBe(-1); - expect(parsed.host_type).toBe('domain'); - - data = 'myhost123'; - expect((parsed = JsSIP.Grammar.parse(data, 'host'))).not.toBe(-1); - expect(parsed.host_type).toBe('domain'); - - data = '1.2.3.4'; - expect((parsed = JsSIP.Grammar.parse(data, 'host'))).not.toBe(-1); - expect(parsed.host_type).toBe('IPv4'); - - data = '[1:0:fF::432]'; - expect((parsed = JsSIP.Grammar.parse(data, 'host'))).not.toBe(-1); - expect(parsed.host_type).toBe('IPv6'); - - data = '1.2.3.444'; - expect((parsed = JsSIP.Grammar.parse(data, 'host'))).toBe(-1); - - data = 'iñaki.com'; - expect((parsed = JsSIP.Grammar.parse(data, 'host'))).toBe(-1); - - data = '1.2.3.bar.qwe-asd.foo'; - expect((parsed = JsSIP.Grammar.parse(data, 'host'))).not.toBe(-1); - expect(parsed.host_type).toBe('domain'); - - data = '1.2.3.4.bar.qwe-asd.foo'; - expect((parsed = JsSIP.Grammar.parse(data, 'host'))).not.toBe(-1); - expect(parsed.host_type).toBe('domain'); - }); - - test('parse Refer-To', () => - { - let data, parsed; - - data = 'sip:alice@versatica.com'; - expect((parsed = JsSIP.Grammar.parse(data, 'Refer_To'))).not.toBe(-1); - expect(parsed.uri.scheme).toBe('sip'); - expect(parsed.uri.user).toBe('alice'); - expect(parsed.uri.host).toBe('versatica.com'); - - data = ''; - expect((parsed = JsSIP.Grammar.parse(data, 'Refer_To'))).not.toBe(-1); - expect(parsed.uri.scheme).toBe('sip'); - expect(parsed.uri.user).toBe('bob'); - expect(parsed.uri.host).toBe('versatica.com'); - expect(parsed.uri.hasHeader('Accept-Contact')).toBe(true); - }); - - test('parse Replaces', () => - { - let parsed; - - const data = '5t2gpbrbi72v79p1i8mr;to-tag=03aq91cl9n;from-tag=kun98clbf7'; - - expect((parsed = JsSIP.Grammar.parse(data, 'Replaces'))).not.toBe(-1); - expect(parsed.call_id).toBe('5t2gpbrbi72v79p1i8mr'); - expect(parsed.to_tag).toBe('03aq91cl9n'); - expect(parsed.from_tag).toBe('kun98clbf7'); - }); - - test('parse Status Line', () => - { - const data = 'SIP/2.0 420 Bad Extension'; - let parsed; - - expect((parsed = JsSIP.Grammar.parse(data, 'Status_Line'))).not.toBe(-1); - expect(parsed.status_code).toBe(420); - }); - - test('parse message', () => - { - // eslint-disable-next-line no-multi-str - const data = 'INVITE sip:bob@biloxi.com SIP/2.0\r\n\ + const data = '5t2gpbrbi72v79p1i8mr;to-tag=03aq91cl9n;from-tag=kun98clbf7'; + + expect((parsed = JsSIP.Grammar.parse(data, 'Replaces'))).not.toBe(-1); + expect(parsed.call_id).toBe('5t2gpbrbi72v79p1i8mr'); + expect(parsed.to_tag).toBe('03aq91cl9n'); + expect(parsed.from_tag).toBe('kun98clbf7'); + }); + + test('parse Status Line', () => { + const data = 'SIP/2.0 420 Bad Extension'; + let parsed; + + expect((parsed = JsSIP.Grammar.parse(data, 'Status_Line'))).not.toBe(-1); + expect(parsed.status_code).toBe(420); + }); + + test('parse message', () => { + const data = + // eslint-disable-next-line no-multi-str + 'INVITE sip:bob@biloxi.com SIP/2.0\r\n\ Via: SIP/2.0/TCP useragent.cisco.com;branch=z9hG4bK-a111\r\n\ To: \r\n\ From: "Anonymous" ;tag=9802748\r\n\ @@ -401,24 +417,26 @@ Max-Forwards: 70\r\n\ Privacy: id\r\n\ P-Preferred-Identity: "Cullen Jennings" \r\n\r\n'; - const config = testUA.UA_CONFIGURATION; - const wsSocket = new JsSIP.WebSocketInterface(testUA.SOCKET_DESCRIPTION.url); + const config = testUA.UA_CONFIGURATION; + const wsSocket = new JsSIP.WebSocketInterface( + testUA.SOCKET_DESCRIPTION.url + ); - config.sockets = wsSocket; + config.sockets = wsSocket; - const ua = new JsSIP.UA(config); - const message = Parser.parseMessage(data, ua); + const ua = new JsSIP.UA(config); + const message = Parser.parseMessage(data, ua); - expect(message.hasHeader('P-Preferred-Identity')).toBe(true); + expect(message.hasHeader('P-Preferred-Identity')).toBe(true); - const pai = message.getHeader('P-Preferred-Identity'); - const nameAddress = JsSIP.NameAddrHeader.parse(pai); + const pai = message.getHeader('P-Preferred-Identity'); + const nameAddress = JsSIP.NameAddrHeader.parse(pai); - expect(nameAddress instanceof JsSIP.NameAddrHeader).toBeTruthy(); - expect(nameAddress.uri.user).toBe('fluffy'); - expect(nameAddress.uri.host).toBe('cisco.com'); + expect(nameAddress instanceof JsSIP.NameAddrHeader).toBeTruthy(); + expect(nameAddress.uri.user).toBe('fluffy'); + expect(nameAddress.uri.host).toBe('cisco.com'); - expect(message.hasHeader('Privacy')).toBe(true); - expect(message.getHeader('Privacy')).toBe('id'); - }); + expect(message.hasHeader('Privacy')).toBe(true); + expect(message.getHeader('Privacy')).toBe('id'); + }); }); diff --git a/test/test-properties.js b/test/test-properties.js index 5b9d7d5c..0a1d4eea 100644 --- a/test/test-properties.js +++ b/test/test-properties.js @@ -2,16 +2,12 @@ require('./include/common'); const JsSIP = require('../'); const pkg = require('../package.json'); +describe('Properties', () => { + test('should have a name property', () => { + expect(JsSIP.name).toEqual(pkg.title); + }); -describe('Properties', () => -{ - test('should have a name property', () => - { - expect(JsSIP.name).toEqual(pkg.title); - }); - - test('should have a version property', () => - { - expect(JsSIP.version).toEqual(pkg.version); - }); + test('should have a version property', () => { + expect(JsSIP.version).toEqual(pkg.version); + }); }); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..1251e87f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,43 @@ +{ + "compileOnSave": true, + "compilerOptions": { + "lib": ["es2024", "dom"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "lib", + "rootDir": "src", + "allowJs": true, + // Do not generate declaration files automatically yet. + // "declaration": true, + // "declarationMap": true, + // "emitDeclarationOnly": false, + // "declarationDir": "lib", + "isolatedModules": true, + "verbatimModuleSyntax": false, + "useDefineForClassFields": true, + "esModuleInterop": false, + "allowImportingTsExtensions": false, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "alwaysStrict": true, + "exactOptionalPropertyTypes": false, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noPropertyAccessFromIndexSignature": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "strict": true, + "strictBindCallApply": true, + "strictBuiltinIteratorReturn": true, + "strictFunctionTypes": true, + "strictNullChecks": true, + "strictPropertyInitialization": true, + "useUnknownInCatchVariables": true, + "noUncheckedSideEffectImports": true, + }, + "include": ["src"], +}