├── .eslintignore ├── .eslintrc ├── .github ├── pull_request_template.md └── workflows │ ├── ci.yml │ └── codecov.yml ├── .gitignore ├── .npmrc ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── DEVELOPING.md ├── LICENSE ├── README.md ├── lib ├── api.js ├── archiving.js ├── broadcast.js ├── callbacks.js ├── captions.js ├── client.js ├── errors.js ├── generateJwt.js ├── moderation.js ├── opentok.js ├── render.js ├── session.js ├── signaling.js ├── sipInterconnect.js └── stream.js ├── package-lock.json ├── package.json ├── sample ├── Archiving │ ├── README.md │ ├── index.js │ ├── package.json │ ├── public │ │ ├── css │ │ │ └── sample.css │ │ ├── img │ │ │ ├── archiving-off.png │ │ │ ├── archiving-on-idle.png │ │ │ └── archiving-on-message.png │ │ └── js │ │ │ ├── host.js │ │ │ └── participant.js │ └── views │ │ ├── footer.ejs │ │ ├── header.ejs │ │ ├── history.ejs │ │ ├── host.ejs │ │ ├── index.ejs │ │ └── participant.ejs ├── Broadcast │ ├── README.md │ ├── index.js │ ├── package.json │ ├── public │ │ ├── css │ │ │ └── sample.css │ │ └── js │ │ │ ├── host.js │ │ │ └── participant.js │ └── views │ │ ├── footer.ejs │ │ ├── header.ejs │ │ ├── host.ejs │ │ ├── index.ejs │ │ └── participant.ejs ├── HelloWorld │ ├── README.md │ ├── index.js │ ├── package.json │ ├── public │ │ └── js │ │ │ └── helloworld.js │ └── views │ │ └── index.ejs └── SipInterconnect │ ├── Infographic.jpg │ ├── README.md │ ├── app.js │ ├── config.js │ ├── package.json │ ├── public │ └── stylesheets │ │ ├── pattern.css │ │ └── style.css │ ├── tokbox-logo.png │ └── views │ └── index.ejs └── test ├── .eslintrc ├── archiving-test.js ├── callbacks-test.js ├── captions-test.js ├── helpers.js ├── moderation-test.js ├── opentok-test.js ├── session-test.js ├── shim-test.js └── signaling-test.js /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | bower_components 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb-base/legacy", 3 | "rules": { 4 | "no-restricted-syntax": ["off", "ForInStatement"], 5 | "brace-style": ["error", "stroustrup", { "allowSingleLine": true }], 6 | "no-param-reassign": ["off"], 7 | "func-names": ["off"], 8 | "no-plusplus": ["error", { "allowForLoopAfterthoughts": true }] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | #### What is this PR doing? 2 | 3 | 4 | #### How should this be manually tested? 5 | 6 | 7 | #### What are the relevant tickets? 8 | 9 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Vonage 5 | 6 | on: 7 | pull_request: 8 | jobs: 9 | test: 10 | strategy: 11 | matrix: 12 | os: [ubuntu-latest, windows-latest, macos-latest] 13 | node: [16.x, 18.x, 20.x] 14 | runs-on: ${{ matrix.os }} 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Node.js ${{ matrix.node }} 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: ${{ matrix.node }} 21 | - name: Install dependencies 22 | run: npm install 23 | - name: Lint, Compile, Test 24 | run: npm test 25 | -------------------------------------------------------------------------------- /.github/workflows/codecov.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Codecov Report 5 | 6 | on: 7 | pull_request: 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | node: [18.x] 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Use Node.js ${{ matrix.node }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node }} 20 | - name: Install dependencies 21 | run: npm install 22 | - name: Create codecov report 23 | run: npm run report-coverage 24 | - name: Run codecov 25 | uses: codecov/codecov-action@v1 26 | with: 27 | files: ./mocha.lcov,./jasmine.lcov 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .DS_Store 3 | .vscode 4 | *.log 5 | sample/*/package-lock.json 6 | out/ 7 | .nyc_output 8 | coverage.lcov 9 | mocha.lcov 10 | jasmine.lcov 11 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | devrel@vonage.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | For anyone looking to get involved to this project, we are glad to hear from you. Here are a few types of contributions 4 | that we would be interested in hearing about. 5 | 6 | * Bug fixes 7 | - If you find a bug, please first report it using Github Issues. 8 | - Issues that have already been identified as a bug will be labelled `bug`. 9 | - If you'd like to submit a fix for a bug, send a Pull Request from your own fork and mention the Issue number. 10 | + Include a test that isolates the bug and verifies that it was fixed. 11 | * New Features 12 | - If you'd like to accomplish something in the library that it doesn't already do, describe the problem in a new 13 | Github Issue. 14 | - Issues that have been identified as a feature request will be labelled `enhancement`. 15 | - If you'd like to implement the new feature, please wait for feedback from the project maintainers before spending 16 | too much time writing the code. In some cases, `enhancement`s may not align well with the project objectives at 17 | the time. 18 | * Tests, Documentation, Miscellaneous 19 | - If you think the test coverage could be improved, the documentation could be clearer, you've got an alternative 20 | implementation of something that may have more advantages, or any other change we would still be glad hear about 21 | it. 22 | - If its a trivial change, go ahead and send a Pull Request with the changes you have in mind 23 | - If not, open a Github Issue to discuss the idea first. 24 | 25 | ## Requirements 26 | 27 | For a contribution to be accepted: 28 | 29 | * The test suite must be complete and pass 30 | * Code must follow existing styling conventions 31 | * Commit messages must be descriptive. Related issues should be mentioned by number. 32 | 33 | If the contribution doesn't meet these criteria, a maintainer will discuss it with you on the Issue. You can still 34 | continue to add more commits to the branch you have sent the Pull Request from. 35 | 36 | ## How To 37 | 38 | 1. Fork this repository on GitHub. 39 | 1. Clone/fetch your fork to your local development machine. 40 | 1. Create a new branch (e.g. `issue-12`, `feat.add_foo`, etc) and check it out. 41 | 1. Make your changes and commit them. (Did the tests pass?) 42 | 1. Push your new branch to your fork. (e.g. `git push myname issue-12`) 43 | 1. Open a Pull Request from your new branch to the original fork's `master` branch. 44 | 45 | ## Developer Guidelines 46 | 47 | See DEVELOPING.md for guidelines for developing this project. 48 | -------------------------------------------------------------------------------- /DEVELOPING.md: -------------------------------------------------------------------------------- 1 | # Development Guidelines 2 | 3 | This document describes tools, tasks and workflow that one needs to be familiar with in order to effectively maintain 4 | this project. If you use this package within your own software as is but don't plan on modifying it, this guide is 5 | **not** for you. 6 | 7 | ## Tasks 8 | 9 | ### Testing 10 | 11 | This project's tests are written with Mocha and with Jasmine. Common tasks: 12 | 13 | * `npm run test` - run the complete test suite. 14 | * `npm run mochaTest` - run just the mocha tests 15 | * `npm run jasmine_node` - run just the jasmine tests 16 | 17 | ### Releasing 18 | 19 | In order to create a release, the following should be completed in order. 20 | 21 | 1. Ensure all the tests are passing (`pm run test`) and that there is enough test coverage. 22 | 1. Ensure package-lock.json and yarn.lock have been updated and committed if any dependency has been added/removed. 23 | 1. Make sure you are on the `dev` branch of the repository, with all changes merged/committed 24 | already. 25 | 1. Update the version number anywhere it appears in the source code and documentation. See 26 | [Versioning](#versioning) for information about selecting an appropriate version number. Files to 27 | check: 28 | - package.json 29 | 1. Commit the version number change with the message "Update to version x.y.z", substituting the new 30 | version number. 31 | 1. Create a git tag: `git tag -a vx.y.z -m "Release vx.y.z"` 32 | 1. Ensure that you have permission to update the 33 | [opentok npm module](https://www.npmjs.org/package/opentok) 34 | 1. Run `npm publish` to release to npm. 35 | 1. Change the version number for future development by incrementing the patch number (z) adding 36 | "-alpha.1" in the source code (not the documentation). For possible files, see above. Then make 37 | another commit with the message "Begin development on next version". 38 | 1. Push the changes to the source repository: `git push origin dev && git push --tags origin` 39 | 1. Add a description to the [GitHub Releases](https://github.com/opentok/opentok-node/releases) page 40 | with any notable changes. 41 | 42 | ## Workflow 43 | 44 | ### Versioning 45 | 46 | The project uses [semantic versioning](http://semver.org/) as a policy for incrementing version numbers. For planned 47 | work that will go into a future version, there should be a Milestone created in the Github Issues named with the version 48 | number (e.g. "v2.2.1"). 49 | 50 | During development the version number should end in "-alpha.x" or "-beta.x", where x is an increasing number starting from 1. 51 | 52 | ### Branches 53 | 54 | * `dev` - the main development branch. 55 | * `master` - reflects the latest stable release. 56 | * `feat.foo` - feature branches. these are used for longer running tasks that cannot be accomplished in one commit. 57 | once merged into master, these branches should be deleted. 58 | * `vx.x.x` - if development for a future version/milestone has begun while master is working towards a sooner 59 | release, this is the naming scheme for that branch. once merged into master, these branches should be deleted. 60 | 61 | ### Tags 62 | 63 | * `vx.x.x` - commits are tagged with a final version number during release. 64 | 65 | ### Issues 66 | 67 | Issues are labelled to help track their progress within the pipeline. 68 | 69 | * no label - these issues have not been triaged. 70 | * `bug` - confirmed bug. aim to have a test case that reproduces the defect. 71 | * `enhancement` - contains details/discussion of a new feature. it may not yet be approved or placed into a 72 | release/milestone. 73 | * `wontfix` - closed issues that were never addressed. 74 | * `duplicate` - closed issue that is the same to another referenced issue. 75 | * `question` - purely for discussion 76 | 77 | ### Management 78 | 79 | When in doubt, find the maintainers and ask. 80 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014-2016 TokBox, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/api.js: -------------------------------------------------------------------------------- 1 | const fetch = require('node-fetch'); 2 | const { version } = require('../package.json'); 3 | const _ = require('lodash'); 4 | const generateJwt = require('./generateJwt'); 5 | const errors = require('./errors'); 6 | const debug = require('debug') 7 | 8 | const log = debug('@opentok'); 9 | 10 | const generateHeaders = (config) => ({ 11 | 'User-Agent': `OpenTok-Node-SDK/${version}`, 12 | Accept: 'application/json', 13 | ...(config.callVonage 14 | ? {'Authorization': `Bearer ${generateJwt(config)}`} 15 | : {'X-OPENTOK-AUTH': generateJwt(config)} 16 | ), 17 | }); 18 | 19 | exports.api = ({ 20 | method, 21 | url, 22 | callback, 23 | body, 24 | form, 25 | headers = {}, 26 | config, 27 | }) => { 28 | if (!config) { 29 | throw new Error('OT Config was not passed to API caller'); 30 | } 31 | 32 | log(`Calling ${config.callVonage ? 'Vonage Video API' : 'OpenTok API'} `) 33 | 34 | let fetchRequest = { 35 | method: method, 36 | body: body, 37 | headers: { 38 | ...generateHeaders(config), 39 | headers, 40 | }, 41 | } 42 | 43 | if (body && ['POST', 'PATCH', 'PUT'].includes(method)) { 44 | log('Have body for request') 45 | fetchRequest.body = JSON.stringify(body); 46 | fetchRequest.headers['Content-type'] = 'application/json'; 47 | } 48 | 49 | if (form) { 50 | log('Have a form for request') 51 | fetchRequest.body =new URLSearchParams(form).toString() 52 | fetchRequest.headers['Content-type'] ='application/x-www-form-urlencoded'; 53 | } 54 | 55 | log(`Request to ${url}`, fetchRequest); 56 | 57 | Promise.resolve(fetch(url, fetchRequest)) 58 | .then(async (response) => { 59 | const bodyText = await response.text(); 60 | log('Response headers:', response.headers); 61 | log(`Response Body: ${bodyText}`) 62 | let body = bodyText; 63 | 64 | const [contentType] = (response.headers.get('content-type') || '').split(';'); 65 | 66 | switch (contentType) { 67 | case 'application/x-www-form-urlencoded': 68 | body = response.body 69 | ? new URLSearchParams(body) 70 | : '' ; 71 | break; 72 | case 'application/json': 73 | // It appears that sometimes, an empty body is sent with the Content-Type header set 74 | // to application/json. If that happens, just set the body to null 75 | body = body ? JSON.parse(bodyText) : null; 76 | // Assume response is just text which will be passed along no default needed 77 | } 78 | 79 | // If we try calling text again, it will fail since the Buffer has been cleared 80 | Object.assign(response, 'text', new Promise((resolve) => resolve(body))) 81 | 82 | switch (response.status) { 83 | case 401: 84 | case 403: 85 | callback( 86 | new errors.AuthError(), 87 | null, 88 | response, 89 | ); 90 | return; 91 | case 404: 92 | callback( 93 | new errors.NotFoundError(), 94 | null, 95 | response, 96 | ); 97 | return; 98 | } 99 | 100 | if (response.status >= 200 && response.status < 300) { 101 | callback(null, body, response); 102 | return; 103 | } 104 | 105 | callback( 106 | new errors.RequestError(`Unexpected response from OpenTok: "${JSON.stringify({"message": body.message || bodyText})}"`), 107 | body, 108 | response 109 | ); 110 | }) 111 | .catch(async (error) => { 112 | callback(error, null, null); 113 | }); 114 | }; 115 | -------------------------------------------------------------------------------- /lib/archiving.js: -------------------------------------------------------------------------------- 1 | var errors = require('./errors'); 2 | var {api} = require('./api') 3 | 4 | /** 5 | * An object representing an OpenTok archive. 6 | *

7 | * Do not call the new() constructor. To start recording an archive, call the 8 | * {@link OpenTok#startArchive OpenTok.startArchive()} method. 9 | * 10 | * @property {Number} createdAt 11 | * The time at which the archive was created, in milliseconds since the UNIX epoch. 12 | * 13 | * @property {String} duration 14 | * The duration of the archive, in seconds. 15 | * 16 | * @property {Boolean} hasAudio 17 | * Whether the archive has an audio track (true) or not (false). 18 | * You can prevent audio from being recorded by setting 19 | * hasAudio to false 20 | * in the options parameter you pass into the 21 | * {@link OpenTok#startArchive OpenTok.startArchive()} method. 22 | * 23 | * @property {Boolean} hasVideo 24 | * Whether the archive has an video track (true) or not (false). 25 | * You can prevent video from being recorded by setting 26 | * hasVideo to false 27 | * in the options parameter you pass into the 28 | * {@link OpenTok#startArchive OpenTok.startArchive()} method. 29 | * 30 | * @property {String} id 31 | * The archive ID. 32 | * 33 | * @property {String} name 34 | * The name of the archive. If no name was provided when the archive was created, this is set 35 | * to null. 36 | * 37 | * @property {String} streamMode 38 | * The stream mode for the archive. This can be set to one of the the following: 39 | * 40 | *

48 | * 49 | * @property {String} outputMode 50 | * The output mode to be generated for this archive, which can be one of the following: 51 | * 55 | * 56 | * See the {@link OpenTok#startArchive OpenTok.startArchive()} method. 57 | * 58 | * @property {String} projectId 59 | * The API key associated with the archive. 60 | * 61 | * @property {String} reason 62 | * For archives with the status "stopped" or "failed", this string describes the reason 63 | * the archive stopped (such as "maximum duration exceeded") or failed. 64 | * 65 | * @property {String} resolution The resolution of the archive (either "640x480", "1280x720" 66 | * or "1920x1080"). 67 | * This property is only set for composed archives. 68 | * 69 | * @property {String} sessionId 70 | * The session ID of the OpenTok session associated with this archive. 71 | * 72 | * @property {Number} size 73 | * The size of the MP4 file. For archives that have not been generated, this value is set to 0. 74 | * 75 | * @property {String} status 76 | * The status of the archive, which can be one of the following: 77 | * 93 | * 94 | * @property {String} url 95 | * The download URL of the available MP4 file. This is only set for an archive with the status set 96 | * to "available"; for other archives, (including archives with the status "uploaded") this 97 | * property is set to null. The download URL is obfuscated, and the file is only available from 98 | * the URL for 10 minutes. To generate a new URL, call the 99 | * {@link OpenTok#getArchive OpenTok.getArchive()} or 100 | * {@link OpenTok#listArchives OpenTok.listArchives()} method. 101 | * 102 | * @property {String} multiArchiveTag 103 | * Set this to support recording multiple archives for the same session simultaneously. Set 104 | * this to a unique string for each simultaneous archive of an ongoing session. You must also 105 | * set this option when manually starting an archive that is automatically archived. Note that 106 | * the multiArchiveTag value is not included in the response for the methods to list archives 107 | * and retrieve archive information. If you do not specify a unique multiArchiveTag, you can 108 | * only record one archive at a time for a given session. 109 | * See 110 | * Simultaneous archives. 111 | * 112 | * @see {@link OpenTok#deleteArchive OpenTok.deleteArchive()} 113 | * @see {@link OpenTok#getArchive OpenTok.getArchive()} 114 | * @see {@link OpenTok#startArchive OpenTok.startArchive()} 115 | * @see {@link OpenTok#stopArchive OpenTok.stopArchive()} 116 | * @see {@link OpenTok#listArchives OpenTok.listArchives()} 117 | * 118 | * @class Archive 119 | */ 120 | 121 | function Archive(config, properties) { 122 | var hasProp = {}.hasOwnProperty; 123 | var id = properties.id; 124 | var key; 125 | 126 | for (key in properties) { 127 | if (hasProp.call(properties, key)) { 128 | this[key] = properties[key]; 129 | } 130 | } 131 | 132 | /** 133 | * Stops the recording of the archive. 134 | *

135 | * Archives automatically stop recording after 120 minutes or when all clients have disconnected 136 | * from the session being archived. 137 | * 138 | * @param callback {Function} The function to call upon completing the operation. Two arguments 139 | * are passed to the function: 140 | * 141 | *

152 | * 153 | * @method #stop 154 | * @memberof Archive 155 | */ 156 | this.stop = function (callback) { 157 | exports.stopArchive(config, id, callback); 158 | }; 159 | 160 | /** 161 | * Deletes the OpenTok archive. 162 | *

163 | * You can only delete an archive which has a status of "available" or "uploaded". Deleting an 164 | * archive removes its record from the list of archives. For an "available" archive, it also 165 | * removes the archive file, making it unavailable for download. 166 | * 167 | * @param callback {Function} The function to call upon completing the operation. On successfully 168 | * deleting the archive, the function is called with no arguments passed in. On failure, an error 169 | * object is passed into the function. 170 | * 171 | * @method #delete 172 | * @memberof Archive 173 | */ 174 | this.delete = function (callback) { 175 | exports.deleteArchive(config, id, callback); 176 | }; 177 | } 178 | 179 | exports.listArchives = function (config, options, callback) { 180 | if (typeof options === 'function') { 181 | callback = options; 182 | options = {}; 183 | } 184 | 185 | if (typeof callback !== 'function') { 186 | throw new errors.ArgumentError('No callback given to listArchives'); 187 | } 188 | 189 | const archiveUrl = new URL(`${config.apiEndpoint}/v2/project/${config.apiKey}/archive`); 190 | 191 | if (options.offset) { 192 | archiveUrl.searchParams.set( 193 | 'offset', 194 | options.offset, 195 | ) 196 | } 197 | 198 | if (options.count) { 199 | archiveUrl.searchParams.set( 200 | 'count', 201 | options.count, 202 | ) 203 | } 204 | 205 | if (options.sessionId) { 206 | archiveUrl.searchParams.set( 207 | 'sessionId', 208 | options.sessionId, 209 | ) 210 | } 211 | 212 | const parseResponse = (err, body, response) => { 213 | if (err) { 214 | callback(err); 215 | return; 216 | } 217 | 218 | callback( 219 | null, 220 | body?.items.map((item) => new Archive(config, item)) || [], 221 | body.count || 0 222 | ); 223 | } 224 | 225 | api({ 226 | config: config, 227 | method: 'GET', 228 | url: archiveUrl.toString(), 229 | callback: parseResponse, 230 | }); 231 | }; 232 | 233 | exports.startArchive = function (ot, config, sessionId, options, callback) { 234 | if (typeof options === 'function') { 235 | callback = options; 236 | options = {}; 237 | } 238 | 239 | if (typeof callback !== 'function') { 240 | throw new errors.ArgumentError('No callback given to startArchive'); 241 | } 242 | 243 | if (!sessionId) { 244 | callback(new errors.ArgumentError('No session ID given')); 245 | return; 246 | } 247 | 248 | const archiveUrl = new URL(`${config.apiEndpoint}/v2/project/${config.apiKey}/archive`); 249 | 250 | //const oldApi = function (config, method, path, body, callback) { 251 | 252 | const startArchiveCallback = (err, body, response ) => { 253 | if (err && response.status === 404) { 254 | callback(new errors.ArchiveError('Session not found')); 255 | return; 256 | } 257 | 258 | if (err) { 259 | callback( 260 | err, 261 | response.status === 409 262 | ? new errors.ArchiveError('Recording already in progress or session not using OpenTok Media Router') 263 | : err, 264 | ); 265 | return; 266 | } 267 | 268 | if (body.status !== 'started') { 269 | callback(new errors.RequestError('Unexpected response from OpenTok: ' + JSON.stringify(body || { status: response.status, statusMessage: response.statusMessage }))); 270 | return; 271 | } 272 | 273 | callback(null, new Archive(config, body)); 274 | } 275 | 276 | const requestBody = { 277 | sessionId: sessionId, 278 | ...options, 279 | }; 280 | 281 | api({ 282 | config: config, 283 | method: 'POST', 284 | url: archiveUrl.toString(), 285 | body: requestBody, 286 | callback: startArchiveCallback 287 | }) 288 | }; 289 | 290 | exports.stopArchive = function (config, archiveId, callback) { 291 | if (typeof callback !== 'function') { 292 | throw new errors.ArgumentError('No callback given to stopArchive'); 293 | } 294 | 295 | if (!archiveId) { 296 | callback(new errors.ArgumentError('No archive ID given')); 297 | return; 298 | } 299 | 300 | const archiveUrl = new URL(`${config.apiEndpoint}/v2/project/${config.apiKey}/archive/${encodeURIComponent(archiveId)}/stop`); 301 | 302 | const stopArchiveCallback = (err, body, response) => { 303 | if (!err) { 304 | callback(null, new Archive(config, body)); 305 | return; 306 | } 307 | 308 | switch (response?.status) { 309 | case 404: 310 | callback(new errors.ArchiveError('Archive not found')); 311 | break; 312 | case 409: 313 | callback(new errors.ArchiveError(body?.message || 'Unknown archive error')); 314 | break; 315 | default: 316 | callback(err); 317 | } 318 | } 319 | 320 | api({ 321 | config: config, 322 | method: 'POST', 323 | url: archiveUrl.toString(), 324 | body: {}, 325 | callback: stopArchiveCallback, 326 | }) 327 | }; 328 | 329 | exports.getArchive = function (config, archiveId, callback) { 330 | if (typeof callback !== 'function') { 331 | throw new errors.ArgumentError('No callback given to getArchive'); 332 | } 333 | 334 | if (!archiveId) { 335 | callback(new errors.ArgumentError('No archive ID given')); 336 | return; 337 | } 338 | 339 | const archiveUrl = new URL(`${config.apiEndpoint}/v2/project/${config.apiKey}/archive/${encodeURIComponent(archiveId)}`); 340 | 341 | const getArchiveCallback = (err, body, response) => { 342 | if (err && response.status === 404) { 343 | err.message = 'Archive not found'; 344 | } 345 | 346 | if (err) { 347 | callback(err); 348 | return; 349 | } 350 | 351 | callback(null, new Archive(config, body)); 352 | } 353 | api({ 354 | config: config, 355 | method: 'GET', 356 | url: archiveUrl.toString(), 357 | callback: getArchiveCallback, 358 | }); 359 | }; 360 | 361 | function patchArchive(config, archiveId, options, callback) { 362 | if (!archiveId) { 363 | callback(new errors.ArgumentError('No Archive ID given')); 364 | return; 365 | } 366 | 367 | if (options.addStream && options.removeStream) { 368 | callback(new errors.ArgumentError('You cannot have both addStream and removeStream')); 369 | } 370 | 371 | if (!options.addStream && !options.removeStream) { 372 | callback(new errors.ArgumentError('Need one of addStream or removeStream')); 373 | } 374 | 375 | // Coerce to boolean 376 | if (options.addStream) { 377 | options.hasAudio = Boolean(options.hasAudio); 378 | options.hasVideo = Boolean(options.hasVideo); 379 | } 380 | 381 | const archiveUrl = new URL(`${config.apiEndpoint}/v2/project/${config.apiKey}/archive/${encodeURIComponent(archiveId)}/streams`); 382 | 383 | const patchArchiveCallback = (err, body, response) => { 384 | switch (response.status) { 385 | case 400: 386 | callback(new errors.ArgumentError('Invalid request: ' + JSON.stringify(body))); 387 | return; 388 | case 405: 389 | callback(new errors.ArchiveError('Unsupported Stream Mode')); 390 | return; 391 | case 404: 392 | callback(new errors.ArchiveError('Archive or stream not found')); 393 | return; 394 | } 395 | 396 | if (err) { 397 | callback(err); 398 | return; 399 | } 400 | callback(null) 401 | } 402 | 403 | api({ 404 | config: config, 405 | method: 'PATCH', 406 | url: archiveUrl.toString(), 407 | callback: patchArchiveCallback, 408 | body: options, 409 | }); 410 | } 411 | 412 | exports.addArchiveStream = function (config, archiveId, streamId, archiveOptions, callback) { 413 | if (typeof archiveOptions === 'function') { 414 | callback = archiveOptions; 415 | archiveOptions = {}; 416 | } 417 | 418 | if (typeof callback !== 'function') { 419 | throw new errors.ArgumentError('No callback given to addArchiveStream'); 420 | } 421 | 422 | patchArchive( 423 | config, 424 | archiveId, 425 | { 426 | hasAudio: archiveOptions.hasAudio ? archiveOptions.hasAudio : true, 427 | hasVideo: archiveOptions.hasVideo ? archiveOptions.hasVideo : true, 428 | addStream: streamId 429 | }, 430 | callback, 431 | ); 432 | }; 433 | 434 | exports.removeArchiveStream = function (config, archiveId, streamId, callback) { 435 | if (typeof callback !== 'function') { 436 | throw new errors.ArgumentError('No callback given to removeArchiveStream'); 437 | } 438 | 439 | patchArchive(config, archiveId, { removeStream: streamId }, callback); 440 | }; 441 | 442 | exports.deleteArchive = function (config, archiveId, callback) { 443 | if (typeof callback !== 'function') { 444 | throw new errors.ArgumentError('No callback given to deleteArchive'); 445 | } 446 | 447 | if (!archiveId) { 448 | callback(new errors.ArgumentError('No archive ID given')); 449 | return; 450 | } 451 | 452 | const archiveUrl = new URL(`${config.apiEndpoint}/v2/project/${config.apiKey}/archive/${encodeURIComponent(archiveId)}`); 453 | 454 | const deleteArchiveCallback = (err, body, response) => { 455 | if (response.status === 404) { 456 | callback(new errors.ArchiveError('Archive not found')); 457 | return; 458 | } 459 | 460 | callback(err); 461 | } 462 | api({ 463 | config: config, 464 | method: 'DELETE', 465 | url: archiveUrl.toString(), 466 | callback: deleteArchiveCallback, 467 | }); 468 | }; 469 | -------------------------------------------------------------------------------- /lib/broadcast.js: -------------------------------------------------------------------------------- 1 | /** 2 | * An object representing an OpenTok live streaming broadcast. 3 | *

4 | * Do not call the new() constructor. To start a live streaming broadcast, call the 5 | * {@link OpenTok#startBroadcast OpenTok.startBroadcast()} method. 6 | * 7 | * @property {String} id 8 | * The broadcast ID. 9 | * @property {String} sessionId 10 | * The session ID of the OpenTok session associated with this broadcast. 11 | * @property {String} projectId 12 | * The API key associated with the broadcast. 13 | * @property {Number} createdAt 14 | * The time at which the broadcast was created, in milliseconds since the UNIX epoch. 15 | * @property {String} resolution 16 | * The resolution of the broadcast: one of the following: 17 | *

25 | * You may want to use a portrait aspect ratio for broadcasts that include video streams from 26 | * mobile devices (which often use the portrait aspect ratio). This property is optional. 27 | * @property {Object} broadcastUrls 28 | * An object containing details about the HLS and RTMP broadcasts. 29 | *

30 | *

66 | * @property {Number} maxDuration 67 | * The maximum time allowed for the broadcast, in seconds. 68 | * After this time, the broadcast will be stopped automatically, if it is still started. 69 | * 70 | * @propert { Number } maxBitRate 71 | * Maximum bitrate allowed for broadcast composing 72 | * 73 | * @property {String} streamMode 74 | * The stream mode for the broadcast. This can be set to one of the the following: 75 | * 76 | * 84 | * 85 | * @property {Array} streams 86 | * An array of objects corresponding to streams currently being broadcast. 87 | * This is only set for a broadcast with the status set to "started" and 88 | * the streamMode set to "manual". Each object in the array includes the following properties: 89 | * 94 | * 95 | * @property {String} multiBroadcastTag 96 | * Set this to support multiple broadcasts for the same session simultaneously. Set this 97 | * to a unique string for each simultaneous broadcast of an ongoing session. 98 | * Note that the multiBroadcastTag value is not included in the response 99 | * for the methods to list live streaming broadcasts and get information about a live 100 | * streaming broadcast. 101 | * 102 | * @see {@link OpenTok#getBroadcast OpenTok.getBroadcast()} 103 | * @see {@link OpenTok#startBroadcast OpenTok.startBroadcast()} 104 | * @see {@link OpenTok#stopBroadcast OpenTok.stopBroadcast()} 105 | * 106 | * @class Broadcast 107 | */ 108 | var Broadcast = function Broadcast(client, json) { 109 | var properties = typeof json === 'string' ? JSON.parse(json) : json; 110 | var hasProp = {}.hasOwnProperty; 111 | var id = properties.id; 112 | var key; 113 | 114 | for (key in properties) { 115 | if (hasProp.call(properties, key) && key !== 'event' && key !== 'partnerId') { 116 | this[key] = properties[key]; 117 | } 118 | } 119 | 120 | /** 121 | * Stops the live streaming broadcast. 122 | *

123 | * Broadcasts automatically stop recording after 120 minutes or when all clients have disconnected 124 | * from the session being broadcast. 125 | * 126 | * @param callback {Function} The function to call upon completing the operation. Two arguments 127 | * are passed to the function: 128 | * 129 | *

140 | * 141 | * @method #stop 142 | * @memberof Broadcast 143 | */ 144 | this.stop = function (callback) { 145 | client.stopBroadcast(id, function (err, response) { 146 | if (err) { 147 | return callback(new Error('Failed to stop broadcast. ' + err)); 148 | } 149 | return callback(null, new Broadcast(client, response)); 150 | }); 151 | }; 152 | }; 153 | 154 | module.exports = Broadcast; 155 | -------------------------------------------------------------------------------- /lib/callbacks.js: -------------------------------------------------------------------------------- 1 | var errors = require('./errors'); 2 | const { api } = require('./api'); 3 | 4 | function Callback(config, properties) { 5 | var hasProp = {}.hasOwnProperty; 6 | var id = properties.id; 7 | var key; 8 | 9 | for (key in properties) { 10 | if (hasProp.call(properties, key)) this[key] = properties[key]; 11 | } 12 | 13 | this.unregister = function (callback) { 14 | exports.unregister(config, id, callback); 15 | }; 16 | } 17 | 18 | exports.listCallbacks = function (config, options, callback) { 19 | if (typeof options === 'function') { 20 | callback = options; 21 | options = {}; 22 | } 23 | 24 | if (typeof callback !== 'function') { 25 | throw (new errors.ArgumentError('No callback given to listCallbacks')); 26 | } 27 | 28 | const callbackUrl = new URL(`${config.apiEndpoint}/v2/project/${config.apiKey}/callback`); 29 | 30 | const listCallbackHandler = (err, body, response) => { 31 | if (err) { 32 | callback(err); 33 | } 34 | 35 | callback(null, body.map((item) => new Callback(config, item))); 36 | }; 37 | 38 | api({ 39 | config: config, 40 | method: 'GET', 41 | url: callbackUrl.toString(), 42 | callback: listCallbackHandler, 43 | }); 44 | }; 45 | 46 | exports.registerCallback = function (config, options, callback) { 47 | if (typeof options === 'function') { 48 | callback = options; 49 | options = {}; 50 | } 51 | 52 | if (typeof callback !== 'function') { 53 | throw (new errors.ArgumentError('No callback given to registerCallback')); 54 | } 55 | 56 | const apiCallback = (err, body, response) => { 57 | if (err) { 58 | callback(err); 59 | return; 60 | } 61 | 62 | callback(null, new Callback(config, body)); 63 | }; 64 | 65 | const callbackUrl = new URL(`${config.apiEndpoint}/v2/project/${config.apiKey}/callback`); 66 | 67 | api({ 68 | config: config, 69 | method: 'POST', 70 | url: callbackUrl.toString(), 71 | body: { 72 | group: options.group, 73 | event: options.event, 74 | url: options.url 75 | }, 76 | callback: apiCallback, 77 | }); 78 | }; 79 | 80 | exports.unregisterCallback = function (config, callbackId, callback) { 81 | if (typeof callback !== 'function') { 82 | throw (new errors.ArgumentError('No callback given to unregisterCallback')); 83 | } 84 | 85 | if (callbackId == null || callbackId.length === 0) { 86 | callback(new errors.ArgumentError('No callback ID given')); 87 | return; 88 | } 89 | 90 | const callbackUrl = new URL(`${config.apiEndpoint}/v2/project/${config.apiKey}/callback/${encodeURIComponent(callbackId)}`); 91 | 92 | const callbackHandler = (err) => { 93 | if (err) { 94 | callback(err); 95 | return; 96 | } 97 | 98 | callback(null); 99 | } 100 | 101 | api({ 102 | config: config, 103 | method: 'DELETE', 104 | url: callbackUrl.toString(), 105 | callback: callbackHandler, 106 | }); 107 | }; 108 | -------------------------------------------------------------------------------- /lib/captions.js: -------------------------------------------------------------------------------- 1 | const errors = require('./errors'); 2 | const { api } = require('./api'); 3 | 4 | exports.startCaptions = ( 5 | config, 6 | sessionId, 7 | token, 8 | { 9 | languageCode = 'en-US', 10 | maxDuration = 14400, 11 | partialCaptions = true 12 | }, 13 | callback, 14 | ) => { 15 | 16 | if (typeof callback !== 'function') { 17 | throw new errors.ArgumentError('No callback given to startCaptions'); 18 | } 19 | 20 | const captionsUrl = new URL(`${config.apiEndpoint}/v2/project/${config.apiKey}/captions`); 21 | 22 | const captionsCallback = (err, body, response ) => { 23 | if (response.status === 409) { 24 | callback(new errors.CaptionsError()); 25 | return; 26 | } 27 | 28 | if (err) { 29 | callback(err); 30 | return; 31 | } 32 | 33 | const { captionsId } = body 34 | callback(null, captionsId); 35 | } 36 | 37 | api({ 38 | config: config, 39 | method: 'POST', 40 | url: captionsUrl.toString(), 41 | body: { 42 | sessionId: sessionId, 43 | token: token, 44 | languageCode: languageCode, 45 | maxDuration: maxDuration, 46 | partialCaptions: partialCaptions, 47 | }, 48 | callback: captionsCallback, 49 | }); 50 | }; 51 | 52 | exports.stopCaptions = ( 53 | config, 54 | captionsId, 55 | callback, 56 | ) => { 57 | if (typeof callback !== 'function') { 58 | throw new errors.ArgumentError('No callback given to stopArchive'); 59 | } 60 | 61 | const captionsUrl = new URL(`${config.apiEndpoint}/v2/project/${config.apiKey}/captions/${captionsId}/stop`); 62 | const stopCallback = (err) => { 63 | if (err) { 64 | callback(err); 65 | return; 66 | } 67 | 68 | callback(null, true); 69 | } 70 | 71 | api({ 72 | config: config, 73 | method: 'POST', 74 | url: captionsUrl.toString(), 75 | callback: stopCallback, 76 | }); 77 | }; 78 | -------------------------------------------------------------------------------- /lib/client.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const Stream = require('./stream'); 3 | const Broadcast = require('./broadcast'); 4 | const { api } = require('./api'); 5 | const defaultConfig = { 6 | apiKey: null, 7 | apiSecret: null, 8 | apiUrl: 'https://api.opentok.com', 9 | endpoints: { 10 | createSession: '/session/create', 11 | getStream: 12 | '/v2/project/<%apiKey%>/session/<%sessionId%>/stream/<%streamId%>', 13 | listStreams: '/v2/project/<%apiKey%>/session/<%sessionId%>/stream', 14 | setArchiveLayout: '/v2/project/<%apiKey%>/archive/<%archiveId%>/layout', 15 | setStreamClassLists: '/v2/project/<%apiKey%>/session/<%sessionId%>/stream', 16 | dial: '/v2/project/<%apiKey%>/dial', 17 | playDTMFToSession: '/v2/project/<%apiKey%>/session/<%sessionId%>/play-dtmf', 18 | playDTMFToClient: 19 | '/v2/project/<%apiKey%>/session/<%sessionId%>/connection/<%connectionId%>/play-dtmf', 20 | forceMuteStream: 21 | '/v2/project/<%apiKey%>/session/<%sessionId%>/stream/<%streamId%>/mute', 22 | forceMute: '/v2/project/<%apiKey%>/session/<%sessionId%>/mute', 23 | startBroadcast: '/v2/project/<%apiKey%>/broadcast', 24 | stopBroadcast: '/v2/project/<%apiKey%>/broadcast/<%broadcastId%>/stop', 25 | getBroadcast: '/v2/project/<%apiKey%>/broadcast/<%broadcastId%>', 26 | patchBroadcast: '/v2/project/<%apiKey%>/broadcast/<%broadcastId%>/streams', 27 | setBroadcastLayout: 28 | '/v2/project/<%apiKey%>/broadcast/<%broadcastId%>/layout', 29 | listBroadcasts: '/v2/project/<%apiKey%>/broadcast', 30 | audioStreamer: '/v2/project/<%apiKey%>/connect' 31 | }, 32 | request: { 33 | timeout: 20000 // 20 seconds 34 | }, 35 | auth: { 36 | expire: 300 37 | } 38 | }; 39 | 40 | const Client = function (c) { 41 | this.c = {}; 42 | this.config(_.defaults(c, defaultConfig)); 43 | }; 44 | 45 | Client.prototype.config = function (c) { 46 | _.merge(this.c, c); 47 | if (this.c.endpoints && this.c.endpoints.dial && this.c.apiKey) { 48 | this.c.endpoints.dial = this.c.endpoints.dial.replace( 49 | /<%apiKey%>/g, 50 | this.c.apiKey 51 | ); 52 | } 53 | 54 | return this.c; 55 | }; 56 | 57 | Client.prototype.createSession = function (opts, cb) { 58 | const url = new URL(this.c.apiUrl + this.c.endpoints.createSession); 59 | 60 | api({ 61 | config:this.c, 62 | url: url.toString(), 63 | method: 'POST', 64 | form: opts, 65 | callback: cb, 66 | }); 67 | }; 68 | 69 | Client.prototype.startArchive = function () {}; 70 | 71 | Client.prototype.stopArchive = function () {}; 72 | 73 | Client.prototype.getArchive = function () {}; 74 | 75 | Client.prototype.listArchives = function () {}; 76 | 77 | Client.prototype.deleteArchive = function () {}; 78 | 79 | Client.prototype.playDTMF = function (opts, cb) { 80 | let url; 81 | 82 | if (opts.sessionId) { 83 | url = this.c.apiUrl 84 | + this.c.endpoints.playDTMFToSession 85 | .replace(/<%apiKey%>/g, this.c.apiKey) 86 | .replace(/<%sessionId%>/g, opts.sessionId); 87 | } 88 | if (opts.connectionId) { 89 | url = this.c.apiUrl 90 | + this.c.endpoints.playDTMFToClient 91 | .replace(/<%apiKey%>/g, this.c.apiKey) 92 | .replace(/<%sessionId%>/g, opts.sessionId) 93 | .replace(/<%connectionId%>/g, opts.connectionId); 94 | } 95 | 96 | api({ 97 | config:this.c, 98 | url:url, 99 | method: 'POST', 100 | body: { 101 | digits: opts.digits 102 | }, 103 | callback: cb, 104 | }); 105 | }; 106 | 107 | Client.prototype.forceMuteStream = function (opts, cb) { 108 | const url = this.c.apiUrl 109 | + this.c.endpoints.forceMuteStream 110 | .replace(/<%apiKey%>/g, this.c.apiKey) 111 | .replace(/<%sessionId%>/g, opts.sessionId) 112 | .replace(/<%streamId%>/g, opts.streamId); 113 | 114 | api({ 115 | config:this.c, 116 | url:url, 117 | method: 'POST', 118 | body: { }, 119 | callback: cb, 120 | }); 121 | }; 122 | 123 | Client.prototype.forceMuteAll = function (opts, cb) { 124 | const url = this.c.apiUrl 125 | + this.c.endpoints.forceMute 126 | .replace(/<%apiKey%>/g, this.c.apiKey) 127 | .replace(/<%sessionId%>/g, opts.sessionId); 128 | 129 | opts.options.active = true; 130 | 131 | api({ 132 | config:this.c, 133 | url:url, 134 | method: 'POST', 135 | body: opts.options, 136 | callback: cb, 137 | }); 138 | }; 139 | 140 | Client.prototype.disableForceMute = function (opts, cb) { 141 | const url = this.c.apiUrl 142 | + this.c.endpoints.forceMute 143 | .replace(/<%apiKey%>/g, this.c.apiKey) 144 | .replace(/<%sessionId%>/g, opts.sessionId); 145 | 146 | const options = { 147 | active: false 148 | }; 149 | 150 | api({ 151 | config:this.c, 152 | url:url, 153 | method: 'POST', 154 | body: options, 155 | callback: cb, 156 | }); 157 | }; 158 | 159 | Client.prototype.setArchiveLayout = function setArchiveLayout(opts, cb) { 160 | const url = this.c.apiUrl 161 | + this.c.endpoints.setArchiveLayout 162 | .replace(/<%apiKey%>/g, this.c.apiKey) 163 | .replace(/<%archiveId%>/g, opts.archiveId); 164 | api({ 165 | config:this.c, 166 | url:url, 167 | method: 'PUT', 168 | body: { 169 | type: opts.type, 170 | stylesheet: opts.stylesheet || undefined, 171 | screenshareType: opts.screenshareType || undefined 172 | }, 173 | callback: cb, 174 | }); 175 | }; 176 | 177 | Client.prototype.startBroadcast = function (opts, cb) { 178 | const url = this.c.apiUrl 179 | + this.c.endpoints.startBroadcast.replace(/<%apiKey%>/g, this.c.apiKey); 180 | 181 | api({ 182 | config:this.c, 183 | url:url, 184 | method: 'POST', 185 | body: opts, 186 | callback: (err, body, response) => { 187 | if (response.status === 400) { 188 | cb(new Error('Bad session ID, token, SIP credentials, or SIP URI (sip:user@domain.tld)')); 189 | return; 190 | } 191 | 192 | cb(err, body); 193 | }, 194 | }); 195 | 196 | }; 197 | 198 | Client.prototype.patchBroadcast = function patchBroadcast(broadcastId, opts, cb) { 199 | const url = this.c.apiUrl + this.c.endpoints.patchBroadcast.replace(/<%apiKey%>/g, this.c.apiKey) 200 | .replace(/<%broadcastId%>/g, broadcastId); 201 | api({ 202 | config:this.c, 203 | url:url, 204 | method: 'PATCH', 205 | body: opts, 206 | callback: cb, 207 | }); 208 | 209 | }; 210 | 211 | Client.prototype.stopBroadcast = function (broadcastId, cb) { 212 | const url = this.c.apiUrl 213 | + this.c.endpoints.stopBroadcast 214 | .replace(/<%apiKey%>/g, this.c.apiKey) 215 | .replace(/<%broadcastId%>/g, broadcastId); 216 | api({ 217 | config:this.c, 218 | url:url, 219 | method: 'POST', 220 | body: { }, 221 | callback: (err, json) => { 222 | const responseText = typeof json === 'object' ? JSON.stringify(json) : json; 223 | cb(err, responseText); 224 | }, 225 | }); 226 | }; 227 | 228 | Client.prototype.getBroadcast = function getBroadcast(broadcastId, cb) { 229 | const url = this.c.apiUrl 230 | + this.c.endpoints.getBroadcast 231 | .replace(/<%apiKey%>/g, this.c.apiKey) 232 | .replace(/<%broadcastId%>/g, broadcastId); 233 | api({ 234 | config:this.c, 235 | url:url, 236 | method: 'GET', 237 | callback: cb, 238 | }); 239 | }; 240 | 241 | Client.prototype.listBroadcasts = function listBroadcasts(queryString, cb) { 242 | const baseUrl = this.c.apiUrl 243 | + this.c.endpoints.listBroadcasts.replace(/<%apiKey%>/g, this.c.apiKey); 244 | const url = queryString.length > 0 ? baseUrl + '?' + queryString : baseUrl; 245 | api({ 246 | config:this.c, 247 | url:url, 248 | method: 'GET', 249 | callback: (err, items) => { 250 | cb( 251 | err, 252 | items?.items.map((item) => new Broadcast(Client, JSON.stringify(item))), 253 | items?.count, 254 | ) 255 | }, 256 | }); 257 | }; 258 | 259 | Client.prototype.setBroadcastLayout = function setBroadcastLayout(opts, cb) { 260 | const url = this.c.apiUrl 261 | + this.c.endpoints.setBroadcastLayout 262 | .replace(/<%apiKey%>/g, this.c.apiKey) 263 | .replace(/<%broadcastId%>/g, opts.broadcastId); 264 | api({ 265 | config:this.c, 266 | url:url, 267 | method: 'PUT', 268 | body: { 269 | type: opts.type, 270 | stylesheet: opts.stylesheet || undefined, 271 | screenshareType: opts.screenshareType || undefined 272 | }, 273 | callback: cb, 274 | }); 275 | 276 | }; 277 | 278 | Client.prototype.websocketConnect = function websocketConnect(opts, cb) { 279 | const url = this.c.apiUrl + this.c.endpoints.audioStreamer 280 | .replace(/<%apiKey%>/g, this.c.apiKey); 281 | api({ 282 | config:this.c, 283 | url:url, 284 | method: 'POST', 285 | body: opts, 286 | callback: cb, 287 | }); 288 | 289 | }; 290 | 291 | Client.prototype.setStreamClassLists = function setStreamClassLists( 292 | sessionId, 293 | classListArray, 294 | cb 295 | ) { 296 | const url = this.c.apiUrl 297 | + this.c.endpoints.setStreamClassLists 298 | .replace(/<%apiKey%>/, this.c.apiKey) 299 | .replace(/<%sessionId%>/g, sessionId); 300 | api({ 301 | config:this.c, 302 | url:url, 303 | method: 'PUT', 304 | body: { 305 | items: classListArray 306 | }, 307 | callback: cb, 308 | }); 309 | }; 310 | 311 | Client.prototype.dial = function (opts, cb) { 312 | api({ 313 | config:this.c, 314 | url: this.c.apiUrl + this.c.endpoints.dial, 315 | method: 'POST', 316 | body: opts, 317 | callback: cb, 318 | }); 319 | }; 320 | 321 | Client.prototype.getStream = function getStream(sessionId, streamId, cb) { 322 | const url = this.c.apiUrl 323 | + this.c.endpoints.getStream 324 | .replace(/<%apiKey%>/g, this.c.apiKey) 325 | .replace(/<%streamId%>/g, streamId) 326 | .replace(/<%sessionId%>/g, sessionId); 327 | api({ 328 | config:this.c, 329 | url: url, 330 | method: 'GET', 331 | callback: cb, 332 | }); 333 | 334 | }; 335 | 336 | Client.prototype.listStreams = function listStreams(sessionId, cb) { 337 | const url = this.c.apiUrl 338 | + this.c.endpoints.listStreams 339 | .replace(/<%apiKey%>/g, this.c.apiKey) 340 | .replace(/<%sessionId%>/g, sessionId); 341 | api({ 342 | config:this.c, 343 | url: url, 344 | method: 'GET', 345 | callback: (err, body) => { 346 | cb(err, body?.items?.map((stream) => new Stream(JSON.stringify(stream))) || []) 347 | }, 348 | }); 349 | }; 350 | 351 | module.exports = Client; 352 | -------------------------------------------------------------------------------- /lib/errors.js: -------------------------------------------------------------------------------- 1 | 2 | exports.ArgumentError = function (message) { 3 | this.message = message; 4 | }; 5 | 6 | exports.ArgumentError.prototype = Object.create(Error.prototype); 7 | 8 | exports.AuthError = function (message = 'Invalid API key or secret') { 9 | this.message = message; 10 | }; 11 | 12 | exports.AuthError.prototype = Object.create(Error.prototype); 13 | 14 | 15 | exports.ArchiveError = function (message) { 16 | this.message = message; 17 | }; 18 | 19 | exports.ArchiveError.prototype = Object.create(Error.prototype); 20 | 21 | exports.CaptionsError = function (message = 'Live captions have already started for this OpenTok Session') { 22 | this.message = message; 23 | }; 24 | 25 | exports.SipError = function (message) { 26 | this.message = message; 27 | }; 28 | 29 | exports.SipError.prototype = Object.create(Error.prototype); 30 | 31 | 32 | exports.SignalError = function (message) { 33 | this.message = message; 34 | }; 35 | 36 | exports.SignalError.prototype = Object.create(Error.prototype); 37 | 38 | 39 | exports.ForceDisconnectError = function (message) { 40 | this.message = message; 41 | }; 42 | 43 | exports.ForceDisconnectError.prototype = Object.create(Error.prototype); 44 | 45 | 46 | exports.CallbackError = function (message) { 47 | this.message = message; 48 | }; 49 | 50 | exports.CallbackError.prototype = Object.create(Error.prototype); 51 | 52 | exports.RequestError = function (message = 'Unexpected response from OpenTok') { 53 | this.message = message; 54 | }; 55 | 56 | exports.RequestError.prototype = Object.create(Error.prototype); 57 | 58 | exports.NotFoundError = function (message = 'Not Found') { 59 | this.message = message; 60 | }; 61 | 62 | exports.NotFoundError.prototype = Object.create(Error.prototype); 63 | -------------------------------------------------------------------------------- /lib/generateJwt.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken'); 2 | const { v4 } = require('uuid') 3 | const debug = require('debug'); 4 | 5 | const log = debug('@opentok'); 6 | 7 | module.exports = function (config, additionalClaims) { 8 | const currentTime = Math.floor(new Date() / 1000); 9 | const initialClaims = { 10 | iss: config.apiKey, 11 | ist: 'project', 12 | iat: currentTime, 13 | exp: additionalClaims?.expire_time || currentTime + config.auth.expire, 14 | }; 15 | 16 | const vonageClaims = { 17 | application_id: config.apiKey, 18 | jti: v4(), 19 | } 20 | 21 | const claims = { 22 | ...initialClaims, 23 | ...additionalClaims, 24 | ...(config.callVonage ? vonageClaims : {}), 25 | }; 26 | 27 | log('JWT Claims', claims); 28 | 29 | const token = jwt.sign( 30 | claims, 31 | config.apiSecret, 32 | config.callVonage 33 | ? { 34 | algorithm: 'RS256', 35 | header: { 36 | typ: 'JWT', 37 | alg: 'RS256' 38 | }, 39 | } 40 | : {}, 41 | ); 42 | 43 | return token; 44 | }; 45 | -------------------------------------------------------------------------------- /lib/moderation.js: -------------------------------------------------------------------------------- 1 | var errors = require('./errors'); 2 | const { api } = require('./api'); 3 | 4 | exports.forceDisconnect = function (config, sessionId, connectionId, callback) { 5 | if (typeof callback !== 'function') { 6 | throw (new errors.ArgumentError('No callback given to forceDisconnect')); 7 | } 8 | 9 | if (sessionId == null || connectionId == null) { 10 | return callback(new errors.ArgumentError('No sessionId or connectionId given to forceDisconnect')); 11 | } 12 | 13 | const moderationUrl = new URL(`${config.apiEndpoint}/v2/project/${config.apiKey}/session/${sessionId}`); 14 | 15 | if (connectionId) { 16 | moderationUrl.pathname = `${moderationUrl.pathname}/connection/${connectionId}` 17 | } 18 | 19 | const moderationHandler = (err, body, response) => { 20 | if (response.status === 404) { 21 | callback(new errors.ForceDisconnectError('Session or Connection not found')); 22 | return; 23 | } 24 | 25 | callback(err); 26 | }; 27 | 28 | api({ 29 | config: config, 30 | method: 'DELETE', 31 | url: moderationUrl.toString(), 32 | callback: moderationHandler, 33 | }); 34 | }; 35 | -------------------------------------------------------------------------------- /lib/render.js: -------------------------------------------------------------------------------- 1 | const errors = require('./errors'); 2 | const { api } = require('./api'); 3 | const _ = require('lodash'); 4 | 5 | const guardParams = (method, options, callback) => { 6 | const cb = typeof options === 'function' ? options : callback; 7 | const opts = typeof options !== 'function' ? options : { count: 50 }; 8 | 9 | if (typeof cb !== 'function') { 10 | throw new errors.ArgumentError('No callback given to ' + method); 11 | } 12 | 13 | return [cb, opts]; 14 | }; 15 | 16 | /** 17 | * An object representing an Experience Composer renderer. 18 | * 19 | * @property {String} id 20 | * The ID of the render instance 21 | * 22 | * @property {String} projectId 23 | * The ID of the project for the render. 24 | * 25 | * @property {String} sessionId 26 | * The ID of the session being rendered into. 27 | * 28 | * @property {Number} createdAt 29 | * The time at which the render was created, in milliseconds since the UNIX epoch. 30 | * 31 | * @property {Number} upddatedAt 32 | * The time at which the render was created, in milliseconds since the UNIX epoch. 33 | * 34 | * @property {String} url 35 | * A publically reachable URL controlled by the customer and capable of generating 36 | * the content to be rendered without user intervention. 37 | * 38 | * @proerpty {String} status 39 | * Current status of for the render. Will be one of `starting`, `started`, `stopped` 40 | * or `failed` 41 | * 42 | * @property {String} reason 43 | * Gives a short textual reason for why the Render failed or stopped. 44 | * 45 | * @property {String} callbackUrl 46 | * URL of the customer service where the callbacks will be received. 47 | * 48 | * @property {String} event 49 | * Last sent event of for the render 50 | * 51 | * @property {String} resoultion 52 | * Resolution of the display area for the composition. 53 | * 54 | * @see {@link OpenTok#getRender OpenTok.getRender()} 55 | * @see {@link OpenTok#startRender OpenTok.startRender()} 56 | * @see {@link OpenTok#stopRender OpenTok.stopRender()} 57 | * @see {@link OpenTok#listRenders OpenTok.listRenders()} 58 | * 59 | * @class Render 60 | */ 61 | 62 | 63 | const handleResponse = (callback) => (err, body, response) => { 64 | if (err) { 65 | callback(err); 66 | return; 67 | } 68 | 69 | callback(null, body, response); 70 | }; 71 | 72 | /** 73 | * Return a list of {@link Render} objects, representing Experience Composer in any status 74 | * 75 | * @param {Object} config - API configuration settings {@see OpenTok.listRenders} 76 | * @param {Object} options - Optional parameters for the API call {@see OpenTok.listRenders} 77 | * @param {Function} callback - Callback function 78 | * 79 | * @method #listRenders 80 | * @memberof Render 81 | */ 82 | exports.listRenders = (config, options, callback) => { 83 | if (typeof callback !== 'function') { 84 | throw new errors.ArgumentError('No callback given to listRenders'); 85 | } 86 | 87 | const { offset, count } = options; 88 | 89 | if (count > 1000 || count < 1) { 90 | throw new errors.ArgumentError('Count is out of range'); 91 | } 92 | 93 | const renderUrl = new URL(`${config.apiEndpoint}/v2/project/${config.apiKey}/render`); 94 | 95 | if (offset) { 96 | renderUrl.searchParams.set('offset', offset); 97 | } 98 | 99 | if (count) { 100 | renderUrl.searchParams.set('count', count); 101 | } 102 | 103 | api({ 104 | config: config, 105 | url: renderUrl.toString(), 106 | callback: handleResponse(callback), 107 | }); 108 | }; 109 | 110 | /** 111 | * Gets a {@link Render} object for the given render ID. 112 | * 113 | * @param {Object} config - API config {@see OpenTok} 114 | * @param {String} renderId - The Render ID to fetch 115 | * @param {Function} callback - Callback function 116 | * 117 | * @method #getRender 118 | * @memberof Render 119 | */ 120 | exports.getRender = (config, renderId, callback) => { 121 | if (typeof callback !== 'function') { 122 | throw new errors.ArgumentError('No callback given to getRender'); 123 | } 124 | 125 | const renderUrl = new URL(`${config.apiEndpoint}/v2/project/${config.apiKey}/render/${renderId}`); 126 | 127 | api({ 128 | method: 'GET', 129 | config: config, 130 | url: renderUrl.toString(), 131 | callback: handleResponse(callback) 132 | }); 133 | }; 134 | 135 | /** 136 | * Starts an Experience Composer for an OpenTok session. 137 | * 138 | * 139 | * @param {Object} config - API configuration settings {@see OpenTok.startRender} 140 | * @param {Object} options - Optional parameters for the API call {@see OpenTok.startRender} 141 | * @param {Function} callback - Callback function 142 | * 143 | * @method #startRender 144 | * @memberof Render 145 | */ 146 | exports.startRender = (config, options, callback) => { 147 | if (typeof callback !== 'function') { 148 | throw new errors.ArgumentError('No callback given to startRender'); 149 | } 150 | 151 | const [cb, opts] = guardParams('startRender', options, callback); 152 | 153 | const renderUrl = new URL(`${config.apiEndpoint}/v2/project/${config.apiKey}/render`); 154 | 155 | api({ 156 | url: renderUrl.toString(), 157 | config: config, 158 | method: 'POST', 159 | body: { 160 | sessionId: _.get(opts, 'sessionId'), 161 | token: _.get(opts, 'token'), 162 | url: _.get(opts, 'url'), 163 | maxDuration: _.get(opts, 'maxDuration', 1800), 164 | resolution: _.get(opts, 'resolution', '1280x720'), 165 | statusCallbackUrl: _.get(opts, 'statusCallbackUrl') 166 | }, 167 | callback: handleResponse(cb), 168 | }); 169 | }; 170 | 171 | /** 172 | * Stops an OpenTok render that is being rendered. 173 | * 174 | * @param {Object} config - API configuration settings {@see OpenTok.stopRender} 175 | * @param {String} renderId - The Render ID to fetch 176 | * @param {Function} callback - Callback function 177 | * 178 | * @method #stopRender 179 | * @memberof Render 180 | */ 181 | exports.stopRender = (config, renderId, callback) => { 182 | if (typeof callback !== 'function') { 183 | throw new errors.ArgumentError('No callback given to stopRender'); 184 | } 185 | 186 | const renderUrl = new URL(`${config.apiEndpoint}/v2/project/${config.apiKey}/render/${renderId}`); 187 | 188 | api({ 189 | method: 'DELETE', 190 | config: config, 191 | url: renderUrl.toString(), 192 | callback: handleResponse(callback), 193 | }); 194 | }; 195 | -------------------------------------------------------------------------------- /lib/session.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Represents an OpenTok session. Use the {@link OpenTok#createSession OpenTok.createSession()} 3 | * method to create an OpenTok session. The sessionId property of the Session object 4 | * is the session ID. 5 | * 6 | * @property {String} sessionId The session ID. 7 | * 8 | * @class Session 9 | */ 10 | 11 | var Session = function Session(ot, sessionId, properties) { 12 | var prop; 13 | this.ot = ot; 14 | this.sessionId = sessionId; 15 | for (prop in properties) { 16 | if ({}.hasOwnProperty.call(properties, prop)) { 17 | this[prop] = properties[prop]; 18 | } 19 | } 20 | }; 21 | 22 | Session.prototype.generateToken = function generateToken(opts) { 23 | return this.ot.generateToken(this.sessionId, opts); 24 | }; 25 | 26 | module.exports = Session; 27 | -------------------------------------------------------------------------------- /lib/signaling.js: -------------------------------------------------------------------------------- 1 | var errors = require('./errors'); 2 | const {api} = require('./api'); 3 | 4 | exports.signal = function (config, sessionId, connectionId, payload, callback) { 5 | if (typeof callback !== 'function') { 6 | throw (new errors.ArgumentError('No callback given to signal')); 7 | } 8 | 9 | if (sessionId == null || payload == null) { 10 | return callback(new errors.ArgumentError('No sessionId or payload given to signal')); 11 | } 12 | 13 | const signalUrl = new URL(`${config.apiEndpoint}/v2/project/${config.apiKey}/session/${sessionId}`); 14 | if (connectionId) { 15 | signalUrl.pathname = `${signalUrl.pathname}/connection/${connectionId}`; 16 | } 17 | 18 | signalUrl.pathname = `${signalUrl.pathname}/signal`; 19 | 20 | const signalHandler = (err, body, response) => { 21 | if (response.status === 404) { 22 | callback(new errors.SignalError('Session or Connection not found')); 23 | return; 24 | } 25 | 26 | callback(err); 27 | }; 28 | 29 | return api({ 30 | config: config, 31 | method: 'POST', 32 | url: signalUrl.toString(), 33 | body: payload, 34 | callback: signalHandler, 35 | }); 36 | }; 37 | -------------------------------------------------------------------------------- /lib/sipInterconnect.js: -------------------------------------------------------------------------------- 1 | /** 2 | * An object representing an OpenTok SIP call. 3 | *

4 | * Do not call the new() constructor. To start a SIP call, call the 5 | * {@link OpenTok#dial OpenTok.dial()} method. 6 | * 7 | * @property {String} id 8 | * The unique ID of the SIP conference. 9 | * 10 | * @property {String} connectionId 11 | * The connection ID of the audio-only stream that is put into an OpenTok Session. 12 | * 13 | * @property {String} streamId 14 | * The stream ID of the audio-only stream that is put into an OpenTok Session. 15 | * 16 | * 17 | * @see {@link OpenTok#dial OpenTok.dial()} 18 | * 19 | * @class SipInterconnect 20 | */ 21 | function SipInterconnect(config, properties) { 22 | var hasProp = {}.hasOwnProperty; 23 | var key; 24 | 25 | for (key in properties) { 26 | if (hasProp.call(properties, key)) { 27 | this[key] = properties[key]; 28 | } 29 | } 30 | } 31 | 32 | module.exports = SipInterconnect; 33 | -------------------------------------------------------------------------------- /lib/stream.js: -------------------------------------------------------------------------------- 1 | /** 2 | * An object representing an OpenTok stream. This is passed into the callback function 3 | * of the {@link OpenTok#getStream OpenTok.getStream()} method. 4 | * 5 | * @property {String} id 6 | * The stream ID of the stream. 7 | * 8 | * @property {String} name 9 | * The stream name (if one was set when the client published the stream). 10 | * 11 | * @property {Array} layoutClassList 12 | * An array of the layout classes for the stream. These layout classes are used in 13 | * customizing the layout in 14 | * Live 15 | * streaming broadcasts and 16 | * 17 | * composed archives. 18 | * 19 | * @property {String} videoType 20 | * Set to either "camera" or "screen". A "screen" video uses screen sharing on the publisher as 21 | * the video source; for other videos, this property is set to "camera". 22 | * 23 | * @see {@link OpenTok#dial OpenTok.dial()} 24 | * 25 | * @class Stream 26 | */ 27 | function Stream(json) { 28 | var properties = JSON.parse(json); 29 | var hasProp = {}.hasOwnProperty; 30 | var key; 31 | 32 | for (key in properties) { 33 | if (hasProp.call(properties, key)) { 34 | this[key] = properties[key]; 35 | } 36 | } 37 | } 38 | 39 | module.exports = Stream; 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/package.json", 3 | "name": "opentok", 4 | "version": "2.21.2", 5 | "description": "OpenTok server-side SDK", 6 | "homepage": "https://github.com/opentok/opentok-node", 7 | "bugs": { 8 | "url": "https://github.com/opentok/opentok-node/issues", 9 | "email": "support@tokbox.com" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/opentok/opentok-node.git" 14 | }, 15 | "license": "MIT", 16 | "contributors": [ 17 | { 18 | "name": "Hashir Baqai", 19 | "email": "hashirbaqai@gmail.com" 20 | }, 21 | { 22 | "name": "Brian Stoner", 23 | "email": "bsstoner@gmail.com", 24 | "url": "http://brianstoner.com" 25 | }, 26 | { 27 | "name": "Song Zheng", 28 | "email": "song@tokbox.com", 29 | "url": "http://songz.me" 30 | }, 31 | { 32 | "name": "Ankur Oberoi", 33 | "email": "aoberoi@gmail.com", 34 | "url": "http://aoberoi.me" 35 | }, 36 | { 37 | "name": "Jeff Swartz", 38 | "email": "swartz@tokbox.com" 39 | }, 40 | { 41 | "name": "Hamza Nasir", 42 | "email": "mnasir@hawk.iit.edu" 43 | }, 44 | { 45 | "name": "Manik Sachdeva", 46 | "url": "http://maniksach.dev" 47 | }, 48 | { 49 | "name": "Michael Jolley", 50 | "url": "https://baldbeardedbuilder.com" 51 | }, 52 | { 53 | "name": "Alex Lakatos", 54 | "url": "twitter.com/lakatos88" 55 | }, 56 | { 57 | "name": "Mofi Rahman", 58 | "url": "https://twitter.com/moficodes" 59 | }, 60 | { 61 | "name": "Chuck \"MANCHUCK\" Reeves", 62 | "email": "chuck@manchuck.com", 63 | "url": "https://github.com/manchuck" 64 | } 65 | ], 66 | "main": "lib/opentok.js", 67 | "files": [ 68 | "lib/" 69 | ], 70 | "scripts": { 71 | "lint": "eslint ./lib/ ./test/ ./sample/", 72 | "lint-fix": "eslint --fix", 73 | "mocha-coverage": "cross-env NODE_ENV=test nyc --reporter=text-lcov mocha > mocha.lcov", 74 | "report-coverage": "npm run mocha-coverage", 75 | "test": "npm run test-no-lint", 76 | "test-coverage": "cross-env NODE_ENV=test nyc mocha", 77 | "test-coverage-html": "cross-env NODE_ENV=test nyc --reporter html mocha", 78 | "test-no-lint": "mocha ./test/*-test.js" 79 | }, 80 | "dependencies": { 81 | "@vonage/jwt": "1.11.0", 82 | "debug": "4.4.0", 83 | "jsonwebtoken": "9.0.2", 84 | "lodash": "4.17.21", 85 | "node-fetch": "2.7.0", 86 | "opentok-token": "1.1.1", 87 | "uuid": "11.0.5" 88 | }, 89 | "devDependencies": { 90 | "chai": "4.3.10", 91 | "cross-env": "7.0.3", 92 | "eslint": "8.51.0", 93 | "eslint-config-airbnb-base": "15.0.0", 94 | "eslint-plugin-import": "2.28.1", 95 | "mocha": "10.8.2", 96 | "nock": "13.3.6", 97 | "nyc": "15.1.0" 98 | }, 99 | "engines": { 100 | "node": ">=4" 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /sample/Archiving/README.md: -------------------------------------------------------------------------------- 1 | # OpenTok Archiving Sample for Node 2 | 3 | This is a simple demo app that shows how you can use the OpenTok Node SDK to archive (or record) 4 | Sessions, list archives that have been created, download the recordings, and delete the recordings. 5 | 6 | ## Running the App 7 | 8 | First, download the dependencies using [npm](https://www.npmjs.org) in this directory. 9 | 10 | ``` 11 | $ npm install 12 | ``` 13 | 14 | Next, add your own API Key and API Secret to the environment variables. There are a few ways to do 15 | this but the simplest would be to do it right in your shell. 16 | 17 | ``` 18 | $ export API_KEY=0000000 19 | $ export API_SECRET=abcdef1234567890abcdef01234567890abcdef 20 | ``` 21 | 22 | Finally, start the app using node 23 | 24 | ``` 25 | $ node index.js 26 | ``` 27 | 28 | Visit in your browser. You can now create new archives (either as a host or 29 | as a participant) and also play archives that have already been created. 30 | 31 | ## Walkthrough 32 | 33 | This demo application uses the same frameworks and libraries as the HelloWorld sample. If you have 34 | not already gotten familiar with the code in that project, consider doing so before continuing. 35 | 36 | The explanations below are separated by page. Each section will focus on a route handler within the 37 | main application (index.js). 38 | 39 | ### Creating Archives – Host View 40 | 41 | Start by visiting the host page at and using the application to record 42 | an archive. Your browser will first ask you to approve permission to use the camera and microphone. 43 | Once you've accepted, your image will appear inside the section titled 'Host'. To start recording 44 | the video stream, press the 'Start Archiving' button. Once archiving has begun the button will turn 45 | green and change to 'Stop Archiving'. You should also see a red blinking indicator that you are 46 | being recorded. Wave and say hello! Stop archiving when you are done. 47 | 48 | Next we will see how the host view is implemented on the server. The route handler for this page is 49 | shown below: 50 | 51 | ```javascript 52 | app.get('/host', function(req, res) { 53 | var sessionId = app.get('sessionId'), 54 | // generate a fresh token for this client 55 | token = opentok.generateToken(sessionId, { role: 'moderator' }); 56 | 57 | res.render('host.ejs', { 58 | apiKey: apiKey, 59 | sessionId: sessionId, 60 | token: token 61 | }); 62 | }); 63 | ``` 64 | 65 | If you've completed the HelloWorld walkthrough, this should look familiar. This handler simply 66 | generates the three strings that the client (JavaScript) needs to connect to the session: `apiKey`, 67 | `sessionId` and `token`. After the user has connected to the session, they press the 68 | 'Start Archiving' button, which sends an XHR (or Ajax) request to the 69 | URL. The route handler for this URL is shown below: 70 | 71 | ```javascript 72 | app.post('/start', function(req, res) { 73 | var hasAudio = (req.param('hasAudio') !== undefined); 74 | var hasVideo = (req.param('hasVideo') !== undefined); 75 | var outputMode = req.param('outputMode'); 76 | var archiveOptions = { 77 | name: 'Node Archiving Sample App', 78 | hasAudio: hasAudio, 79 | hasVideo: hasVideo, 80 | outputMode: outputMode, 81 | }; 82 | if (outputMode === 'composed') { 83 | startOptions.layout = { type: 'horizontalPresentation' }; 84 | } 85 | opentok.startArchive(app.get('sessionId'), archiveOptions, function(err, archive) { 86 | if (err) return res.send(500, 87 | 'Could not start archive for session '+sessionId+'. error='+err.message 88 | ); 89 | res.json(archive); 90 | }); 91 | }); 92 | ``` 93 | 94 | In this handler, the `startArchive()` method of the `opentok` instance is called with the 95 | `sessionId` for the session that needs to be archived. In this case, as in the HelloWorld 96 | sample app, there is only one session created and it is used here and for the participant view. 97 | This will trigger the recording to begin. 98 | 99 | The optional second argument is for options. The `name` is stored with the archive and can 100 | be read later. The `hasAudio`, `hasVideo`, `outputMode`, values are read from the request body; 101 | these define whether the archive will record audio and video, and whether it will record streams 102 | individually or to a single file composed of all streams. See the "Changing Archive Layout" section 103 | below for information on the `layout` option. 104 | 105 | The last argument is the callback for the result of this asynchronous function. 106 | The callback signature follows the common node pattern of using the first argument fo 107 | an error if one occurred, otherwise the second parameter is an Archive object. As long 108 | as there is no error, a response is sent back to the client's XHR request with the JSON 109 | representation of the archive. The client is also listening for the `archiveStarted` event, and uses 110 | that event to change the 'Start Archiving' button to show 'Stop Archiving' instead. When the user 111 | presses the button this time, another XHR request is sent to the 112 | URL where `:archiveId` represents the ID the client receives 113 | in the 'archiveStarted' event. The route handler for this request is shown below: 114 | 115 | ```javascript 116 | app.get('/stop/:archiveId', function(req, res) { 117 | var archiveId = req.param('archiveId'); 118 | opentok.stopArchive(archiveId, function(err, archive) { 119 | if (err) return res.send(500, 'Could not stop archive '+archiveId+'. error='+err.message); 120 | res.json(archive); 121 | }); 122 | }); 123 | ``` 124 | 125 | This handler is very similar to the previous one. Instead of calling the `startArchive()` method, 126 | the `stopArchive()` method is called. This method takes an `archiveId` as its parameter, which 127 | is different for each time a session starts recording. But the client has sent this to the server 128 | as part of the URL, so the `req.param('archiveId')` expression is used to retrieve it. 129 | 130 | Now you have understood the three main routes that are used to create the Host experience of 131 | creating an archive. Much of the functionality is done in the client with JavaScript. That code can 132 | be found in the `public/js/host.js` file. Read about the 133 | [OpenTok.js JavaScript](http://tokbox.com/opentok/libraries/client/js/) library to learn more. 134 | 135 | ### Creating Archives - Participant View 136 | 137 | With the host view still open and publishing, open an additional window or tab and navigate to 138 | and allow the browser to use your camera and microphone. Once 139 | again, start archiving in the host view. Back in the participant view, notice that the red blinking 140 | indicator has been shown so that the participant knows his video is being recorded. Now stop the 141 | archiving in the host view. Notice that the indicator has gone away in the participant view too. 142 | 143 | Creating this view on the server is as simple as the HelloWorld sample application. See the code 144 | for the route handler below: 145 | 146 | ```javascript 147 | app.get('/participant', function(req, res) { 148 | var sessionId = app.get('sessionId'), 149 | // generate a fresh token for this client 150 | token = opentok.generateToken(sessionId, { role: 'moderator' }); 151 | 152 | res.render('participant.ejs', { 153 | apiKey: apiKey, 154 | sessionId: sessionId, 155 | token: token 156 | }); 157 | }); 158 | ``` 159 | 160 | Since this view has no further interactivity with buttons, this is all that is needed for a client 161 | that is participating in an archived session. Once again, much of the functionality is implemented 162 | in the client, in code that can be found in the `public/js/participant.js` file. 163 | 164 | ### Changing Archive Layout 165 | 166 | *Note:* Changing archive layout is only available for composed archives, and setting the layout 167 | is not required. By default, composed archives use the "best fit" layout. For more information, 168 | see the OpenTok developer guide for [Customizing the video layout for composed 169 | archives](https://tokbox.com/developer/guides/archiving/layout-control.html). 170 | 171 | When you create a composed archive (when the `outputMode` is set to 'composed), we set the 172 | `layout` property of the `options` object passed into `OpenTok.startArchive()` to 173 | `'horizontalPresentation'`. This sets the initial layout type of the archive. 174 | `'horizontalPresentation'` is one of the predefined layout types for composed archives. 175 | 176 | For composed archives, you can change the layout dynamically. The host view includes a 177 | *Toggle layout* button. This toggles the layout of the streams between a horizontal and vertical 178 | presentation. When you click this button, the host client switches makes an HTTP POST request to 179 | the '/archive/:archiveId/layout' endpoint: 180 | 181 | ```javascript 182 | app.post('/archive/:archiveId/layout', function (req, res) { 183 | var archiveId = req.param('archiveId'); 184 | var type = req.body.type; 185 | app.set('layout', type); 186 | opentok.setArchiveLayout(archiveId, type, null, function (err) { 187 | if (err) return res.send(500, 'Could not set layout ' + type + '. error=' + err.message); 188 | res.send(200, 'OK'); 189 | }); 190 | }); 191 | ``` 192 | 193 | This calls the `OpenTok.setArchiveLayout()` method of the OpenTok Node.js SDK, setting the 194 | archive layout to the layout type defined in the POST request's body. The layout type will 195 | either be set to `horizontalPresentation` or `verticalPresentation`, which are two of the predefined layout types for OpenTok composed archives. 196 | 197 | Also, in the host view, you can click any stream to set it to be the focus stream in the 198 | archive layout. (Click outside of the mute audio icon.) Doing so sends an HTTP POST request 199 | to the `/focus` endpoint: 200 | 201 | ```javascript 202 | app.post('/focus', function (req, res) { 203 | var otherStreams = req.body.otherStreams; 204 | var focusStreamId = req.body.focus; 205 | var classListArray = []; 206 | if (otherStreams) { 207 | var i; 208 | for (i = 0; i < otherStreams.length; i++) { 209 | classListArray.push({ 210 | id: otherStreams[i], 211 | layoutClassList: [], 212 | }); 213 | } 214 | } 215 | classListArray.push({ 216 | id: focusStreamId, 217 | layoutClassList: ['focus'], 218 | }); 219 | app.set('focusStreamId', focusStreamId); 220 | opentok.setStreamClassLists(app.get('sessionId'), classListArray, function (err) { 221 | if (err) return res.send(500, 'Could not set class lists. Error:' + err.message); 222 | return res.send(200, 'OK'); 223 | }); 224 | }); 225 | ``` 226 | 227 | The body of the POST request includes the stream ID of the "focus" stream and an array of 228 | other stream IDs in the session. The server-side method that handles the POST requests assembles 229 | a `classListArray` array, based on these stream IDs: 230 | 231 | ```javascript 232 | [ 233 | { 234 | "id": "6ad90229-df4f-4849-8974-5d675727c8b5", 235 | "layoutClassList": [] 236 | }, 237 | { 238 | "id": "aef616a5-769c-43e9-96d2-221edb986cbf", 239 | "layoutClassList": [] 240 | }, 241 | { 242 | "id": "db9f2372-7564-4b38-9bb2-d6ba4249fe63", 243 | "layoutClassList": ["focus"] 244 | } 245 | ] 246 | ``` 247 | 248 | This is passed in as the `classListArray` parameter of the `OpenTok.setStreamClassLists()` method 249 | of the OpenTok Node.js SDK: 250 | 251 | ```javascript 252 | opentok.setStreamClassLists(app.get('sessionId'), classListArray, function (err) { 253 | if (err) return res.send(500, 'Could not set class lists. Error:' + err.message); 254 | return res.send(200, 'OK'); 255 | }); 256 | ``` 257 | 258 | This sets one stream to have the `focus` class, which causes it to be the large stream 259 | displayed in the composed archive. (This is the behavior of the `horizontalPresentation` and 260 | `verticalPresentation` layout types.) To see this effect, you should open the host and participant 261 | pages on different computers (using different cameras). Or, if you have multiple cameras connected 262 | to your machine, you can use one camera for publishing from the host, and use another for the 263 | participant. Or, if you are using a laptop with an external monitor, you can load the host page 264 | with the laptop closed (no camera) and open the participant page with the laptop open. 265 | 266 | The host client page also uses OpenTok signaling to notify other clients when the layout type and 267 | focus stream changes, and they then update the local display of streams in the HTML DOM accordingly. 268 | However, this is not necessary. The layout of the composed archive is unrelated to the layout of 269 | streams in the web clients. 270 | 271 | When you playback the composed archive, the layout type and focus stream changes, based on calls 272 | to the `OpenTok.setArchiveLayout()` and `OpenTok.setStreamClassLists()` methods during 273 | the recording. 274 | 275 | ### Past Archives 276 | 277 | Start by visiting the history page at . You will see a table that 278 | displays all the archives created with your API Key. If there are more than five, the older ones 279 | can be seen by clicking the "Older →" link. If you click on the name of an archive, your browser 280 | will start downloading the archive file. If you click the "Delete" link in the end of the row 281 | for any archive, that archive will be deleted and no longer available. Some basic information like 282 | when the archive was created, how long it is, and its status is also shown. You should see the 283 | archives you created in the previous sections here. 284 | 285 | We begin to see how this page is created by looking at the route handler for this URL: 286 | 287 | ```javascript 288 | app.get('/history', function(req, res) { 289 | var page = req.param('page') || 1, 290 | offset = (page - 1) * 5; 291 | opentok.listArchives({ offset: offset, count: 5 }, function(err, archives, count) { 292 | if (err) return res.send(500, 'Could not list archives. error=' + err.message); 293 | res.render('history.ejs', { 294 | archives: archives, 295 | showPrevious: page > 1 ? ('/history?page='+(page-1)) : null, 296 | showNext: (count > offset + 5) ? ('/history?page='+(page+1)) : null 297 | }); 298 | }); 299 | }); 300 | ``` 301 | 302 | This view is paginated so that we don't potentially show hundreds of rows on the table, which would 303 | be difficult for the user to navigate. So this code starts by figuring out which page needs to be 304 | shown, where each page is a set of 5 archives. The `page` number is read from the request's query 305 | string parameters. The `offset`, which represents how many archives are being skipped, is always 306 | calculated as five times as many pages that are less than the current page, which is 307 | `(page - 1) * 5`. Now there is enough information to ask for a list of archives from OpenTok, which 308 | we do by calling the `listArchives()` method of the `opentok` instance. The first parameter is an 309 | optional object to specify a count and offset. If we are not at the first page, we can pass the view 310 | a string that contains the relative URL for the previous page. Similarly, we can also include one 311 | for the next page. Now the application renders the view using that information and the partial list 312 | of archives. 313 | 314 | At this point the template file `views/history.ejs` handles looping over the array of archives and 315 | outputting the proper information for each column in the table. It also places a link to the 316 | download and delete routes around the archive's name and its delete button, respectively. 317 | 318 | The code for the download route handler is shown below: 319 | 320 | ```javascript 321 | app.get('/download/:archiveId', function(req, res) { 322 | var archiveId = req.param('archiveId'); 323 | opentok.getArchive(archiveId, function(err, archive) { 324 | if (err) return res.send(500, 'Could not get archive '+archiveId+'. error='+err.message); 325 | res.redirect(archive.url); 326 | }); 327 | }); 328 | ``` 329 | 330 | The download URL for an archive is available as a property of an `Archive` instance. In order to get 331 | an instance to this archive, the `getArchive()` method of the `opentok` instance is used. The first 332 | parameter is required and it is the `archiveId`. We use the same technique as above to read that 333 | `archiveId` from the URL. The second parameter is a callback function, whose signature has arguments 334 | for the error object and the resulting archive instance. Lastly, we send a redirect response back to 335 | the browser with the archive's URL so the download begins. 336 | 337 | The code for the delete route handler is shown below: 338 | 339 | ```javascript 340 | app.get('/delete/:archiveId', function(req, res) { 341 | var archiveId = req.param('archiveId'); 342 | opentok.deleteArchive(archiveId, function(err) { 343 | if (err) return res.send(500, 'Could not stop archive '+archiveId+'. error='+err.message); 344 | res.redirect('/history'); 345 | }); 346 | }); 347 | ``` 348 | 349 | Once again the `archiveId` is retrieved from the URL of the request. This value is then passed to the 350 | `deleteArchive()` method of the `opentok` instance. The callback only has an argument for an error 351 | in case one occurred. Now that the archive has been deleted, a redirect response back to the first 352 | page of the history is sent back to the browser. 353 | 354 | That completes the walkthrough for this Archiving sample application. Feel free to continue to use 355 | this application to browse the archives created for your API Key. 356 | -------------------------------------------------------------------------------- /sample/Archiving/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console, no-path-concat */ 2 | 3 | // Dependencies 4 | var express = require('express'); 5 | var bodyParser = require('body-parser'); 6 | var OpenTok = require('../../lib/opentok'); 7 | var app = express(); 8 | 9 | var opentok; 10 | var apiKey = process.env.API_KEY; 11 | var apiSecret = process.env.API_SECRET; 12 | 13 | // Verify that the API Key and API Secret are defined 14 | if (!apiKey || !apiSecret) { 15 | console.log('You must specify API_KEY and API_SECRET environment variables'); 16 | process.exit(1); 17 | } 18 | 19 | // Initialize the express app 20 | app.use(express.static(__dirname + '/public')); 21 | app.use(bodyParser.json()); // for parsing application/json 22 | app.use(bodyParser.urlencoded({ 23 | extended: true 24 | })); 25 | 26 | // Starts the express app 27 | function init() { 28 | app.listen(3000, function () { 29 | console.log('You\'re app is now ready at http://localhost:3000/'); 30 | }); 31 | } 32 | 33 | // Initialize OpenTok 34 | opentok = new OpenTok(apiKey, apiSecret); 35 | 36 | // Create a session and store it in the express app 37 | opentok.createSession({ mediaMode: 'routed' }, function (err, session) { 38 | if (err) throw err; 39 | app.set('sessionId', session.sessionId); 40 | app.set('layout', 'horizontalPresentation'); 41 | // We will wait on starting the app until this is done 42 | init(); 43 | }); 44 | 45 | app.get('/', function (req, res) { 46 | res.render('index.ejs'); 47 | }); 48 | 49 | app.get('/host', function (req, res) { 50 | var sessionId = app.get('sessionId'); 51 | // generate a fresh token for this client 52 | var token = opentok.generateToken(sessionId, { 53 | role: 'moderator', 54 | initialLayoutClassList: ['focus'] 55 | }); 56 | 57 | res.render('host.ejs', { 58 | apiKey: apiKey, 59 | sessionId: sessionId, 60 | token: token, 61 | focusStreamId: app.get('focusStreamId') || '', 62 | layout: app.get('layout') 63 | }); 64 | }); 65 | 66 | app.get('/participant', function (req, res) { 67 | var sessionId = app.get('sessionId'); 68 | // generate a fresh token for this client 69 | var token = opentok.generateToken(sessionId, { role: 'moderator' }); 70 | 71 | res.render('participant.ejs', { 72 | apiKey: apiKey, 73 | sessionId: sessionId, 74 | token: token, 75 | focusStreamId: app.get('focusStreamId') || '', 76 | layout: app.get('layout') 77 | }); 78 | }); 79 | 80 | app.get('/history', function (req, res) { 81 | var page = req.param('page') || 1; 82 | var offset = (page - 1) * 5; 83 | opentok.listArchives({ offset: offset, count: 5 }, function (err, archives, count) { 84 | if (err) return res.send(500, 'Could not list archives. error=' + err.message); 85 | return res.render('history.ejs', { 86 | archives: archives, 87 | showPrevious: page > 1 ? ('/history?page=' + (page - 1)) : null, 88 | showNext: (count > offset + 5) ? ('/history?page=' + (page + 1)) : null 89 | }); 90 | }); 91 | }); 92 | 93 | app.get('/download/:archiveId', function (req, res) { 94 | var archiveId = req.param('archiveId'); 95 | opentok.getArchive(archiveId, function (err, archive) { 96 | if (err) return res.send(500, 'Could not get archive ' + archiveId + '. error=' + err.message); 97 | return res.redirect(archive.url); 98 | }); 99 | }); 100 | 101 | app.post('/start', function (req, res) { 102 | var hasAudio = (req.param('hasAudio') !== undefined); 103 | var hasVideo = (req.param('hasVideo') !== undefined); 104 | var outputMode = req.param('outputMode'); 105 | var archiveOptions = { 106 | name: 'Node Archiving Sample App', 107 | hasAudio: hasAudio, 108 | hasVideo: hasVideo, 109 | outputMode: outputMode 110 | }; 111 | if (outputMode === 'composed') { 112 | archiveOptions.layout = { type: 'horizontalPresentation' }; 113 | } 114 | opentok.startArchive(app.get('sessionId'), archiveOptions, function (err, archive) { 115 | if (err) { 116 | return res.send( 117 | 500, 118 | 'Could not start archive for session ' + app.get('sessionId') + '. error=' + err.message 119 | ); 120 | } 121 | return res.json(archive); 122 | }); 123 | }); 124 | 125 | app.get('/stop/:archiveId', function (req, res) { 126 | var archiveId = req.param('archiveId'); 127 | opentok.stopArchive(archiveId, function (err, archive) { 128 | if (err) return res.send(500, 'Could not stop archive ' + archiveId + '. error=' + err.message); 129 | return res.json(archive); 130 | }); 131 | }); 132 | 133 | app.get('/delete/:archiveId', function (req, res) { 134 | var archiveId = req.param('archiveId'); 135 | opentok.deleteArchive(archiveId, function (err) { 136 | if (err) return res.send(500, 'Could not stop archive ' + archiveId + '. error=' + err.message); 137 | return res.redirect('/history'); 138 | }); 139 | }); 140 | 141 | app.post('/archive/:archiveId/layout', function (req, res) { 142 | var archiveId = req.param('archiveId'); 143 | var type = req.body.type; 144 | app.set('layout', type); 145 | opentok.setArchiveLayout(archiveId, type, null, function (err) { 146 | if (err) { 147 | return res.send(500, 'Could not set layout ' + type + '. Error: ' + err.message); 148 | } 149 | return res.send(200, 'OK'); 150 | }); 151 | }); 152 | 153 | app.post('/focus', function (req, res) { 154 | var otherStreams = req.body.otherStreams; 155 | var focusStreamId = req.body.focus; 156 | var classListArray = []; 157 | var i; 158 | 159 | if (otherStreams) { 160 | for (i = 0; i < otherStreams.length; i++) { 161 | classListArray.push({ 162 | id: otherStreams[i], 163 | layoutClassList: [] 164 | }); 165 | } 166 | } 167 | classListArray.push({ 168 | id: focusStreamId, 169 | layoutClassList: ['focus'] 170 | }); 171 | app.set('focusStreamId', focusStreamId); 172 | opentok.setStreamClassLists(app.get('sessionId'), classListArray, function (err) { 173 | if (err) return res.send(500, 'Could not set class lists. Error:' + err.message); 174 | return res.send(200, 'OK'); 175 | }); 176 | }); 177 | -------------------------------------------------------------------------------- /sample/Archiving/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "opentok-archiving-sample", 3 | "version": "0.0.0", 4 | "description": "Demo of OpenTok API", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "body-parser": "^1.19.0", 13 | "ejs": "^2.5.5", 14 | "express": "^4.16.0" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /sample/Archiving/public/css/sample.css: -------------------------------------------------------------------------------- 1 | /* Move down content because we have a fixed navbar that is 50px tall */ 2 | body { 3 | padding-top: 50px; 4 | padding-bottom: 20px; 5 | background-color: #F2F2F2; 6 | } 7 | 8 | /* Responsive: Portrait tablets and up */ 9 | @media screen and (min-width: 768px) { 10 | /* Remove padding from wrapping element since we kick in the grid classes here */ 11 | .body-content { 12 | padding: 0; 13 | } 14 | } 15 | 16 | #streams { 17 | background-color: gray; 18 | width: 320px; 19 | height: 240px; 20 | } 21 | 22 | #streams > div { 23 | width: 20%; 24 | height: 20%; 25 | float: left; 26 | position: relative; 27 | cursor: pointer; 28 | } 29 | 30 | #streams.vertical > div { 31 | left: 0px; 32 | clear: left; 33 | padding: 0px; 34 | } 35 | 36 | #streams .focus { 37 | position: relative; 38 | top: 0; 39 | left: 0; 40 | margin-top: 0; 41 | height: 80%; 42 | width: 100%; 43 | } 44 | 45 | #streams.vertical .focus { 46 | padding: 0; 47 | left: 0; 48 | margin: 0; 49 | left: 20%; 50 | height: 100%; 51 | width: 80%; 52 | } 53 | 54 | .stop { 55 | display: none; 56 | } 57 | 58 | .bump-me { 59 | padding-top: 40px; 60 | } 61 | -------------------------------------------------------------------------------- /sample/Archiving/public/img/archiving-off.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opentok/opentok-node/778f82737dd56102e5c95b9a51c7c6d05b48e41d/sample/Archiving/public/img/archiving-off.png -------------------------------------------------------------------------------- /sample/Archiving/public/img/archiving-on-idle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opentok/opentok-node/778f82737dd56102e5c95b9a51c7c6d05b48e41d/sample/Archiving/public/img/archiving-on-idle.png -------------------------------------------------------------------------------- /sample/Archiving/public/img/archiving-on-message.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opentok/opentok-node/778f82737dd56102e5c95b9a51c7c6d05b48e41d/sample/Archiving/public/img/archiving-on-message.png -------------------------------------------------------------------------------- /sample/Archiving/public/js/host.js: -------------------------------------------------------------------------------- 1 | /* global OT, apiKey, sessionId, token, $, layout, focusStreamId */ 2 | /* eslint-disable no-console */ 3 | 4 | var session = OT.initSession(apiKey, sessionId); 5 | var publisher = OT.initPublisher('publisher', { 6 | insertMode: 'append', 7 | width: '100%', 8 | height: '100%' 9 | }); 10 | var archiveID = null; 11 | 12 | function disableForm() { 13 | $('.archive-options-fields').attr('disabled', 'disabled'); 14 | } 15 | 16 | function enableForm() { 17 | $('.archive-options-fields').removeAttr('disabled'); 18 | } 19 | 20 | function positionStreams() { 21 | var $focusElement; 22 | $focusElement = $('.focus'); 23 | if ($('#streams').hasClass('vertical')) { 24 | $('#streams').children().css('top', '0'); 25 | $focusElement.appendTo('#streams'); 26 | $focusElement.css('top', (-20 * ($('#streams').children().size() - 1)) + '%'); 27 | } 28 | else { 29 | $focusElement.prependTo('#streams'); 30 | $focusElement.css('top', '0'); 31 | } 32 | } 33 | 34 | function setFocus(focusStreamId) { 35 | var $focusElement; 36 | var otherStreams = $.map($('#streams').children(), function (element) { 37 | var streamId = (element.id === 'publisher' && publisher.stream) ? publisher.stream.streamId 38 | : element.id; 39 | if (streamId !== focusStreamId) { 40 | $('#' + element.id).removeClass('focus'); 41 | return streamId; 42 | } 43 | return null; 44 | }); 45 | 46 | $.post('/focus', { 47 | focus: focusStreamId, 48 | otherStreams: otherStreams 49 | }).done(function () { 50 | console.log('Focus changed.'); 51 | }).fail(function (jqXHR, textStatus, errorThrown) { 52 | console.error('Stream class list error:', errorThrown); 53 | }); 54 | 55 | $('.focus').removeClass('focus'); 56 | $focusElement = (publisher.stream && publisher.stream.streamId === focusStreamId) ? 57 | $('#publisher') : $('#' + focusStreamId); 58 | $focusElement.addClass('focus'); 59 | session.signal({ 60 | type: 'focusStream', 61 | data: focusStreamId 62 | }); 63 | positionStreams(); 64 | } 65 | 66 | function createFocusClick(elementId, focusStreamId) { 67 | $('#' + elementId).click(function () { 68 | setFocus(focusStreamId); 69 | }); 70 | } 71 | 72 | if (layout === 'verticalPresentation') { 73 | $('#streams').addClass('vertical'); 74 | } 75 | 76 | session.connect(token, function (err) { 77 | if (err) { 78 | alert(err.message || err); // eslint-disable-line no-alert 79 | } 80 | session.publish(publisher); 81 | }); 82 | 83 | publisher.on('streamCreated', function () { 84 | createFocusClick(publisher.id, publisher.stream.streamId); 85 | positionStreams(); 86 | }); 87 | 88 | session.on('streamCreated', function (event) { 89 | var subscriber; 90 | var streamId = event.stream.streamId; 91 | var $streamContainer = $('

'); 92 | $streamContainer.attr('id', event.stream.id); 93 | $('#streams').append($streamContainer); 94 | subscriber = session.subscribe(event.stream, streamId, { 95 | insertMode: 'append', 96 | width: '100%', 97 | height: '100%' 98 | }); 99 | 100 | if (streamId === focusStreamId) { 101 | setFocus(streamId); 102 | } 103 | createFocusClick(subscriber.id, streamId); 104 | positionStreams(); 105 | }); 106 | 107 | session.on('streamDestroyed', function (event) { 108 | var $streamElem = $('#' + event.stream.id); 109 | if ($streamElem.hasClass('focus')) { 110 | setFocus(publisher.stream.streamId); 111 | } 112 | $streamElem.remove(); 113 | positionStreams(); 114 | }); 115 | 116 | session.on('archiveStarted', function (event) { 117 | archiveID = event.id; 118 | console.log('ARCHIVE STARTED'); 119 | $('.start').hide(); 120 | $('.stop').show(); 121 | disableForm(); 122 | }); 123 | 124 | session.on('archiveStopped', function () { 125 | archiveID = null; 126 | console.log('ARCHIVE STOPPED'); 127 | $('.start').show(); 128 | $('.stop').hide(); 129 | enableForm(); 130 | }); 131 | 132 | $(document).ready(function () { 133 | $('.start').click(function () { 134 | var options = $('.archive-options').serialize(); 135 | disableForm(); 136 | $.post('/start', options) 137 | .fail(enableForm); 138 | }).prop('disabled', false); 139 | $('.stop').click(function () { 140 | $.get('stop/' + archiveID); 141 | }); 142 | $('.toggle-layout').click(function () { 143 | var newLayoutClass; 144 | 145 | if ($('#streams').hasClass('vertical')) { 146 | $('#streams').removeClass('vertical'); 147 | } 148 | else { 149 | $('#streams').addClass('vertical'); 150 | } 151 | 152 | positionStreams(); 153 | 154 | newLayoutClass = $('#streams').hasClass('vertical') ? 'verticalPresentation' 155 | : 'horizontalPresentation'; 156 | 157 | if (archiveID) { 158 | $.post('archive/' + archiveID + '/layout', { 159 | type: newLayoutClass 160 | }).done(function () { 161 | console.log('Archive layout updated.'); 162 | }).fail(function (jqXHR) { 163 | console.error('Archive layout error:', jqXHR.responseText); 164 | }); 165 | } 166 | 167 | session.signal({ 168 | type: 'layoutClass', 169 | data: newLayoutClass 170 | }); 171 | }); 172 | }); 173 | -------------------------------------------------------------------------------- /sample/Archiving/public/js/participant.js: -------------------------------------------------------------------------------- 1 | /* global OT, apiKey, sessionId, token, $, layout, focusStreamId */ 2 | var session = OT.initSession(apiKey, sessionId); 3 | var publisher; 4 | 5 | var container = $('
'); 6 | 7 | if (layout === 'verticalPresentation') { 8 | $('#streams').addClass('vertical'); 9 | } 10 | 11 | container.addClass('focus'); 12 | $('#streams').append(container); 13 | 14 | publisher = OT.initPublisher('publisher', { 15 | insertMode: 'append', 16 | width: '100%', 17 | height: '100%' 18 | }); 19 | 20 | function positionStreams() { 21 | var $focusElement = $('.focus'); 22 | if ($('#streams').hasClass('vertical')) { 23 | $focusElement.appendTo('#streams'); 24 | $('#streams').children().css('top', '0'); 25 | $focusElement.css('top', (-20 * ($('#streams').children().size() - 1)) + '%'); 26 | } 27 | else { 28 | $focusElement.prependTo('#streams'); 29 | $focusElement.css('top', '0'); 30 | } 31 | } 32 | 33 | function focusStream(streamId) { 34 | var focusStreamId = streamId; 35 | var $focusElement = (publisher.stream && publisher.stream.id === focusStreamId) ? $('#publisher') 36 | : $('#' + focusStreamId); 37 | $('.focus').removeClass('focus'); 38 | $focusElement.addClass('focus'); 39 | positionStreams(); 40 | } 41 | 42 | session.connect(token, function (err) { 43 | if (err) { 44 | alert(err.message || err); // eslint-disable-line no-alert 45 | } 46 | session.publish(publisher); 47 | }); 48 | 49 | session.on('streamCreated', function (event) { 50 | var streamId = event.stream.id; 51 | container = document.createElement('div'); 52 | container.id = streamId; 53 | $('#streams').append(container); 54 | session.subscribe(event.stream, streamId, { 55 | insertMode: 'append', 56 | width: '100%', 57 | height: '100%' 58 | }); 59 | if (streamId === focusStreamId) { 60 | focusStream(streamId); 61 | } 62 | positionStreams(); 63 | }); 64 | 65 | session.on('streamDestroyed', function (event) { 66 | $('#' + event.stream.id).remove(); 67 | positionStreams(); 68 | }); 69 | 70 | session.on('signal:layoutClass', function (event) { 71 | if (event.data === 'horizontalPresentation') { 72 | $('#streams').removeClass('vertical'); 73 | $('.focus').prependTo('#streams'); 74 | } 75 | else { 76 | $('#streams').addClass('vertical'); 77 | $('.focus').appendTo('#streams'); 78 | } 79 | positionStreams(); 80 | }); 81 | 82 | session.on('signal:focusStream', function (event) { 83 | focusStream(event.data); 84 | }); 85 | -------------------------------------------------------------------------------- /sample/Archiving/views/footer.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /sample/Archiving/views/header.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Archiving Sample 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 24 | -------------------------------------------------------------------------------- /sample/Archiving/views/history.ejs: -------------------------------------------------------------------------------- 1 | <% include header %> 2 |
3 | 4 |
5 | 6 |
7 |
8 |

Past Recordings

9 |
10 |
11 | <% if (archives.length > 0) { %> 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | <% archives.forEach(function(item){ %> 23 | 24 | 25 | 34 | 35 | 36 | 37 | 44 | 45 | 46 | <% }) %> 47 | 48 |
 CreatedDurationStatus
26 | <% if ((item.status === "available") && ("url" in item) && (item.url.length > 0)) { %> 27 | 28 | <% } %> 29 | <%= (item.name || "Untitled") %> 30 | <% if ((item.status === "available") && ("url" in item) && (item.url.length > 0)) { %> 31 | 32 | <% } %> 33 | <%= new Date(item.createdAt).toUTCString() %><%= item.duration %> seconds<%= item.status %> 38 | <% if (item.status === "available") { %> 39 | Delete 40 | <% } else { %> 41 |   42 | <% } %> 43 |
49 | <% } else { %> 50 |

51 | There are no archives currently. Try making one in the host view. 52 |

53 | <% } %> 54 |
55 | 64 |
65 |
66 |
67 | <% include footer %> 68 | -------------------------------------------------------------------------------- /sample/Archiving/views/host.ejs: -------------------------------------------------------------------------------- 1 | <% include header %> 2 | 3 | 4 | 5 |
6 | 7 |
8 | 9 |
10 |
11 |

Host

12 |
13 |
14 |
15 |
16 |
17 |
18 | 46 |
47 |
48 | 49 |
50 |
51 |

Instructions

52 |
53 |
54 |

55 | Click Start archiving to begin archiving this session. 56 | All publishers in the session will be included, and all publishers that 57 | join the session will be included as well. 58 |

59 |

60 | Click Stop archiving to end archiving this session. 61 | You can then go to past archives to 62 | view your archive (once its status changes to available). 63 |

64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 |
WhenYou will see
Archiving is started
Archiving remains on
Archiving is stopped
86 |

87 | Click Toggle layout to toggle the layout 88 | between a vertical and horizontal presentation. The layout changes in all clients 89 | and (in a composed archive) in the archive. 90 |

91 | Click any stream to set it to be the focus stream in the archive layout. 92 |

93 |
94 |
95 | 96 | 103 | 104 | 105 | 106 | 107 | <% include footer %> 108 | -------------------------------------------------------------------------------- /sample/Archiving/views/index.ejs: -------------------------------------------------------------------------------- 1 | <% include header %> 2 | 3 |
4 | 5 | 6 |
7 | 8 | 9 |
10 |
11 | 12 |
13 |
Create an archive
14 |
15 |

16 | Everyone who joins either the Host View or Participant View 17 | joins a single OpenTok session. Anyone with the Host View 18 | open can click Start Archive or Stop Archive to control 19 | recording of the entire session. 20 |

21 |
22 | 26 |
27 | 28 |
29 |
30 | 31 |
32 |
Play an archive
33 |
34 |

35 | Click through to Past Archives to see examples of using the 36 | Archiving REST API to list archives showing status (started, 37 | stopped, available) and playback (for available archives). 38 |

39 |
40 | 43 |
44 | 45 |
46 |
47 | 48 |
49 | 50 |
51 | 52 | <% include footer %> 53 | -------------------------------------------------------------------------------- /sample/Archiving/views/participant.ejs: -------------------------------------------------------------------------------- 1 | <% include header %> 2 | 3 | 4 | 5 |
6 | 7 |
8 | 9 |
10 |
11 |

Participant

12 |
13 |
14 |
15 |
16 |
17 |
18 | 19 |
20 |
21 |

Instructions

22 |
23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 |
WhenYou will see
Archiving is started
Archiving remains on
Archiving is stopped
46 |
47 |
48 |
49 | 50 | 57 | 58 | 59 | 60 | 61 | <% include footer %> 62 | -------------------------------------------------------------------------------- /sample/Broadcast/README.md: -------------------------------------------------------------------------------- 1 | # OpenTok Broadcast Sample for Node 2 | 3 | This is a simple demo app that shows how you can use the OpenTok Node SDK to create a 4 | [live-streaming broadcasts](https://tokbox.com/developer/guides/broadcast/live-streaming) 5 | of an OpenTok session. 6 | 7 | ## Running the App 8 | 9 | First, download the dependencies using [npm](https://www.npmjs.org) in this directory. 10 | 11 | ``` 12 | $ npm install 13 | ``` 14 | 15 | Next, add your own API Key and API Secret to the environment variables. There are a few ways to do 16 | this but the simplest would be to do it right in your shell. 17 | 18 | ``` 19 | $ export API_KEY=0000000 20 | $ export API_SECRET=abcdef1234567890abcdef01234567890abcdef 21 | ``` 22 | 23 | Finally, start the app using Node: 24 | 25 | ``` 26 | $ node index.js 27 | ``` 28 | 29 | Visit in your browser. Then, mute the audio on your computer 30 | (to prevent feedback) and load the page in another browser tab 31 | (or in a browser on another computer). The two client pages are connected in an OpenTok session. 32 | 33 | In the host page, set broadast options -- maximum duration and resolution -- and then click the **Start Broadcast** button to start the live streaming broadcast of the session. (The maximum 34 | duration setting is optional. The default maximum duration of a broadcast is 4 hours 35 | (14,400 seconds). 36 | 37 | Then, visit in Safari. This page shows the live streaming 38 | HLS broadcast of the session. Safari supports HLS streams natively. To support the HLS 39 | broadcast in other browsers, you will need to use an extension or script such as 40 | [videojs-http-streaming](https://github.com/videojs/http-streaming). 41 | 42 | In the host page, click the **Toggle Layout** button to switch the layout of the archive between 43 | horizontal and vertical presentation. Click any stream to make it the focus (larger) video in 44 | the layout. 45 | 46 | ## Walkthrough 47 | 48 | This demo application uses the same frameworks and libraries as the HelloWorld sample. If you have 49 | not already gotten familiar with the code in that project, consider doing so before continuing. 50 | 51 | Each section will focus on a route handler within the main Express index.js file. 52 | 53 | ### Starting a live streaming broadcast 54 | 55 | This is the route handler, in the Express index.js file, for the host page at 56 | : 57 | 58 | ```javascript 59 | app.get('/host', function(req, res) { 60 | var sessionId = app.get('sessionId'); 61 | // generate a fresh token for this client 62 | var token = opentok.generateToken(sessionId, { 63 | role: 'publisher', 64 | initialLayoutClassList: ['focus'] 65 | }); 66 | 67 | res.render('host.ejs', { 68 | apiKey: apiKey, 69 | sessionId: sessionId, 70 | token: token, 71 | initialBroadcastId: app.get('broadcastId'), 72 | focusStreamId: app.get('focusStreamId') || '', 73 | initialLayout: app.get('layout') 74 | }); 75 | }); 76 | ``` 77 | 78 | If you've completed the HelloWorld walkthrough, this should look familiar. This route handler passes 79 | three strings that the client (JavaScript) needs to connect to the session: `apiKey`, `sessionId`, 80 | and `token`. It also passes a broadcast ID (if a broadcast is already in progress), and the layout 81 | state (the focus stream ID and the current layout type), which will be discussed later. 82 | 83 | When the host user clicks the 'Start Broadcast' button, the client sends an XHR request to the 84 | URL. The route handler for this URL is shown below: 85 | 86 | ```javascript 87 | app.post('/start', function(req, res) { 88 | var broadcastOptions = { 89 | outputs: { 90 | hls: {} 91 | } 92 | maxDuration: Number(req.param('maxDuration')) || undefined, 93 | resolution: req.param('resolution'), 94 | layout: req.param('layout'), 95 | }; 96 | opentok.startBroadcast(app.get('sessionId'), broadcastOptions, function (err, broadcast) { 97 | if (err) { 98 | return res.send( 99 | 500, 100 | 'Could not start broadcast for session ' + app.get('sessionId') + '. error=' + err.message 101 | ); 102 | } 103 | app.set('broadcastId', broadcast.id); 104 | return res.json(broadcast); 105 | }); 106 | }); 107 | ``` 108 | 109 | In this handler, the `startBroadcast()` method of the `opentok` instance is called with the 110 | `sessionId` for the session. This will trigger the broadcast to start. 111 | 112 | The optional second argument is for options. In this app, the broadcast options are passed in 113 | from the client. These options include: 114 | 115 | * `outputs` -- This sets the HLS and RTMP outputs for the broadcast. This application simply 116 | broadcasts to an HLS stream. You could also specify RTMP stream URLs (in addition to or 117 | without specifying an HLS output): 118 | 119 | ```javascript 120 | outputs: { 121 | hls: {}, 122 | rtmp: [{ 123 | id: 'foo', 124 | serverUrl: 'rtmp://myfooserver/myfooapp', 125 | streamName: 'myfoostream' 126 | }, 127 | { 128 | id: 'bar', 129 | serverUrl: 'rtmp://mybarserver/mybarapp', 130 | streamName: 'mybarstream' 131 | }] 132 | }, 133 | ``` 134 | 135 | Set `hls` to an object (with no properties) to have an HLS broadcast be started. Set `rtmp` 136 | options for each RTMP stream you want to be started (if any) or leave `rtmp` unset if you 137 | do not want RTMP streams (as in this sample application). 138 | 139 | * `maxDuration` (Optional) -- The maximum duration of the archive, in seconds. 140 | 141 | * `resolution` (Optional) -- The resolution of the broadcast (either '640x480', '1280x720' or '1920x1080). 142 | 143 | * `layout` (Optional) -- The layout type of the broadcast, discussed ahead. 144 | 145 | The last argument is the callback for the result of this asynchronous function. 146 | The callback signature follows the common node pattern of using the first argument fo 147 | an error if one occurred, otherwise the second parameter is a Broadcast object (defined by 148 | the OpenTok Node SDK). If there is no error, a response is sent back to the client's XHR request 149 | with the JSON representation of the broadcast. This JSON includes a `broadcastId` property, 150 | the unique ID of the broadcast. 151 | 152 | When the host client receives this response, the **Start Broadcast** button is hidden and the **Stop Broadcast** is displayed. 153 | 154 | ### Stopping the broadcast 155 | 156 | When the host user presses the **Stop Broadcast**, the client sends 157 | an XHR request is sent to the URL where `:broadcastId` is the broadcast ID. This is route handler for this request (from the Express index.js file): 158 | 159 | ```javascript 160 | app.get('/stop/:broadcastId', function(req, res) { 161 | var broadcastId = req.param('broadcastId'); 162 | opentok.stopBroadcast(broadcastId, function (err, broadcast) { 163 | if (err) { 164 | return res.send(500, 'Could not stop broadcast ' + broadcastId + '. Error = ' + err.message); 165 | } 166 | app.set('broadcastId', null); 167 | return res.json(broadcast); 168 | }); 169 | }); 170 | ``` 171 | 172 | This handler is very similar to the previous one. Instead of calling the `startBroadcast()` method, 173 | the `stopBroadcast()` method is called. This method takes an `broadcastId` as its first parameter. 174 | The second parameter is the callback function that indicates success or failure in stopping the 175 | broadcast. 176 | 177 | ### Viewing the broadcast stream 178 | 179 | When you load the broadcast URL , this Express route handler 180 | is invoked: 181 | 182 | ```javascript 183 | app.get('/broadcast', function (req, res) { 184 | var broadcastId = app.get('broadcastId'); 185 | if (!broadcastId) { 186 | return res.send(404, 'Broadcast not in progress.'); 187 | } 188 | return opentok.getBroadcast(broadcastId, function (err, broadcast) { 189 | if (err) { 190 | return res.send(500, 'Could not get broadcast ' + broadcastId + '. error=' + err.message); 191 | } 192 | if (broadcast.status === 'started') { 193 | return res.redirect(broadcast.broadcastUrls.hls); 194 | } 195 | return res.send(404, 'Broadcast not in progress.'); 196 | }); 197 | }); 198 | ``` 199 | 200 | The app set the `broadcastId` property from the Broadcast object passed into the 201 | `OpenTok.startBroadcast()` callback function. 202 | 203 | The router method for the broadcast URL calls the `Opentok.getBroadcast()`, defined in the 204 | OpenTok Node SDK, to get information about the broadcast. The Broadcast object returned to the 205 | `getBroadcast()` completion handler includes the HLS broadcast URL (the `broadcastUrls.hls` property) as well as the `status` of the broadcast. If the `status` of the broacast is `'started'`, 206 | the Express router redirects the client to the URL of the HLS stream. 207 | 208 | Again, only Safari natively support viewing of an HLS stream. In other clients, you will need to 209 | use a HLS viewing extension. 210 | 211 | ### Changing Broadcast Layout 212 | 213 | When you start the broadcast, by calling the `OpenTok.startBroadcast()` method of the OpenTok Node 214 | SDK, you set the `layout` property of the `options` object. This sets the initial layout type 215 | of the broadcast. In our case, we set it to `'horizontalPresentation'` or `'verticalPresentation'`, 216 | which are two of the predefined layout types for live streaming broadcasts. 217 | 218 | You can change the layout dynamically. The host view includes a **Toggle layout** button. 219 | This toggles the layout of the streams between a horizontal and vertical presentation. 220 | When you click this button, the host client switches makes an HTTP POST request to 221 | the '/broadcast/:broadcastId/layout' endpoint: 222 | 223 | ```javascript 224 | app.post('/broadcast/:broadcastId/layout', function (req, res) { 225 | var broadcastId = req.param('broadcastId'); 226 | var type = req.body.type; 227 | app.set('layout', type); 228 | if (broadcastId) { 229 | opentok.setBroadcastLayout(broadcastId, type, null, function (err) { 230 | if (err) { 231 | return res.send(500, 'Could not set layout ' + type + '. Error: ' + err.message); 232 | } 233 | return res.send(200, 'OK'); 234 | }); 235 | } 236 | }); 237 | ``` 238 | 239 | This calls the `OpenTok.setBroadcastLayout()` method of the OpenTok Node.js SDK, setting the 240 | broadcast layout to the layout type defined in the POST request's body. In this app, the layout 241 | type is set to `horizontalPresentation` or `verticalPresentation`, two of the [predefined layout 242 | types](https://tokbox.com/developer/guides/broadcast/live-streaming/#predefined-layout-types) 243 | available to live streaming broadcasts. 244 | 245 | In the host view you can click any stream to set it to be the focus stream in the broadcast layout. 246 | (Click outside of the mute audio icon.) Doing so sends an HTTP POST request to the `/focus` 247 | endpoint: 248 | 249 | ```javascript 250 | app.post('/focus', function (req, res) { 251 | var otherStreams = req.body.otherStreams; 252 | var focusStreamId = req.body.focus; 253 | var classListArray = []; 254 | var i; 255 | 256 | if (otherStreams) { 257 | for (i = 0; i < otherStreams.length; i++) { 258 | classListArray.push({ 259 | id: otherStreams[i], 260 | layoutClassList: [] 261 | }); 262 | } 263 | } 264 | classListArray.push({ 265 | id: focusStreamId, 266 | layoutClassList: ['focus'] 267 | }); 268 | app.set('focusStreamId', focusStreamId); 269 | opentok.setStreamClassLists(app.get('sessionId'), classListArray, function (err) { 270 | if (err) { 271 | return res.send(500, 'Could not set class lists. Error:' + err.message); 272 | } 273 | return res.send(200, 'OK'); 274 | }); 275 | }); 276 | ``` 277 | 278 | The body of the POST request includes the stream ID of the "focus" stream and an array of 279 | other stream IDs in the session. The server-side method that handles the POST requests assembles 280 | a `classListArray` array, based on these stream IDs: 281 | 282 | ```javascript 283 | [ 284 | { 285 | "id": "6ad90229-df4f-4849-8974-5d675727c8b5", 286 | "layoutClassList": [] 287 | }, 288 | { 289 | "id": "aef616a5-769c-43e9-96d2-221edb986cbf", 290 | "layoutClassList": [] 291 | }, 292 | { 293 | "id": "db9f2372-7564-4b38-9bb2-d6ba4249fe63", 294 | "layoutClassList": ["focus"] 295 | } 296 | ] 297 | ``` 298 | 299 | This is passed in as the `classListArray` parameter of the `OpenTok.setStreamClassLists()` method 300 | of the OpenTok Node.js SDK: 301 | 302 | ```javascript 303 | opentok.setStreamClassLists(app.get('sessionId'), classListArray, function (err) { 304 | if (err) { 305 | return res.send(500, 'Could not set class lists. Error:' + err.message); 306 | } 307 | return res.send(200, 'OK'); 308 | }); 309 | ``` 310 | 311 | This sets one stream to have the `focus` class, which causes it to be the large stream 312 | displayed in the broadcast. (This is the behavior of the `horizontalPresentation` and 313 | `verticalPresentation` layout types.) To see this effect, you should open the host and participant 314 | pages on different computers (using different cameras). Or, if you have multiple cameras connected 315 | to your machine, you can use one camera for publishing from the host, and use another for the 316 | participant. Or, if you are using a laptop with an external monitor, you can load the host page 317 | with the laptop closed (no camera) and open the participant page with the laptop open. 318 | 319 | The host client page also uses OpenTok signaling to notify other clients when the layout type and 320 | focus stream changes, and they then update the local display of streams in the HTML DOM accordingly. 321 | However, this is not necessary. The layout of the broadcast is unrelated to the layout of 322 | streams in the web clients. 323 | 324 | When you view the broadcast stream, the layout type and focus stream changes, based on calls 325 | to the `OpenTok.setBroadcastLayout()` and `OpenTok.setStreamClassLists()` methods during 326 | the broadcast. 327 | 328 | For more information, see [Configuring video layout for OpenTok live streaming 329 | broadcasts](https://tokbox.com/developer/guides/broadcast/live-streaming/#configuring-video-layout-for-opentok-live-streaming-broadcasts). 330 | -------------------------------------------------------------------------------- /sample/Broadcast/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console, no-path-concat */ 2 | 3 | // Dependencies 4 | var express = require('express'); 5 | var bodyParser = require('body-parser'); 6 | var OpenTok = require('../../lib/opentok'); 7 | var app = express(); 8 | 9 | var opentok; 10 | var apiKey = process.env.API_KEY; 11 | var apiSecret = process.env.API_SECRET; 12 | 13 | // Verify that the API Key and API Secret are defined 14 | if (!apiKey || !apiSecret) { 15 | console.log('You must specify API_KEY and API_SECRET environment variables'); 16 | process.exit(1); 17 | } 18 | 19 | // Initialize the express app 20 | app.use(express.static(__dirname + '/public')); 21 | app.use(bodyParser.json()); // for parsing application/json 22 | app.use(bodyParser.urlencoded({ 23 | extended: true 24 | })); 25 | 26 | // Starts the express app 27 | function init() { 28 | app.listen(3000, function () { 29 | console.log('You\'re app is now ready at http://localhost:3000/'); 30 | }); 31 | } 32 | 33 | // Initialize OpenTok 34 | opentok = new OpenTok(apiKey, apiSecret); 35 | 36 | // Create a session and store it in the express app 37 | opentok.createSession({ mediaMode: 'routed' }, function (err, session) { 38 | if (err) throw err; 39 | app.set('sessionId', session.sessionId); 40 | app.set('layout', 'horizontalPresentation'); 41 | // We will wait on starting the app until this is done 42 | init(); 43 | }); 44 | 45 | app.get('/', function (req, res) { 46 | res.render('index.ejs'); 47 | }); 48 | 49 | app.get('/host', function (req, res) { 50 | var sessionId = app.get('sessionId'); 51 | // generate a fresh token for this client 52 | var token = opentok.generateToken(sessionId, { 53 | role: 'publisher', 54 | initialLayoutClassList: ['focus'] 55 | }); 56 | 57 | res.render('host.ejs', { 58 | apiKey: apiKey, 59 | sessionId: sessionId, 60 | token: token, 61 | initialBroadcastId: app.get('broadcastId'), 62 | focusStreamId: app.get('focusStreamId') || '', 63 | initialLayout: app.get('layout') 64 | }); 65 | }); 66 | 67 | app.get('/participant', function (req, res) { 68 | var sessionId = app.get('sessionId'); 69 | // generate a fresh token for this client 70 | var token = opentok.generateToken(sessionId, { role: 'publisher' }); 71 | 72 | res.render('participant.ejs', { 73 | apiKey: apiKey, 74 | sessionId: sessionId, 75 | token: token, 76 | focusStreamId: app.get('focusStreamId') || '', 77 | layout: app.get('layout') 78 | }); 79 | }); 80 | 81 | app.get('/broadcast', function (req, res) { 82 | var broadcastId = app.get('broadcastId'); 83 | if (!broadcastId) { 84 | return res.send(404, 'Broadcast not in progress.'); 85 | } 86 | return opentok.getBroadcast(broadcastId, function (err, broadcast) { 87 | if (err) { 88 | return res.send(500, 'Could not get broadcast ' + broadcastId + '. error=' + err.message); 89 | } 90 | if (broadcast.status === 'started') { 91 | return res.redirect(broadcast.broadcastUrls.hls); 92 | } 93 | return res.send(404, 'Broadcast not in progress.'); 94 | }); 95 | }); 96 | 97 | app.post('/start', function (req, res) { 98 | var broadcastOptions = { 99 | maxDuration: Number(req.param('maxDuration')) || undefined, 100 | resolution: req.param('resolution'), 101 | layout: req.param('layout'), 102 | outputs: { 103 | hls: {} 104 | } 105 | }; 106 | opentok.startBroadcast(app.get('sessionId'), broadcastOptions, function (err, broadcast) { 107 | if (err) { 108 | return res.send(500, err.message); 109 | } 110 | app.set('broadcastId', broadcast.id); 111 | return res.json(broadcast); 112 | }); 113 | }); 114 | 115 | app.get('/stop/:broadcastId', function (req, res) { 116 | var broadcastId = req.param('broadcastId'); 117 | opentok.stopBroadcast(broadcastId, function (err, broadcast) { 118 | if (err) { 119 | return res.send(500, 'Error = ' + err.message); 120 | } 121 | app.set('broadcastId', null); 122 | return res.json(broadcast); 123 | }); 124 | }); 125 | 126 | app.post('/broadcast/:broadcastId/layout', function (req, res) { 127 | var broadcastId = req.param('broadcastId'); 128 | var type = req.body.type; 129 | app.set('layout', type); 130 | if (broadcastId) { 131 | opentok.setBroadcastLayout(broadcastId, type, null, function (err) { 132 | if (err) { 133 | return res.send(500, 'Could not set layout ' + type + '. Error: ' + err.message); 134 | } 135 | return res.send(200, 'OK'); 136 | }); 137 | } 138 | }); 139 | 140 | app.post('/focus', function (req, res) { 141 | var otherStreams = req.body.otherStreams; 142 | var focusStreamId = req.body.focus; 143 | var classListArray = []; 144 | var i; 145 | 146 | if (otherStreams) { 147 | for (i = 0; i < otherStreams.length; i++) { 148 | classListArray.push({ 149 | id: otherStreams[i], 150 | layoutClassList: [] 151 | }); 152 | } 153 | } 154 | classListArray.push({ 155 | id: focusStreamId, 156 | layoutClassList: ['focus'] 157 | }); 158 | app.set('focusStreamId', focusStreamId); 159 | opentok.setStreamClassLists(app.get('sessionId'), classListArray, function (err) { 160 | if (err) { 161 | return res.send(500, 'Could not set class lists.' + err.message); 162 | } 163 | return res.send(200, 'OK'); 164 | }); 165 | }); 166 | -------------------------------------------------------------------------------- /sample/Broadcast/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "opentok-broadcast-sample", 3 | "version": "0.0.0", 4 | "description": "Demo of OpenTok API", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "body-parser": "^1.19.0", 13 | "ejs": "^2.5.5", 14 | "express": "^4.16.0" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /sample/Broadcast/public/css/sample.css: -------------------------------------------------------------------------------- 1 | /* Move down content because we have a fixed navbar that is 50px tall */ 2 | body { 3 | padding-top: 50px; 4 | padding-bottom: 20px; 5 | background-color: #F2F2F2; 6 | } 7 | 8 | /* Responsive: Portrait tablets and up */ 9 | @media screen and (min-width: 768px) { 10 | /* Remove padding from wrapping element since we kick in the grid classes here */ 11 | .body-content { 12 | padding: 0; 13 | } 14 | } 15 | 16 | #streams { 17 | background-color: gray; 18 | width: 320px; 19 | height: 180px; 20 | } 21 | 22 | #streams > div { 23 | width: 20%; 24 | height: 20%; 25 | float: left; 26 | position: relative; 27 | cursor: pointer; 28 | } 29 | 30 | #streams.vertical > div { 31 | left: 0px; 32 | clear: left; 33 | padding: 0px; 34 | } 35 | 36 | #streams .focus { 37 | position: relative; 38 | top: 0; 39 | left: 0; 40 | margin-top: 0; 41 | height: 100%; 42 | width: 100%; 43 | } 44 | 45 | #streams.vertical .focus { 46 | padding: 0; 47 | left: 0; 48 | margin: 0; 49 | left: 20%; 50 | height: 100%; 51 | width: 80%; 52 | } 53 | 54 | .stop { 55 | display: none; 56 | } 57 | 58 | .bump-me { 59 | padding-top: 40px; 60 | } 61 | 62 | .help-block { 63 | font-weight: bold; 64 | } 65 | -------------------------------------------------------------------------------- /sample/Broadcast/public/js/host.js: -------------------------------------------------------------------------------- 1 | /* global OT, apiKey, sessionId, initialBroadcastId, token, $, initialLayout, focusStreamId */ 2 | /* eslint-disable no-console */ 3 | 4 | var session = OT.initSession(apiKey, sessionId); 5 | var publisher = OT.initPublisher('publisher', { 6 | insertMode: 'append', 7 | width: '100%', 8 | height: '100%', 9 | resolution: '1280x720' 10 | }); 11 | var broadcastId = initialBroadcastId; 12 | var layout = initialLayout; 13 | 14 | function disableForm() { 15 | $('.broadcast-options-fields').attr('disabled', 'disabled'); 16 | $('.start').hide(); 17 | $('.stop').show(); 18 | } 19 | 20 | function enableForm() { 21 | $('.broadcast-options-fields').removeAttr('disabled'); 22 | $('.start').show(); 23 | $('.stop').hide(); 24 | } 25 | 26 | function positionStreams() { 27 | var $focusElement; 28 | $focusElement = $('.focus'); 29 | if ($('#streams').hasClass('vertical')) { 30 | $('#streams').children().css('top', '0'); 31 | $focusElement.appendTo('#streams'); 32 | $focusElement.css('top', (-20 * ($('#streams').children().size() - 1)) + '%'); 33 | } 34 | else { 35 | $focusElement.prependTo('#streams'); 36 | $focusElement.css('top', '0'); 37 | } 38 | } 39 | 40 | function setFocus(focusStreamId) { 41 | var $focusElement; 42 | var otherStreams = $.map($('#streams').children(), function (element) { 43 | var streamId = (element.id === 'publisher' && publisher.stream) ? publisher.stream.streamId 44 | : element.id; 45 | if (streamId !== focusStreamId) { 46 | $('#' + element.id).removeClass('focus'); 47 | return streamId; 48 | } 49 | return null; 50 | }); 51 | 52 | $.post('/focus', { 53 | focus: focusStreamId, 54 | otherStreams: otherStreams 55 | }).done(function () { 56 | console.log('Focus changed.'); 57 | }).fail(function (jqXHR) { 58 | console.error('Stream class list error:', jqXHR.responseText); 59 | }); 60 | 61 | $('.focus').removeClass('focus'); 62 | $focusElement = (publisher.stream && publisher.stream.streamId === focusStreamId) ? 63 | $('#publisher') : $('#' + focusStreamId); 64 | $focusElement.addClass('focus'); 65 | session.signal({ 66 | type: 'focusStream', 67 | data: focusStreamId 68 | }); 69 | positionStreams(); 70 | } 71 | 72 | function createFocusClick(elementId, focusStreamId) { 73 | $('#' + elementId).click(function () { 74 | setFocus(focusStreamId); 75 | }); 76 | } 77 | 78 | if (initialLayout === 'verticalPresentation') { 79 | $('#streams').addClass('vertical'); 80 | } 81 | 82 | if (initialLayout === 'verticalPresentation') { 83 | $('.start').hide(); 84 | $('.stop').show(); 85 | } 86 | 87 | session.connect(token, function (err) { 88 | if (err) { 89 | alert(err.message || err); // eslint-disable-line no-alert 90 | } 91 | session.publish(publisher); 92 | }); 93 | 94 | publisher.on('streamCreated', function () { 95 | createFocusClick(publisher.id, publisher.stream.streamId); 96 | positionStreams(); 97 | }); 98 | 99 | session.on('streamCreated', function (event) { 100 | var subscriber; 101 | var streamId = event.stream.streamId; 102 | var $streamContainer = $('
'); 103 | $streamContainer.attr('id', event.stream.id); 104 | $('#streams').append($streamContainer); 105 | subscriber = session.subscribe(event.stream, streamId, { 106 | insertMode: 'append', 107 | width: '100%', 108 | height: '100%' 109 | }); 110 | 111 | if (streamId === focusStreamId) { 112 | setFocus(streamId); 113 | } 114 | createFocusClick(subscriber.id, streamId); 115 | positionStreams(); 116 | }); 117 | 118 | session.on('streamDestroyed', function (event) { 119 | var $streamElem = $('#' + event.stream.id); 120 | if ($streamElem.hasClass('focus')) { 121 | setFocus(publisher.stream.streamId); 122 | } 123 | $streamElem.remove(); 124 | positionStreams(); 125 | }); 126 | 127 | $(document).ready(function () { 128 | $('.start').click(function () { 129 | var options = { 130 | maxDuration: $('input[name=maxDuration]').val() || undefined, 131 | resolution: $('input[name=resolution]:checked').val(), 132 | layout: { 133 | type: layout 134 | } 135 | }; 136 | disableForm(); 137 | $.post('/start', options) 138 | .done(function (response) { 139 | console.log('start success.'); 140 | broadcastId = response.id; 141 | }) 142 | .fail(function (jqXHR) { 143 | console.error(jqXHR.responseText); 144 | enableForm(); 145 | }); 146 | }).prop('disabled', false); 147 | $('.stop').click(function () { 148 | $.get('stop/' + broadcastId) 149 | .done(function () { 150 | console.log('stop success.'); 151 | broadcastId = null; 152 | enableForm(); 153 | }) 154 | .fail(function (jqXHR) { 155 | console.error(jqXHR.responseText); 156 | }); 157 | }); 158 | $('.toggle-layout').click(function () { 159 | if ($('#streams').hasClass('vertical')) { 160 | $('#streams').removeClass('vertical'); 161 | } 162 | else { 163 | $('#streams').addClass('vertical'); 164 | } 165 | 166 | positionStreams(); 167 | 168 | layout = $('#streams').hasClass('vertical') ? 'verticalPresentation' 169 | : 'horizontalPresentation'; 170 | 171 | $.post('broadcast/' + broadcastId + '/layout', { 172 | type: layout 173 | }).done(function () { 174 | console.log('Broadcast layout updated.'); 175 | }).fail(function (jqXHR) { 176 | console.error('Broadcast layout error:', jqXHR.responseText); 177 | }); 178 | 179 | session.signal({ 180 | type: 'layoutClass', 181 | data: layout 182 | }); 183 | }); 184 | }); 185 | -------------------------------------------------------------------------------- /sample/Broadcast/public/js/participant.js: -------------------------------------------------------------------------------- 1 | /* global OT, apiKey, sessionId, token, $, layout, focusStreamId */ 2 | var session = OT.initSession(apiKey, sessionId); 3 | var publisher; 4 | 5 | var container = $('
'); 6 | 7 | if (layout === 'verticalPresentation') { 8 | $('#streams').addClass('vertical'); 9 | } 10 | 11 | container.addClass('focus'); 12 | $('#streams').append(container); 13 | 14 | publisher = OT.initPublisher('publisher', { 15 | insertMode: 'append', 16 | width: '100%', 17 | height: '100%', 18 | resolution: '1280x720' 19 | }); 20 | 21 | function positionStreams() { 22 | var $focusElement = $('.focus'); 23 | if ($('#streams').hasClass('vertical')) { 24 | $focusElement.appendTo('#streams'); 25 | $('#streams').children().css('top', '0'); 26 | $focusElement.css('top', (-20 * ($('#streams').children().size() - 1)) + '%'); 27 | } 28 | else { 29 | $focusElement.prependTo('#streams'); 30 | $focusElement.css('top', '0'); 31 | } 32 | } 33 | 34 | function focusStream(streamId) { 35 | var focusStreamId = streamId; 36 | var $focusElement = (publisher.stream && publisher.stream.id === focusStreamId) ? $('#publisher') 37 | : $('#' + focusStreamId); 38 | $('.focus').removeClass('focus'); 39 | $focusElement.addClass('focus'); 40 | positionStreams(); 41 | } 42 | 43 | session.connect(token, function (err) { 44 | if (err) { 45 | alert(err.message || err); // eslint-disable-line no-alert 46 | } 47 | session.publish(publisher); 48 | }); 49 | 50 | session.on('streamCreated', function (event) { 51 | var streamId = event.stream.id; 52 | container = document.createElement('div'); 53 | container.id = streamId; 54 | $('#streams').append(container); 55 | session.subscribe(event.stream, streamId, { 56 | insertMode: 'append', 57 | width: '100%', 58 | height: '100%' 59 | }); 60 | if (streamId === focusStreamId) { 61 | focusStream(streamId); 62 | } 63 | positionStreams(); 64 | }); 65 | 66 | session.on('streamDestroyed', function (event) { 67 | $('#' + event.stream.id).remove(); 68 | positionStreams(); 69 | }); 70 | 71 | session.on('signal:layoutClass', function (event) { 72 | if (event.data === 'horizontalPresentation') { 73 | $('#streams').removeClass('vertical'); 74 | $('.focus').prependTo('#streams'); 75 | } 76 | else { 77 | $('#streams').addClass('vertical'); 78 | $('.focus').appendTo('#streams'); 79 | } 80 | positionStreams(); 81 | }); 82 | 83 | session.on('signal:focusStream', function (event) { 84 | focusStream(event.data); 85 | }); 86 | -------------------------------------------------------------------------------- /sample/Broadcast/views/footer.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /sample/Broadcast/views/header.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Broadcast Sample 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 24 | -------------------------------------------------------------------------------- /sample/Broadcast/views/host.ejs: -------------------------------------------------------------------------------- 1 | <% include header %> 2 | 3 | 4 | 5 |
6 | 7 |
8 | 9 |
10 |
11 |

Host

12 |
13 |
14 |
15 |
16 |
17 |
18 | 46 |
47 |
48 | 49 |
50 |
51 |

Instructions

52 |
53 |
54 |

55 | Click Start broadcast to start broadcasting this session. 56 | All publishers in the session will be included, and all publishers that 57 | join the session will be included as well. 58 |

59 |

60 | Click Stop broadcast to stop broadcasting this session. 61 |

62 |

63 | Click Toggle layout to toggle the layout 64 | between a vertical and horizontal presentation. The layout changes in all clients 65 | and in the broadcast. 66 |

67 | Click any stream to set it to be the focus stream in the broadcast layout. 68 |

69 |
70 |
71 | 72 | 80 | 81 | 82 | 83 | 84 | <% include footer %> 85 | -------------------------------------------------------------------------------- /sample/Broadcast/views/index.ejs: -------------------------------------------------------------------------------- 1 | <% include header %> 2 | 3 |
4 | 5 | 6 |
7 | 8 | 9 |
10 |
11 | 12 |
13 |
Create an archive
14 |
15 |

16 | Everyone who joins either the Host View or Participant View 17 | joins a single OpenTok session. The Host can click 18 | Start Broadcst and Stop Broadcst to control the live streaming 19 | broadcast of the entire session. 20 |

21 |
22 | 27 |
28 | 29 |
30 | 31 |
32 | 33 |
34 | 35 | <% include footer %> 36 | -------------------------------------------------------------------------------- /sample/Broadcast/views/participant.ejs: -------------------------------------------------------------------------------- 1 | <% include header %> 2 | 3 | 4 | 5 |
6 | 7 |
8 | 9 |
10 |
11 |

Participant

12 |
13 |
14 |
15 |
16 |
17 |
18 | 19 | 26 | 27 | 28 |
29 | 30 | <% include footer %> 31 | -------------------------------------------------------------------------------- /sample/HelloWorld/README.md: -------------------------------------------------------------------------------- 1 | # OpenTok Hello World Node 2 | 3 | This is a simple demo app that shows how you can use the OpenTok Node SDK to create Sessions, 4 | generate Tokens with those Sessions, and then pass these values to a JavaScript client that can 5 | connect and conduct a group chat. 6 | 7 | ## Running the App 8 | 9 | First, download the dependencies using [npm](https://www.npmjs.org) in this directory. 10 | 11 | ``` 12 | $ npm install 13 | ``` 14 | 15 | Next, add your own API Key and API Secret to the environment variables. There are a few ways to do 16 | this but the simplest would be to do it right in your shell. 17 | 18 | ``` 19 | $ export API_KEY=0000000 20 | $ export API_SECRET=abcdef1234567890abcdef01234567890abcdef 21 | ``` 22 | 23 | Finally, start the app using node 24 | 25 | ``` 26 | $ node index.js 27 | ``` 28 | 29 | Visit in your browser. Open it again in a second window. Smile! You've just 30 | set up a group chat. 31 | 32 | ## Walkthrough 33 | 34 | This demo application uses the [express web framework](http://expressjs.com/). It is a popular web 35 | framework and similar to others. Its concepts won't be explained but can be explored further at the 36 | website linked above. 37 | 38 | ### Server module (index.js) 39 | 40 | The first thing done in this file is to require the the dependencies. We now have the express 41 | framework, and most importantly the OpenTok SDK available. 42 | 43 | ```javascript 44 | // Dependencies 45 | var express = require('express'), 46 | OpenTok = require('../../lib/opentok'); 47 | ``` 48 | 49 | Next the app performs some basic checks on the environment and initializes the express application 50 | (`app`). 51 | 52 | The first thing that we do with OpenTok is to initialize an instance. The very next thing we do is 53 | create a Session. This application will be setting up a group chat, where anyone that loads the 54 | page will be be connected to the same Session. So before we get started we need one `sessionId`, 55 | and that will be used for every client that connects. In many applications, you would store this 56 | value in a database. Once the Session is created and there are no errors, we store that `sessionId` 57 | in our `app`. Then we call an `init` function when we know that the app is ready to start. 58 | 59 | ```javascript 60 | // Initialize OpenTok and store it in the express app 61 | var opentok = new OpenTok(apiKey, apiSecret); 62 | 63 | // Create a session and store it in the express app 64 | opentok.createSession(function(err, session) { 65 | if (err) throw err; 66 | app.set('sessionId', session.sessionId); 67 | // We will wait on starting the app until this is done 68 | init(); 69 | }); 70 | ``` 71 | 72 | Now we are ready to configure some routes. We only need one GET route for the root path because this 73 | application only has one page. Inside the route handler, we just need to get the three values a 74 | client will need to connect: `sessionId`, `apiKey`, and `token`. The `sessionId` is stored in the 75 | express app, so we get it from there. The `apiKey` is available from the outer scope. Last, we 76 | generate a fresh `token` for this client so that it has permission to connect. 77 | 78 | ```javascript 79 | app.get('/', function(req, res) { 80 | var sessionId = app.get('sessionId'), 81 | // generate a fresh token for this client 82 | token = opentok.generateToken(sessionId); 83 | 84 | // ... 85 | }); 86 | }); 87 | 88 | ``` 89 | 90 | To finish the response we load a template called `index.ejs` in the `views/` directory, and pass in the 91 | three values. 92 | 93 | ```php 94 | res.render('index.ejs', { 95 | apiKey: apiKey, 96 | sessionId: sessionId, 97 | token: token 98 | }); 99 | ``` 100 | 101 | Finally, we have the `init` function that we called from inside the `createSession` callback. To 102 | start the express app, it calls the `listen` method, and log that the app is ready. 103 | 104 | ```javascript 105 | // Start the express app 106 | function init() { 107 | app.listen(3000, function() { 108 | console.log('You\'re app is now ready at http://localhost:3000/'); 109 | }); 110 | } 111 | ``` 112 | 113 | ### Main Template (views/index.ejs) 114 | 115 | This file simply sets up the HTML page for the client JavaScript application to run, imports the 116 | JavaScript library, and passes the values created by the server into the client application 117 | `public/js/helloworld.js`. 118 | 119 | ### JavaScript Applicaton (public/js/helloworld.js) 120 | 121 | The group chat is mostly implemented in this file. At a high level, we connect to the given 122 | Session, publish a stream from our webcam, and listen for new streams from other clients to 123 | subscribe to. 124 | 125 | For more details, read the comments in the file or go to the 126 | [JavaScript Client Library](http://tokbox.com/opentok/libraries/client/js/) for a full reference. 127 | -------------------------------------------------------------------------------- /sample/HelloWorld/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console, no-path-concat */ 2 | 3 | // Dependencies 4 | var express = require('express'); 5 | var OpenTok = require('../../lib/opentok'); 6 | var app = express(); 7 | 8 | var opentok; 9 | var apiKey = process.env.API_KEY; 10 | var apiSecret = process.env.API_SECRET; 11 | 12 | // Verify that the API Key and API Secret are defined 13 | if (!apiKey || !apiSecret) { 14 | console.log('You must specify API_KEY and API_SECRET environment variables'); 15 | process.exit(1); 16 | } 17 | 18 | // Starts the express app 19 | function init() { 20 | app.listen(3000, function () { 21 | console.log('You\'re app is now ready at http://localhost:3000/'); 22 | }); 23 | } 24 | 25 | // Initialize the express app 26 | app.use(express.static(__dirname + '/public')); // 27 | 28 | // Initialize OpenTok 29 | opentok = new OpenTok(apiKey, apiSecret); 30 | 31 | // Create a session and store it in the express app 32 | opentok.createSession(function (err, session) { 33 | if (err) throw err; 34 | app.set('sessionId', session.sessionId); 35 | // We will wait on starting the app until this is done 36 | init(); 37 | }); 38 | 39 | app.get('/', function (req, res) { 40 | var sessionId = app.get('sessionId'); 41 | // generate a fresh token for this client 42 | var token = opentok.generateToken(sessionId); 43 | 44 | res.render('index.ejs', { 45 | apiKey: apiKey, 46 | sessionId: sessionId, 47 | token: token 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /sample/HelloWorld/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "opentok-helloworld-sample", 3 | "version": "0.0.0", 4 | "description": "Group video chat app to demonstrate the basic functionality of OpenTok", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "express": "^4.16.0", 13 | "ejs": "^2.5.5" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /sample/HelloWorld/public/js/helloworld.js: -------------------------------------------------------------------------------- 1 | /* global OT, apiKey, sessionId, token */ 2 | 3 | // Initialize an OpenTok Session object 4 | var session = OT.initSession(apiKey, sessionId); 5 | 6 | // Initialize a Publisher, and place it into the element with id="publisher" 7 | var publisher = OT.initPublisher('publisher'); 8 | 9 | // Attach event handlers 10 | session.on({ 11 | 12 | // This function runs when session.connect() asynchronously completes 13 | sessionConnected: function () { 14 | // Publish the publisher we initialzed earlier (this will trigger 'streamCreated' on other 15 | // clients) 16 | session.publish(publisher); 17 | }, 18 | 19 | // This function runs when another client publishes a stream (eg. session.publish()) 20 | streamCreated: function (event) { 21 | session.subscribe(event.stream, 'subscribers', { insertMode: 'append' }); 22 | } 23 | 24 | }); 25 | 26 | // Connect to the Session using the 'apiKey' of the application and a 'token' for permission 27 | session.connect(token); 28 | -------------------------------------------------------------------------------- /sample/HelloWorld/views/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | OpenTok Hello World 6 | 7 | 12 | 13 | 14 |

Hello, World!

15 | 16 |
17 | 18 |
19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /sample/SipInterconnect/Infographic.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opentok/opentok-node/778f82737dd56102e5c95b9a51c7c6d05b48e41d/sample/SipInterconnect/Infographic.jpg -------------------------------------------------------------------------------- /sample/SipInterconnect/README.md: -------------------------------------------------------------------------------- 1 | ![logo](./tokbox-logo.png) 2 | 3 | # OpenTok SIP Interconnect Sample App for JavaScript
Version 1.0 4 | 5 | This document describes how to use the OpenTok SIP Interconnect Sample App for JavaScript. Through the exploration of this sample application, you will learn best practices for adding audio from a SIP call directly into your website and mobile applications. 6 | 7 | The standard way to connect to the OpenTok platform is with the OpenTok client SDKs that use proprietary signaling interfaces provided by TokBox. However, it is sometimes useful to interconnect with other RTC platforms such as PSTN, IMS, PBX, and Call Centers. For such cases, TokBox exposes a SIP interface that enables access to existing 3rd party PSTN functionality. This interface enables users, who are connected via such 3rd party PSTN platforms, to participate in the audio portion of an OpenTok session. 8 | _OpenTok SIP Interconnect provides support to dial out from an OpenTok session to any SIP URI._ For example, your OpenTok SIP Interconnect application would make a SIP call to a customer contact center, which routes the call to an agent. The agent accepts the phone call and participates by voice in the session in order to speak with the customer. In addition, the agent could still optionally join with video via a laptop. To make such functionality available, the OpenTok cloud infrastructure includes signaling and media gateways, as well as REST APIs, to allow you to trigger the SIP calls from OpenTok sessions as needed for your business logic. 9 | 10 | The OpenTok SIP Interconnect Sample App allows you to start and end SIP calls, and its UI displays the participants using both WebRTC streams and SIP streams. WebRTC participants include video, while SIP participants are displayed with audio only. 11 | 12 | You can configure and run this sample app within just a few minutes! 13 | _**NOTE**: OpenTok SIP Interconnect supports only audio through the SIP interface, and does not currently support video. All the existing functionality of OpenTok, such as multiparty sessions and archiving, are compatible with OpenTok SIP Interconnect. OpenTok SIP Interconnect does not include any built-in PSTN functionality._ 14 | 15 | 16 | This guide has the following sections: 17 | 18 | * [Architecture](#architecture): This section describes how the OpenTok SIP Interconnect feature 19 | works with gateways for third-party SIP platforms. 20 | * [Prerequisites](#prerequisites): A checklist of everything you need to get started. 21 | * [Quick start](#quick-start): A step-by-step tutorial to help you quickly run the sample app. 22 | * [Exploring the code](#exploring-the-code): This describes the sample app code design, which uses recommended best practices to implement the OpenTok SIP Interconnect app features. 23 | 24 | 25 | ## Architecture 26 | 27 | The OpenTok SIP Interconnect Gateway exposes a SIP Interface and REST APIs that trigger and customize the calls via third-party SIP platforms. The OpenTok SIP Interconnect Gateway is composed of two parts: the SIP Interface and the OpenTok Interface: 28 | 29 | ![logo](./Infographic.jpg) 30 | 31 | This architecture enables a SIP participant to appear in an OpenTok session like any other client. All that is required to enable this behavior is to pass an OpenTok **Session ID** and **Token** when initiating the SIP call the makes use of the OpenTok Dial REST API. 32 | 33 | OpenTok identifies a SIP Gateway in the same region of the Media Server where the session is allocated. You can use the existing OpenTok capabilities if the default Media Server allocation is not ideal for your use (making use of the location hint when creating the session). 34 | The SIP Gateway terminates the SIP Dialogs and RTP flows. The protocols and capabilities supported in both interfaces are described in the next sections. 35 | The SIP Gateway is comprised of a session border controller (SBC), a Signaling Gateway, and a Media Mixer. The SBC component handles security as well as the protocol and codec adaptation. The Signaling Gateway converts the SIP primitives into the corresponding OpenTok primitives. The Media Mixer mixes the audio flows from multiple participants in OpenTok session in order to forward them as a single composite audio flow. 36 | 37 | _**NOTE**: If media inactivity lasts for more than 5 minutes, the call will be automatically closed unless session timers are used at the SIP level. As a security measure, any SIP call longer than 6 hours will be automatically closed by OpenTok._ 38 | 39 | 40 | ### Security considerations 41 | 42 | There are some best practices recommended by TokBox when using this new SIP Interface with 43 | your SIP Servers. They try to mitigate the possible attacks by providing the mechanisms to 44 | authenticate and authorize that the SIP calls received in your server are legitimate and to 45 | encrypt all the signaling and media: 46 | 47 | * Use TLS and enable secure calls (SRTP) for signaling to avoid the possibility of 48 | intercepting the communications. 49 | 50 | * Enable SIP authentication on your server. Otherwise, anyone who knows your SIP URI could 51 | send calls to your server. 52 | 53 | If required, you can also block the traffic not coming from the IP addresses of the OpenTok 54 | SIP gateway, which uses the following IP blocks: 55 | 56 | * 52.200.60.16 – 52.200.60.31 57 | * 52.41.63.240 – 52.41.63.255 58 | * 52.51.63.16 – 52.51.63.31 59 | 60 | Be aware that TokBox can add new IP blocks in the future, and you 61 | will need to change your configuration. 62 | 63 | ### Technical details 64 | 65 | **RFC3550 (RTP/RTCP) support:** Media traffic can be encrypted (SRTP) or non-­encrypted 66 | (plain RTP). In case of encryption, both DTLS and SDES protocols are supported. 67 | 68 | **Codec support:** The OpenTok SIP gateway supports the OPUS, G.711, and G.722 audio codecs. 69 | 70 | **Signaling:** The OpenTok SIP gateway supports RFC3561 (SIP) over UDP, TCP, and TLS. 71 | Contact TokBox if you need information or support for any specific extension. 72 | 73 | The OpenTok SIP gateway will not accept any SIP message coming from a third-party SIP 74 | platform unless it is part of a SIP dialog initiated by the OpenTok SIP gateway. 75 | Calls initiated with the OpenTok SIP gateway can be put on ­hold using either a `re-­INVITE` 76 | with the `sendonly/inactive` direction in the SDP or a `re-­INVITE` with port 0 in the SDP. 77 | 78 | **Other considerations:** Early media is disabled, and DTMFs are not currently supported. 79 | 80 | 81 | ### Additional Information 82 | 83 | For additional information see [SIP Interconnect](https://www.tokbox.com/developer/guides/sip/) at the TokBox Developer Center. 84 | 85 | 86 | 87 | ## Prerequisites 88 | 89 | To be prepared to develop your OpenTok SIP Interconnect app: 90 | 91 | 1. Review the [OpenTok.js](https://tokbox.com/developer/sdks/js/) requirements. 92 | 2. Your app will need **Token** and **API Key**, which you can get at the [OpenTok Developer Dashboard](https://dashboard.tokbox.com/). Set the API Key and API Secret in [config.js](./config.js). 93 | 94 | 95 | To install the OpenTok SIP Interconnect Sample App, run the following commands: 96 | 97 | ``` 98 | npm install 99 | npm start 100 | ``` 101 | 102 | 103 | _**IMPORTANT:** In order to deploy an OpenTok SPI Interconnect app, your web domain must use HTTPS._ 104 | 105 | 106 | ## Quick start 107 | 108 | The web page that loads the sample app for JavaScript must be served over HTTP/HTTPS. Browser security limitations prevent you from publishing video using a `file://` path, as discussed in the OpenTok.js [Release Notes](https://www.tokbox.com/developer/sdks/js/release-notes.html#knownIssues). To support clients running [Chrome 47 or later](https://groups.google.com/forum/#!topic/discuss-webrtc/sq5CVmY69sc), HTTPS is required. A web server such as [MAMP](https://www.mamp.info/) or [XAMPP](https://www.apachefriends.org/index.html) will work, or you can use a cloud service such as [Heroku](https://www.heroku.com/) to host the application. 109 | 110 | 111 | 112 | ## Exploring the code 113 | 114 | This section describes how the sample app code design uses recommended best practices to deploy the SIP Interconnect features. 115 | 116 | For detail about the APIs used to develop this sample, see the [OpenTok.js Reference](https://tokbox.com/developer/sdks/js/reference/). 117 | 118 | - [Web page design](#web-page-design) 119 | - [SIP Call](#sip-call) 120 | 121 | 122 | _**NOTE:** The sample app contains logic used for logging. This is used to submit anonymous usage data for internal TokBox purposes only. We request that you do not modify or remove any logging code in your use of this sample application._ 123 | 124 | ### Web page design 125 | 126 | While TokBox hosts [OpenTok.js](https://tokbox.com/developer/sdks/js/), you must host the sample app yourself. This allows you to customize the app as desired. For details about the one-to-one communication audio-video aspects of the design, see the [OpenTok One-to-One Communication Sample App](https://github.com/opentok/one-to-one-sample-apps/tree/master/one-to-one-sample-app/js) and [OpenTok Common Accelerator Session Pack](https://github.com/opentok/acc-pack-common/). 127 | 128 | * **[index.ejs](./views/index.ejs)**: This defines the container defining the publisher and layout for the sample app, including the buttons to start and end the SIP call. This file contains the business logic that initiates the OpenTok session and handles the SIP call click events. 129 | 130 | * **[config.js](./config.js)**: Configures the participant’s **Session ID**, **Token**, and **API Key** used to create the OpenTok session in [app.js](./app.js), and specifies the SIP headers and credentials required to start the SIP call. 131 | 132 | * **[app.js](./app.js)**: Creates the OpenTok session, serves the app, and listens for the POST request for starting the SIP call using the credentials specified in [config.js](./config.js). 133 | 134 | 135 | ### SIP Call 136 | 137 | A SIP call is made by clicking the **Start SIP Call** button, which results in a call to the `OpenTok.dial()` method. 138 | 139 | The `opentok.dial()` method in [app.js](./app.js) creates a REST request to OpenTok to start the SIP call. `opentok.dial()` takes the following arguments: 140 | 141 | - Session ID and session token 142 | - SIP URI 143 | - SIP username and password (optional) 144 | - A `secure` field indicating whether TLS encryption is applied (optional) 145 | - SIP headers to be added to the SIP INVITE request (optional) 146 | 147 | In this sample application, a SIP token is defined by part of its metadata, which may be be useful for embedding UI metadata for the SIP audio participant. The token is generated with the metadata `{data: "sip=true"}` as the OpenTok session is created: 148 | 149 | ```javascript 150 | opentok.createSession({ mediaMode:"routed" }, function(error, session) { 151 | if (error) { 152 | throw new Error("Error creating session:"+error); 153 | } else { 154 | sessionId = session.sessionId; 155 | 156 | // For web tokens, moderator role is used to force disconnect SIP calls. 157 | // For SIP tokens, an identifying SIP flag is embedded in the metadata. 158 | webrtcToken = opentok.generateToken(sessionId, {role: "moderator"}); 159 | sipToken = opentok.generateToken(sessionId, {data: "sip=true"}); 160 | } 161 | }); 162 | ``` 163 | 164 | This metadata can be viewed in the `event.stream.connection.data` property of [ConnectionEvents](https://www.tokbox.com/developer/sdks/js/reference/ConnectionEvent.html). 165 | 166 | **NOTE:** Such metadata is not actually necessary to start a SIP call, though it has advantages as shown in this sample app. It is also possible to simply call `OpenTok.dial(sessionID, token, sipUri, callback)` to start the SIP call.

167 | 168 | 169 | The `streamCreated` listener, as shown here in [index.ejs](./views/index.ejs), inspects this token metadata to determine if the new participant is joining via a SIP call: 170 | 171 | ```javascript 172 | var session = OT.initSession(sessionId); 173 | session.on("streamCreated", function (event) { 174 | var tokenData = event.stream.connection.data; 175 | if (tokenData && tokenData.includes("sip=true")) { 176 | var element = "sipPublisherContainer"; 177 | } else { 178 | var element = "webrtcPublisherContainer"; 179 | } 180 | 181 | . . . 182 | 183 | }); 184 | ``` 185 | 186 | Once the session has been established and the SIP token (`sipToken`) has been generated, the `OpenTok.dial()` method passes that information to the SIP call endpoint: 187 | 188 | ```javascript 189 | opentok.dial(sessionId, sipToken, config.sipUri, { 190 | auth: { 191 | username: config.sipUsername, 192 | password: config.sipPassword 193 | } 194 | }, function (err, sipCall) { 195 | console.error(err); 196 | if (err) return res.status(500).send('Platform error starting SIP Call:'+err); 197 | res.send(sipCall); 198 | }); 199 | }); 200 | ``` 201 | 202 | -------------------------------------------------------------------------------- /sample/SipInterconnect/app.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | var express = require('express'); 4 | var path = require('path'); 5 | var bodyParser = require('body-parser'); 6 | var config = require('./config'); 7 | var OpenTok = require('../../lib/opentok'); 8 | 9 | var app = express(); 10 | var opentok = new OpenTok(config.apiKey, config.apiSecret); 11 | var sessionId; 12 | var webrtcToken; 13 | var sipToken; 14 | var port = process.env.PORT || 3000; 15 | 16 | // view engine setup 17 | app.set('views', path.join(__dirname, 'views')); 18 | app.set('view engine', 'ejs'); 19 | app.use(bodyParser.json()); 20 | app.use(bodyParser.urlencoded({ extended: false })); 21 | app.use(express.static(path.join(__dirname, 'public'))); 22 | 23 | if (!config.apiKey || !config.apiSecret) { 24 | throw new Error('API_KEY or API_SECRET must be defined as an environment variable'); 25 | } 26 | 27 | opentok.createSession({ mediaMode: 'routed' }, function (error, session) { 28 | if (error) { 29 | throw new Error('Error creating session:' + error); 30 | } 31 | else { 32 | sessionId = session.sessionId; 33 | 34 | // For web tokens, moderator role is used to force disconnect SIP calls. 35 | // For SIP tokens, an identifying SIP flag is embedded in the metadata. 36 | webrtcToken = opentok.generateToken(sessionId, { role: 'moderator' }); 37 | sipToken = opentok.generateToken(sessionId, { data: 'sip=true' }); 38 | } 39 | }); 40 | 41 | /* GET home page. */ 42 | app.get('/', function (req, res) { 43 | res.render('index', { 44 | sessionId: sessionId, 45 | token: webrtcToken, 46 | apiKey: config.apiKey 47 | }); 48 | }); 49 | 50 | /* POST to start SIP call. */ 51 | app.post('/sip/start', function (req, res) { 52 | opentok.dial(req.body.sessionId, sipToken, config.sipUri, { 53 | auth: { 54 | username: config.sipUsername, 55 | password: config.sipPassword 56 | }, 57 | headers: config.sipHeaders 58 | }, function (err, sipCall) { 59 | if (err) { 60 | console.error(err); 61 | return res.status(500).send('Platform error starting SIP Call:' + err); 62 | } 63 | console.dir(sipCall); 64 | return res.send(sipCall); 65 | }); 66 | }); 67 | 68 | app.listen(port); 69 | console.log('Sample app is listening on port ' + port); 70 | -------------------------------------------------------------------------------- /sample/SipInterconnect/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apiKey: process.env.API_KEY, 3 | apiSecret: process.env.API_SECRET, 4 | sipUri: process.env.SIP_URI, 5 | sipHeaders: process.env.SIP_HEADERS ? JSON.parse(process.env.SIP_HEADERS) : null, 6 | sipUsername: process.env.SIP_USERNAME, 7 | sipPassword: process.env.SIP_PASSWORD 8 | }; 9 | -------------------------------------------------------------------------------- /sample/SipInterconnect/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "opentok-sip-sample", 3 | "version": "0.0.0", 4 | "private": false, 5 | "scripts": { 6 | "start": "node app.js" 7 | }, 8 | "dependencies": { 9 | "body-parser": "~1.19.0", 10 | "ejs": "^2.5.5", 11 | "express": "~4.19.0", 12 | "opentok": "^2.3.2", 13 | "request": "^2.73.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /sample/SipInterconnect/public/stylesheets/style.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | background-color: #efefef; 3 | } 4 | 5 | #main-nav .bottom { 6 | background-color: #303030 7 | height: 59px; 8 | } 9 | 10 | #main-nav .bottom .logo img { 11 | width: 113px; 12 | height: 30px; 13 | margin-top: 16px; 14 | } 15 | 16 | .main-header { 17 | display: inline-block; 18 | position: relative; 19 | width: 100%; 20 | padding: 17px 208px 0px 208px; 21 | box-sizing: border-box; 22 | } 23 | 24 | @media (max-width: 1024px) { 25 | .main-header { 26 | padding-left: 0; 27 | padding-right: 0; 28 | } 29 | } 30 | 31 | @media (max-width: 1128px) { 32 | .main-header { 33 | padding-left: 52px; 34 | padding-right: 52px; 35 | } 36 | } 37 | 38 | @media (max-width: 1232px) { 39 | .main-header { 40 | padding-left: 104px; 41 | padding-right: 104px; 42 | } 43 | } 44 | 45 | .main-header > header { 46 | margin: 0 auto; 47 | margin-bottom: 24px; 48 | border-radius: 8px; 49 | background: linear-gradient(to bottom, rgba(2,167,211,1) 0%, rgba(5,140,197,1) 100%); 50 | border: 1px solid #008ac4; 51 | max-width: 1024px; 52 | padding: 21px 29px; 53 | box-sizing: border-box; 54 | } 55 | 56 | .main-header > header h1 { 57 | font-family: Muli; 58 | font-weight: 500; 59 | font-size: 32px; 60 | line-height: 41px; 61 | margin: 0 0 9px 0; 62 | color: #FFF; 63 | } 64 | 65 | .main-header > header h1 sup { 66 | font-size: 14px; 67 | color: #9ce6ff; 68 | padding-left: 10px; 69 | vertical-align: top; 70 | } 71 | 72 | .main-header > header hr { 73 | opacity: 0.3; 74 | border: 0; 75 | border-bottom-width: 1px; 76 | border-bottom-color: #ffffff; 77 | border-bottom-style: solid; 78 | width: 100%; 79 | margin-top: 18px; 80 | margin-bottom: 16px; 81 | } 82 | 83 | 84 | .main-header > header h3 { 85 | font-family: Muli; 86 | font-weight: 400; 87 | font-size: 14px; 88 | line-height: 17px; 89 | letter-spacing: 0.3px; 90 | margin: 0; 91 | color: #82d0e8; 92 | } 93 | 94 | .main-header > section { 95 | max-width: 100%; 96 | padding: 21px 29px; 97 | box-sizing: border-box; 98 | } 99 | 100 | .main-container { 101 | display: block; 102 | padding: 21px 29px; 103 | box-sizing: border-box; 104 | position: relative; 105 | } 106 | 107 | #sip-controls { 108 | margin: 0 auto 20px auto; 109 | text-align: center; 110 | } 111 | 112 | #sipPublisherContainer, #webrtcPublisherContainer { 113 | height: 240px; 114 | background-color: #DDD; 115 | position: relative; 116 | margin: 0px 20px 0px 20px; 117 | border-radius: 8px; 118 | } 119 | 120 | #sipPublisherContainer > *, #webrtcPublisherContainer > * { 121 | transition-property: all; 122 | transition-duration: 0.5s; 123 | display: inline-block; 124 | } 125 | 126 | .streams h3 { 127 | margin-left: 20px; 128 | } 129 | 130 | #selfPublisherContainer { 131 | position: absolute; 132 | top: 0px; 133 | right: 0px; 134 | } 135 | 136 | #selfPublisherContainer h3 { 137 | margin-right: 20px; 138 | } 139 | -------------------------------------------------------------------------------- /sample/SipInterconnect/tokbox-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/opentok/opentok-node/778f82737dd56102e5c95b9a51c7c6d05b48e41d/sample/SipInterconnect/tokbox-logo.png -------------------------------------------------------------------------------- /sample/SipInterconnect/views/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | OpenTok SIP Interconnect Sample App 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 |

SIP Interconnect

15 |

Test OpenTok's SIP Interconnect API

16 |
17 | 18 |
19 |

Your Publisher

20 |
21 | 22 |
23 | Start SIP Call 24 | End SIP Calls 25 |
26 |
27 | 28 |
29 |

WebRTC Streams

30 |
31 |
32 |

SIP Streams

33 |
34 |
35 | 36 | 37 | 38 | 78 | 79 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "func-names": 0, 4 | "no-unused-expressions": 0 5 | }, 6 | "env": { 7 | "mocha": true 8 | } 9 | } -------------------------------------------------------------------------------- /test/callbacks-test.js: -------------------------------------------------------------------------------- 1 | var expect = require('chai').expect; 2 | var nock = require('nock'); 3 | 4 | // Subject 5 | var OpenTok = require('../lib/opentok.js'); 6 | 7 | describe('Callbacks', function () { 8 | var opentok = new OpenTok('APIKEY', 'APISECRET'); 9 | var CALLBACK_ID = 'ID'; 10 | var GROUP = 'connection'; 11 | var EVENT = 'created'; 12 | var URL = 'https://'; 13 | var CREATED_AT = 1391149936527; 14 | var CALLBACK = { 15 | id: CALLBACK_ID, group: GROUP, event: EVENT, url: URL, createdAt: CREATED_AT 16 | }; 17 | 18 | afterEach(function () { 19 | nock.cleanAll(); 20 | }); 21 | 22 | describe('registerCallback', function () { 23 | function mockRequest(status, body) { 24 | nock('https://api.opentok.com') 25 | .post('/v2/project/APIKEY/callback', { group: GROUP, event: EVENT, url: URL }) 26 | .reply(status, body, { 27 | server: 'nginx', 28 | date: 'Fri, 31 Jan 2014 06:32:16 GMT', 29 | 'content-type': 'application/json', 30 | 'transfer-encoding': 'chunked', 31 | connection: 'keep-alive' 32 | }); 33 | } 34 | 35 | describe('valid responses', function () { 36 | beforeEach(function () { 37 | mockRequest(200, JSON.stringify(CALLBACK)); 38 | }); 39 | 40 | it('should return a Callback', function (done) { 41 | opentok.registerCallback( 42 | { group: GROUP, event: EVENT, url: URL }, 43 | function (err, callback) { 44 | expect(err).to.be.null; 45 | expect(callback).to.not.be.null; 46 | if (callback) { 47 | expect(callback.id).to.equal(CALLBACK_ID); 48 | expect(callback.group).to.equal(GROUP); 49 | expect(callback.event).to.equal(EVENT); 50 | expect(callback.createdAt).to.equal(CREATED_AT); 51 | expect(callback.url).to.equal(URL); 52 | } 53 | done(); 54 | } 55 | ); 56 | }); 57 | 58 | it('should fail for missing group', function (done) { 59 | opentok.registerCallback({ event: EVENT, url: URL }, function (err) { 60 | expect(err).to.not.be.null; 61 | done(); 62 | }); 63 | }); 64 | 65 | it('should fail for missing event', function (done) { 66 | opentok.registerCallback({ group: GROUP, url: URL }, function (err) { 67 | expect(err).to.not.be.null; 68 | done(); 69 | }); 70 | }); 71 | 72 | it('should fail for missing url', function (done) { 73 | opentok.registerCallback({ group: GROUP, event: EVENT }, function (err) { 74 | expect(err).to.not.be.null; 75 | done(); 76 | }); 77 | }); 78 | }); 79 | 80 | describe('invalid responses', function () { 81 | var errors = [400, 403, 500]; 82 | var i; 83 | function test(error) { 84 | it('should fail for status ' + error, function (done) { 85 | mockRequest(error, ''); 86 | 87 | opentok.registerCallback({ group: GROUP, event: EVENT, url: URL }, function (err) { 88 | expect(err).to.not.be.null; 89 | done(); 90 | }); 91 | }); 92 | } 93 | for (i = 0; i < errors.length; i++) { 94 | test(errors[i]); 95 | } 96 | }); 97 | }); 98 | 99 | describe('unregisterCallback', function () { 100 | function mockRequest(status, body) { 101 | nock('https://api.opentok.com') 102 | .delete('/v2/project/APIKEY/callback/' + CALLBACK_ID) 103 | .reply(status, body, { 104 | server: 'nginx', 105 | date: 'Fri, 31 Jan 2014 06:32:16 GMT', 106 | 'content-type': 'application/json', 107 | 'transfer-encoding': 'chunked', 108 | connection: 'keep-alive' 109 | }); 110 | } 111 | 112 | describe('valid responses', function () { 113 | beforeEach(function () { 114 | mockRequest(204, ''); 115 | }); 116 | 117 | it('should not return an error', function (done) { 118 | opentok.unregisterCallback(CALLBACK_ID, function (err) { 119 | expect(err).to.be.null; 120 | done(); 121 | }); 122 | }); 123 | 124 | it('should return an error for missing id', function (done) { 125 | opentok.unregisterCallback(null, function (err) { 126 | expect(err).to.not.be.null; 127 | done(); 128 | }); 129 | }); 130 | }); 131 | 132 | describe('invalid responses', function () { 133 | var errors = [400, 403, 500]; 134 | var i; 135 | function test(error) { 136 | it('should fail for status ' + error, function (done) { 137 | mockRequest(error, ''); 138 | 139 | opentok.registerCallback( 140 | { group: GROUP, event: EVENT, url: URL }, 141 | function (err) { 142 | expect(err).to.not.be.null; 143 | done(); 144 | } 145 | ); 146 | }); 147 | } 148 | for (i = 0; i < errors.length; i++) { 149 | test(errors[i]); 150 | } 151 | }); 152 | }); 153 | 154 | describe('listCallbacks', function () { 155 | function mockRequest(status, body) { 156 | nock('https://api.opentok.com') 157 | .get('/v2/project/APIKEY/callback') 158 | .reply(status, body, { 159 | server: 'nginx', 160 | date: 'Fri, 31 Jan 2014 06:32:16 GMT', 161 | 'content-type': 'application/json', 162 | 'transfer-encoding': 'chunked', 163 | connection: 'keep-alive' 164 | }); 165 | } 166 | 167 | describe('valid responses', function () { 168 | it('should return a callback list', function (done) { 169 | mockRequest(200, JSON.stringify([CALLBACK, CALLBACK])); 170 | 171 | opentok.listCallbacks(function (err, callbacks) { 172 | expect(err).to.be.null; 173 | expect(callbacks).to.not.be.null; 174 | if (callbacks) { 175 | expect(callbacks.length).to.equal(2); 176 | } 177 | done(); 178 | }); 179 | }); 180 | }); 181 | 182 | describe('invalid responses', function () { 183 | var errors = [400, 403, 500]; 184 | var i; 185 | function test(error) { 186 | it('should fail for status ' + error, function (done) { 187 | mockRequest(error, ''); 188 | 189 | opentok.registerCallback({ group: GROUP, event: EVENT, url: URL }, function (err) { 190 | expect(err).to.not.be.null; 191 | done(); 192 | }); 193 | }); 194 | } 195 | for (i = 0; i < errors.length; i++) { 196 | test(errors[i]); 197 | } 198 | }); 199 | }); 200 | }); 201 | -------------------------------------------------------------------------------- /test/captions-test.js: -------------------------------------------------------------------------------- 1 | var expect = require('chai').expect; 2 | var nock = require('nock'); 3 | var OpenTok = require('../lib/opentok.js'); 4 | 5 | describe('Captions', () => { 6 | const opentok = new OpenTok('123456', 'APISECRET'); 7 | const sessionId = '1_MX4xMDB-MTI3LjAuMC4xflR1ZSBKYW4gMjggMTU6NDg6NDAgUFNUIDIwMTR-MC43NjAyOTYyfg'; 8 | const token = 'my awesome token'; 9 | const testCaptionId = 'my-caption-id'; 10 | 11 | afterEach( () => { 12 | nock.cleanAll(); 13 | }); 14 | 15 | describe('start Captions', () => { 16 | afterEach( () => { 17 | nock.cleanAll(); 18 | }); 19 | 20 | it('Starts Captions', (done) => { 21 | nock('https://api.opentok.com') 22 | .post( 23 | '/v2/project/123456/captions', 24 | { 25 | "sessionId": sessionId, 26 | "token": token, 27 | "languageCode": "en-US", 28 | "maxDuration": 14400, 29 | "partialCaptions": true, 30 | }, 31 | ) 32 | .reply( 33 | 200, 34 | {captionsId: testCaptionId}, 35 | { 'Content-Type': 'application/json' } 36 | ); 37 | 38 | opentok.startCaptions(sessionId, token, {}, (err, captionId) => { 39 | expect(err).to.be.null; 40 | expect(captionId).to.equal(testCaptionId) 41 | done(); 42 | }); 43 | }); 44 | 45 | it('Starts Captions with options', (done) => { 46 | nock('https://api.opentok.com') 47 | .post( 48 | '/v2/project/123456/captions', 49 | { 50 | "sessionId": sessionId, 51 | "token": token, 52 | "languageCode": "hi-IN", 53 | "maxDuration": 42, 54 | "partialCaptions": false, 55 | }, 56 | ) 57 | .reply( 58 | 200, 59 | {captionsId: testCaptionId}, 60 | { 'Content-Type': 'application/json' } 61 | ); 62 | 63 | opentok.startCaptions( 64 | sessionId, 65 | token, 66 | { 67 | languageCode: "hi-IN", 68 | maxDuration: 42, 69 | partialCaptions: false, 70 | }, 71 | (err, captionId) => { 72 | expect(err).to.be.null; 73 | expect(captionId).to.equal(testCaptionId) 74 | done(); 75 | }); 76 | }); 77 | 78 | it('Fails to Start Captions with invalid data', (done) => { 79 | nock('https://api.opentok.com') 80 | .post( 81 | '/v2/project/123456/captions', 82 | { 83 | "sessionId": sessionId, 84 | "token": token, 85 | "languageCode": "en-US", 86 | "maxDuration": 14400, 87 | "partialCaptions": true, 88 | }, 89 | ) 90 | .reply( 91 | 400 92 | ); 93 | 94 | opentok.startCaptions( 95 | sessionId, 96 | token, 97 | {}, 98 | (err, captionId) => { 99 | expect(err).not.to.be.null; 100 | expect(captionId).to.be.undefined; 101 | done(); 102 | }); 103 | }); 104 | 105 | 106 | it('Fails to Start Captions when captions have started', (done) => { 107 | nock('https://api.opentok.com') 108 | .post( 109 | '/v2/project/123456/captions', 110 | { 111 | "sessionId": sessionId, 112 | "token": token, 113 | "languageCode": "en-US", 114 | "maxDuration": 14400, 115 | "partialCaptions": true, 116 | }, 117 | ) 118 | .reply( 119 | 409 120 | ); 121 | 122 | opentok.startCaptions( 123 | sessionId, 124 | token, 125 | {}, 126 | (err, captionId) => { 127 | expect(err).not.to.be.null; 128 | expect(err.message).to.equal('Live captions have already started for this OpenTok Session'); 129 | expect(captionId).to.be.undefined; 130 | done(); 131 | }); 132 | }); 133 | 134 | it('Stops Captions', (done) => { 135 | nock('https://api.opentok.com') 136 | .post( 137 | `/v2/project/123456/captions/${testCaptionId}/stop`, 138 | ) 139 | .reply( 140 | 202 141 | ); 142 | 143 | opentok.stopCaptions(testCaptionId, (err, status) => { 144 | expect(err).to.be.null; 145 | expect(status).to.be.true; 146 | done(); 147 | }); 148 | }); 149 | 150 | it('Fails to Stop Captions with invalid id', (done) => { 151 | nock('https://api.opentok.com') 152 | .post( 153 | `/v2/project/123456/captions/${testCaptionId}/stop`, 154 | ) 155 | .reply( 156 | 404 157 | ); 158 | 159 | opentok.stopCaptions(testCaptionId, (err, status) => { 160 | expect(err).not.to.be.null; 161 | expect(err.message).contain('Not Found'); 162 | expect(status).to.be.undefined; 163 | done(); 164 | }); 165 | }); 166 | }); 167 | }); 168 | describe('Captions', () => { 169 | const opentok = new OpenTok('123456', 'APISECRET'); 170 | const sessionId = '1_MX4xMDB-MTI3LjAuMC4xflR1ZSBKYW4gMjggMTU6NDg6NDAgUFNUIDIwMTR-MC43NjAyOTYyfg'; 171 | const token = 'my awesome token'; 172 | const testCaptionId = 'my-caption-id'; 173 | 174 | afterEach( () => { 175 | nock.cleanAll(); 176 | }); 177 | 178 | describe('start Captions', () => { 179 | afterEach( () => { 180 | nock.cleanAll(); 181 | }); 182 | 183 | it('Starts Captions', (done) => { 184 | nock('https://api.opentok.com') 185 | .post( 186 | '/v2/project/123456/captions', 187 | { 188 | "sessionId": sessionId, 189 | "token": token, 190 | "languageCode": "en-US", 191 | "maxDuration": 14400, 192 | "partialCaptions": true, 193 | }, 194 | ) 195 | .reply( 196 | 200, 197 | {captionsId: testCaptionId}, 198 | { 'Content-Type': 'application/json' } 199 | ); 200 | 201 | opentok.startCaptions(sessionId, token, {}, (err, captionId) => { 202 | expect(err).to.be.null; 203 | expect(captionId).to.equal(testCaptionId) 204 | done(); 205 | }); 206 | }); 207 | 208 | it('Starts Captions with options', (done) => { 209 | nock('https://api.opentok.com') 210 | .post( 211 | '/v2/project/123456/captions', 212 | { 213 | "sessionId": sessionId, 214 | "token": token, 215 | "languageCode": "hi-IN", 216 | "maxDuration": 42, 217 | "partialCaptions": false, 218 | }, 219 | ) 220 | .reply( 221 | 200, 222 | {captionsId: testCaptionId}, 223 | { 'Content-Type': 'application/json' } 224 | ); 225 | 226 | opentok.startCaptions( 227 | sessionId, 228 | token, 229 | { 230 | languageCode: "hi-IN", 231 | maxDuration: 42, 232 | partialCaptions: false, 233 | }, 234 | (err, captionId) => { 235 | expect(err).to.be.null; 236 | expect(captionId).to.equal(testCaptionId) 237 | done(); 238 | }); 239 | }); 240 | 241 | it('Fails to Start Captions with invalid data', (done) => { 242 | nock('https://api.opentok.com') 243 | .post( 244 | '/v2/project/123456/captions', 245 | { 246 | "sessionId": sessionId, 247 | "token": token, 248 | "languageCode": "en-US", 249 | "maxDuration": 14400, 250 | "partialCaptions": true, 251 | }, 252 | ) 253 | .reply( 254 | 400 255 | ); 256 | 257 | opentok.startCaptions( 258 | sessionId, 259 | token, 260 | {}, 261 | (err, captionId) => { 262 | expect(err).not.to.be.null; 263 | expect(captionId).to.be.undefined; 264 | done(); 265 | }); 266 | }); 267 | 268 | 269 | it('Fails to Start Captions when captions have started', (done) => { 270 | nock('https://api.opentok.com') 271 | .post( 272 | '/v2/project/123456/captions', 273 | { 274 | "sessionId": sessionId, 275 | "token": token, 276 | "languageCode": "en-US", 277 | "maxDuration": 14400, 278 | "partialCaptions": true, 279 | }, 280 | ) 281 | .reply( 282 | 409 283 | ); 284 | 285 | opentok.startCaptions( 286 | sessionId, 287 | token, 288 | {}, 289 | (err, captionId) => { 290 | expect(err).not.to.be.null; 291 | expect(err.message).to.equal('Live captions have already started for this OpenTok Session'); 292 | expect(captionId).to.be.undefined; 293 | done(); 294 | }); 295 | }); 296 | 297 | it('Stops Captions', (done) => { 298 | nock('https://api.opentok.com') 299 | .post( 300 | `/v2/project/123456/captions/${testCaptionId}/stop`, 301 | ) 302 | .reply( 303 | 202 304 | ); 305 | 306 | opentok.stopCaptions(testCaptionId, (err, status) => { 307 | expect(err).to.be.null; 308 | expect(status).to.be.true; 309 | done(); 310 | }); 311 | }); 312 | 313 | it('Fails to Stop Captions with invalid id', (done) => { 314 | nock('https://api.opentok.com') 315 | .post( 316 | `/v2/project/123456/captions/${testCaptionId}/stop`, 317 | ) 318 | .reply( 319 | 404 320 | ); 321 | 322 | opentok.stopCaptions(testCaptionId, (err, status) => { 323 | expect(err).not.to.be.null; 324 | expect(err.message).contain('Not Found'); 325 | expect(status).to.be.undefined; 326 | done(); 327 | }); 328 | }); 329 | }); 330 | }); 331 | -------------------------------------------------------------------------------- /test/helpers.js: -------------------------------------------------------------------------------- 1 | // Test Helpers 2 | var qs = require('querystring'); 3 | var crypto = require('crypto'); 4 | var _ = require('lodash'); 5 | 6 | function signString(unsigned, key) { 7 | var hmac = crypto.createHmac('sha1', key); 8 | hmac.update(unsigned); 9 | return hmac.digest('hex'); 10 | } 11 | 12 | exports.decodeToken = function (token) { 13 | var parsed = {}; 14 | var encoded = token.substring(4); // remove 'T1==' 15 | var decoded = Buffer.from(encoded, 'base64').toString('ascii'); 16 | var tokenParts = decoded.split(':'); 17 | tokenParts.forEach(function (part) { 18 | _.merge(parsed, qs.parse(part)); 19 | }); 20 | return parsed; 21 | }; 22 | 23 | exports.verifyTokenSignature = function (token, apiSecret) { 24 | var encoded = token.substring(4); // remove 'T1==' 25 | var decoded = Buffer.from(encoded, 'base64').toString('ascii'); 26 | var tokenParts = decoded.split(':'); 27 | var sig = qs.parse(tokenParts[0]).sig; 28 | return signString(tokenParts[1], apiSecret) === sig; 29 | }; 30 | -------------------------------------------------------------------------------- /test/moderation-test.js: -------------------------------------------------------------------------------- 1 | var expect = require('chai').expect; 2 | var nock = require('nock'); 3 | 4 | // Subject 5 | var OpenTok = require('../lib/opentok.js'); 6 | 7 | describe('Moderation', function () { 8 | var opentok = new OpenTok('APIKEY', 'APISECRET'); 9 | var SESSIONID = '1_MX4xMDB-MTI3LjAuMC4xflR1ZSBKYW4gMjggMTU6NDg6NDAgUFNUIDIwMTR-MC43NjAyOTYyfg'; 10 | var CONNECTIONID = '4072fe0f-d499-4f2f-8237-64f5a9d936f5'; 11 | 12 | afterEach(function () { 13 | nock.cleanAll(); 14 | }); 15 | 16 | describe('forceDisconnect', function () { 17 | function mockRequest(status, body) { 18 | var url = '/v2/project/APIKEY/session/' + SESSIONID + '/connection/' + CONNECTIONID; 19 | nock('https://api.opentok.com:443') 20 | .delete(url) 21 | .reply(status, body, { 22 | server: 'nginx', 23 | date: 'Fri, 31 Jan 2014 06:32:16 GMT', 24 | connection: 'keep-alive' 25 | }); 26 | } 27 | 28 | describe('valid responses', function () { 29 | beforeEach(function () { 30 | mockRequest(204, ''); 31 | }); 32 | 33 | it('should not return an error', function (done) { 34 | opentok.forceDisconnect(SESSIONID, CONNECTIONID, function (err) { 35 | expect(err).to.be.null; 36 | done(); 37 | }); 38 | }); 39 | 40 | it('should return an error for empty connection', function (done) { 41 | opentok.forceDisconnect(SESSIONID, null, function (err) { 42 | expect(err).to.not.be.null; 43 | done(); 44 | }); 45 | }); 46 | 47 | it('should return an error for empty session', function (done) { 48 | opentok.forceDisconnect(null, CONNECTIONID, function (err) { 49 | expect(err).to.not.be.null; 50 | done(); 51 | }); 52 | }); 53 | }); 54 | 55 | describe('invalid responses', function () { 56 | var errors = [400, 403, 404, 500]; 57 | var i; 58 | function test(error) { 59 | it('should fail for status ' + error, function (done) { 60 | mockRequest(error, ''); 61 | 62 | opentok.forceDisconnect(SESSIONID, CONNECTIONID, function (err) { 63 | expect(err).to.not.be.null; 64 | done(); 65 | }); 66 | }); 67 | } 68 | for (i = 0; i < errors.length; i++) { 69 | test(errors[i]); 70 | } 71 | }); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /test/session-test.js: -------------------------------------------------------------------------------- 1 | var expect = require('chai').expect; 2 | 3 | // Subject 4 | var Session = require('../lib/session.js'); 5 | var OpenTok = require('../lib/opentok.js'); 6 | 7 | // Fixtures 8 | var apiKey = '123456'; 9 | var apiSecret = '1234567890abcdef1234567890abcdef1234567890'; 10 | // This is specifically concocted for these tests (uses fake apiKey/apiSecret above) 11 | var sessionId = '1_MX4xMjM0NTZ-flNhdCBNYXIgMTUgMTQ6NDI6MjMgUERUIDIwMTR-MC40OTAxMzAyNX4'; 12 | 13 | describe('Session', function () { 14 | beforeEach(function () { 15 | this.opentok = new OpenTok(apiKey, apiSecret); 16 | }); 17 | 18 | it('initializes with no options', function () { 19 | var session = new Session(this.opentok, sessionId); 20 | expect(session).to.be.an.instanceof(Session); 21 | expect(session.sessionId).to.equal(sessionId); 22 | }); 23 | 24 | describe('when initialized with a media mode', function () { 25 | it('has a mediaMode property', function () { 26 | var session = new Session(this.opentok, sessionId, { mediaMode: 'relayed' }); 27 | expect(session).to.be.an.instanceof(Session); 28 | expect(session.mediaMode).to.equal('relayed'); 29 | session = new Session(this.opentok, sessionId, { mediaMode: 'routed' }); 30 | expect(session).to.be.an.instanceof(Session); 31 | expect(session.mediaMode).to.equal('routed'); 32 | }); 33 | it('does not have a location property', function () { 34 | var session = new Session(this.opentok, sessionId, { mediaMode: 'relayed' }); 35 | expect(session).to.be.an.instanceof(Session); 36 | expect(session.location).to.not.exist; 37 | }); 38 | }); 39 | 40 | describe('when initialized with just a location option', function () { 41 | it('has a location property', function () { 42 | var session = new Session(this.opentok, sessionId, { location: '12.34.56.78' }); 43 | expect(session).to.be.an.instanceof(Session); 44 | expect(session.location).to.equal('12.34.56.78'); 45 | }); 46 | it('does not have a mediaMode property', function () { 47 | var session = new Session(this.opentok, sessionId, { location: '12.34.56.78' }); 48 | expect(session).to.be.an.instanceof(Session); 49 | expect(session.mediaMode).to.not.exist; 50 | }); 51 | }); 52 | 53 | describe('#generateToken', function () { 54 | beforeEach(function () { 55 | this.session = new Session(this.opentok, sessionId); 56 | }); 57 | // TODO: check all the invalid stuff 58 | it('generates tokens', function () { 59 | var token = this.session.generateToken(); 60 | expect(token).to.be.a('string'); 61 | // TODO: decode and check its properties 62 | }); 63 | it('assigns a role in the token', function () { 64 | var token = this.session.generateToken(); 65 | expect(token).to.be.a('string'); 66 | // TODO: decode and check that its a publisher 67 | 68 | token = this.session.generateToken({ role: 'subscriber' }); 69 | expect(token).to.be.a('string'); 70 | // TODO: decode and check that its a subscriber 71 | }); 72 | it('assigns an expire time in the token', function () { 73 | var token; 74 | var inAWhile; 75 | 76 | token = this.session.generateToken(); 77 | expect(token).to.be.a('string'); 78 | // TODO: decode and check that its expireTime is one day 79 | 80 | inAWhile = (new Date().getTime() / 1000) + (10); 81 | token = this.session.generateToken({ 82 | expireTime: inAWhile 83 | }); 84 | expect(token).to.be.a('string'); 85 | // TODO: decode and check that the time is right 86 | }); 87 | it('assigns an connection data to the token', function () { 88 | var token = this.session.generateToken({ data: 'name=Johnny' }); 89 | expect(token).to.be.a('string'); 90 | // TODO: decode and check its data 91 | }); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /test/shim-test.js: -------------------------------------------------------------------------------- 1 | const expect = require('chai').expect; 2 | const nock = require('nock'); 3 | const jwt = require('jsonwebtoken'); 4 | const OpenTok = require('../lib/opentok.js'); 5 | 6 | // This is a test key and is not assigned to a real account 7 | // However it can be used to generate JWT tokens 8 | const testPrivateKey = `-----BEGIN PRIVATE KEY----- 9 | MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCuR/FU8YCqMYj/ 10 | UH/yhAJcu56T7RvN/DqGmR8pnp6AS5LqsUcGW8TB/0at2zxvEVlZ/ubr6X1fAUjm 11 | R2tZcOsigCkw//unWBrS7YghO09eel0WlkzSMlWQavHrUIN6WAZk09F+Li/Mq09b 12 | UvbLnSrtsMPPulWh8BDWEr/3AAM0CboX+kpjXJv/dEhDttbbkMo0leVHazrMhzl/ 13 | 1dXj3RzWv0sNJGp7ALjKh0k94Q0JrFRnv06kI94Z36rlPC8J7rAXrcOJFnLh/OiN 14 | fnWTmnnwNlvCxvG+CHP8O3rf5J42MY2JVSng73piBHCSzLDE9s1lsnMuqY6yqt5h 15 | MUb1AssnAgMBAAECggEABFZ1EBSstk+3pbHRZW4EswiBGL8eNzZvwc7eLYaC/RV5 16 | yXlHwnBBSZolc7P7tpX7dS0jfpFYAKfYXSbqP0EmERHt3zLX3CE/awA6KFLravdn 17 | tBAMnG9f8tFpRl6WzyeRYoFlJZtXruPqpzloPBwkJ+NY6aWCdnPdBL8AY9DubiVE 18 | kC1fez1cfhm88wu1Hdf6woUh+R6vnIFlAnayGPYkM2S9X4xnrVWfLTrDlRf2NB2o 19 | K3P8q3FgWAh4o9Jnl/DpI5euOsorwooIqMmYhK9DYaMsDmWrZLMOciSZJKn3xMtH 20 | +LzMVQgVoc0LZCNA8vAxqefH6jjxzZkRiVuqU8eB5QKBgQDwR+YLwWXVkaLCtPOs 21 | RnW+U6KRaZYYexCx7K8cH/HIZ/ZhYP1m9J0LSuOF6sKlmDOooaOqS62tBfWDRUut 22 | 4NHbzHi3iDhjRH2Mz1h9uRURzPld5TZkMdDVAHxkd4b5tN9u7c+1Y0WQ7mvEJS// 23 | kDDhwNwR1+JiUIDYlPJSadbj6wKBgQC5rrZrlEGTApBoorjIWrUzTC99eBUas92C 24 | BozwLHVB1FzDcBP9ABqee0fgVEND4i2iNZCtCm19Gsv+nd21+E1+wmhREBtCzEXO 25 | wfQQjeOJN0QnjJKm94WSmx1xmkhIZBG7NRmScP2W0BzXdEkbRPfWpnfFmUqim2qH 26 | /lTR139ytQKBgF6tBdD1+EkppEcyA52K+dPvomvHfdPRked5ihn74EoF5MfD7rUF 27 | h2eur23R7bZP/XLhldqBDULSyUVbJZGytx3zOFGgxA8hKpM0E/sd1VZ5PHyp1z+t 28 | fUqgcWMo0a9MfIl5/NDM99k+iIn12S7KwugBFPWW6eWxMMOmFMEyYPDXAoGAW3Z+ 29 | EPvUWS/YJlKRJs/Xlc8fTXSLIL4cjGHhpqSfla+fif15OxSECDC9tPiMsbGFvPMZ 30 | ssMCL6+1cFQe0/XdZmUosVV3uC2a7T+Ik2bw/7QjdD/ANVKTjyWtGTpgBJiWS1ra 31 | n9HceB9HNbHoGPCeDDOvp7vckcBwd1CGQ18dPkkCgYBwm8eIPoW92dOBvnZrh4WT 32 | UBEEJoSmV7iom93Wt6m6u0Ow54JrhJeDpRH301OMU0V0UD4cQ7S4SxvVsFEjyGiO 33 | thaTVNUBxf1N62zIzUL7t6ItA4+PZVu6ehXyZ/rax7DfhmINjQ3fRGPPLHGd0L1O 34 | 8B7ZcGqNJg+6nRv+fTwCjw== 35 | -----END PRIVATE KEY----- 36 | `; 37 | 38 | const validReply = JSON.stringify([ 39 | { 40 | session_id: 'SESSIONID', 41 | create_dt: 'Fri Nov 18 15:50:36 PST 2016', 42 | media_server_url: '' 43 | } 44 | ]); 45 | 46 | describe('when initialized with an applicationId and privatekey', function () { 47 | afterEach(function () { 48 | nock.cleanAll(); 49 | }); 50 | 51 | it('sends its requests to the vonage', (done) => { 52 | const apiKey = '00000000-0000-0000-0000-000000000000'; 53 | const opentok = new OpenTok(apiKey, testPrivateKey); 54 | 55 | const scope = nock('https://video.api.vonage.com') 56 | .matchHeader('authorization', ([value]) => { 57 | const token = `${value}`.replace('Bearer ', '') 58 | try { 59 | const claims = jwt.verify(token, testPrivateKey) 60 | return claims.application_id === apiKey; 61 | } catch (_) { 62 | 63 | } 64 | return false; 65 | }) 66 | .post('/session/create', 'archiveMode=manual&p2p.preference=enabled') 67 | .reply( 68 | 200, 69 | validReply, 70 | { 71 | 'content-type': 'application/json', 72 | } 73 | ); 74 | 75 | opentok.createSession((err, session) => { 76 | expect(err).to.be.null 77 | scope.done(); 78 | done(err); 79 | }); 80 | }); 81 | 82 | it('uses a custom proxy', function (done) { 83 | const apiKey = '00000000-0000-0000-0000-000000000000'; 84 | const opentok = new OpenTok(apiKey, testPrivateKey, 'https://example.com'); 85 | 86 | const scope = nock('https://example.com') 87 | .matchHeader('authorization', ([value]) => { 88 | const token = `${value}`.replace('Bearer ', '') 89 | try { 90 | const claims = jwt.verify(token, testPrivateKey) 91 | return claims.iss === apiKey; 92 | } catch (_) { 93 | 94 | } 95 | return false; 96 | }) 97 | .post('/session/create', 'archiveMode=manual&p2p.preference=enabled') 98 | .reply( 99 | 200, 100 | validReply, 101 | { 102 | 'content-type': 'application/json', 103 | } 104 | ); 105 | 106 | opentok.createSession((err, session) => { 107 | expect(err).to.be.null 108 | scope.done(); 109 | done(err); 110 | }); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /test/signaling-test.js: -------------------------------------------------------------------------------- 1 | var expect = require('chai').expect; 2 | var nock = require('nock'); 3 | 4 | // Subject 5 | var OpenTok = require('../lib/opentok.js'); 6 | 7 | describe('Signal', function () { 8 | var opentok = new OpenTok('APIKEY', 'APISECRET'); 9 | var SESSIONID = '1_MX4xMDB-MTI3LjAuMC4xflR1ZSBKYW4gMjggMTU6NDg6NDAgUFNUIDIwMTR-MC43NjAyOTYyfg'; 10 | var CONNECTIONID = '4072fe0f-d499-4f2f-8237-64f5a9d936f5'; 11 | var TYPE = 'type'; 12 | var DATA = 'data'; 13 | 14 | afterEach(function () { 15 | nock.cleanAll(); 16 | }); 17 | 18 | describe('signalSession', function () { 19 | function mockRequest(status, body) { 20 | var url = '/v2/project/APIKEY/session/' + SESSIONID + '/signal'; 21 | nock('https://api.opentok.com:443') 22 | .post(url, { type: TYPE, data: DATA }) 23 | .reply(status, body, { 24 | server: 'nginx', 25 | date: 'Fri, 31 Jan 2014 06:32:16 GMT', 26 | connection: 'keep-alive' 27 | }); 28 | } 29 | 30 | describe('valid responses', function () { 31 | beforeEach(function () { 32 | mockRequest(204, ''); 33 | }); 34 | 35 | it('should not return an error', function (done) { 36 | opentok.signal(SESSIONID, null, { type: TYPE, data: DATA }, function (err) { 37 | expect(err).to.be.null; 38 | done(); 39 | }); 40 | }); 41 | 42 | it('should return an error if session is null', function (done) { 43 | opentok.signal(null, null, { type: TYPE, data: DATA }, function (err) { 44 | expect(err).to.not.be.null; 45 | done(); 46 | }); 47 | }); 48 | }); 49 | 50 | describe('invalid responses', function () { 51 | var errors = [400, 403, 404, 500]; 52 | var i; 53 | function test(error) { 54 | it('should fail for status ' + error, function (done) { 55 | mockRequest(error, ''); 56 | 57 | opentok.signal(SESSIONID, null, { type: TYPE, data: DATA }, function (err) { 58 | expect(err).to.not.be.null; 59 | done(); 60 | }); 61 | }); 62 | } 63 | for (i = 0; i < errors.length; i++) { 64 | test(errors[i]); 65 | } 66 | }); 67 | }); 68 | 69 | describe('signalConnection', function () { 70 | function mockRequest(status, body) { 71 | var url = '/v2/project/APIKEY/session/' + SESSIONID + '/connection/' + CONNECTIONID + '/signal'; 72 | nock('https://api.opentok.com:443') 73 | .post(url, { type: TYPE, data: DATA }) 74 | .reply(status, body, { 75 | server: 'nginx', 76 | date: 'Fri, 31 Jan 2014 06:32:16 GMT', 77 | connection: 'keep-alive' 78 | }); 79 | } 80 | 81 | describe('valid responses', function () { 82 | beforeEach(function () { 83 | mockRequest(204, ''); 84 | }); 85 | 86 | it('should not return an error', function (done) { 87 | opentok.signal(SESSIONID, CONNECTIONID, { type: TYPE, data: DATA }, function (err) { 88 | expect(err).to.be.null; 89 | done(); 90 | }); 91 | }); 92 | }); 93 | 94 | describe('invalid responses', function () { 95 | var errors = [400, 403, 404, 500]; 96 | var i; 97 | function test(error) { 98 | it('should fail for status ' + error, function (done) { 99 | mockRequest(error, ''); 100 | 101 | opentok.signal(SESSIONID, CONNECTIONID, { type: TYPE, data: DATA }, function (err) { 102 | expect(err).to.not.be.null; 103 | done(); 104 | }); 105 | }); 106 | } 107 | for (i = 0; i < errors.length; i++) { 108 | test(errors[i]); 109 | } 110 | }); 111 | }); 112 | }); 113 | --------------------------------------------------------------------------------