├── .esdoc.json ├── .eslintignore ├── .github └── workflows │ └── node.js.yml ├── .gitignore ├── .gitmodules ├── .npmignore ├── .prettierrc ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── bump-version.js ├── flake.lock ├── flake.nix ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── backend │ ├── index.ts │ ├── tsh.ts │ └── ws.ts ├── browser.ts ├── cli.ts ├── connect.ts ├── index.ts ├── json-parser.ts ├── log.ts ├── schema │ ├── fetch.ts │ ├── generate.ts │ ├── index.ts │ ├── nodes.ts │ └── utils.ts ├── transport │ ├── duplexer.d.ts │ ├── ssh.ts │ ├── stream.ts │ ├── tsh.ts │ └── ws.ts ├── tsconfig.json ├── types.ts ├── version.ts └── xapi │ ├── components.ts │ ├── exc.ts │ ├── feedback.ts │ ├── index.ts │ ├── mixins.ts │ ├── normalizePath.ts │ ├── proxy.ts │ ├── rpc.ts │ └── types.ts ├── test ├── .eslintrc.json ├── backend │ ├── index.spec.ts │ └── tsh.spec.ts ├── connect.spec.ts ├── data │ ├── echo_response.txt │ └── welcome.txt ├── index.ts ├── json-parser.spec.ts ├── mock_transport.ts ├── schema │ ├── __snapshots__ │ │ └── nodes.spec.ts.snap │ ├── access_test.ts │ ├── example.ts │ ├── fetch-schemas.sh │ ├── index.spec.ts │ ├── nodes.spec.ts │ └── utils.spec.ts ├── transport │ ├── ssh.spec.ts │ └── stream.spec.ts └── xapi │ ├── feedback.spec.ts │ ├── index.spec.ts │ ├── proxy.spec.ts │ └── rpc.spec.ts ├── tsconfig.json ├── tslint.json └── typings ├── duplex-passthrough └── index.d.ts └── jsonparse └── index.d.ts /.esdoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "source": "./src", 3 | "destination": "./docs", 4 | "plugins": [ 5 | { 6 | "name": "esdoc-standard-plugin" 7 | }, 8 | { 9 | "name": "esdoc-ecmascript-proposal-plugin", 10 | "option": { 11 | "all": true 12 | } 13 | }, 14 | { 15 | "name": "esdoc-importpath-plugin", 16 | "option": { 17 | "stripPackageName": false, 18 | "replaces": [ 19 | {"from": "^src/", "to": "lib/"} 20 | ] 21 | } 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /docs/ 2 | /lib/ 3 | /node_modules/ 4 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ "master" ] 9 | pull_request: 10 | branches: [ "master" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [18.x, 20.x, 22.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | cache: 'npm' 29 | - run: npm install 30 | - run: npm run build 31 | - run: npm test 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache/ 2 | cache/ 3 | lib/ 4 | node_modules 5 | yarn-error.log 6 | dist/ 7 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "docs"] 2 | path = docs 3 | url = git@github.com:cisco-ce/jsxapi.git 4 | branch = origin/gh-pages 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .* 2 | docs/ 3 | test/ 4 | .npmignore 5 | *.tgz 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "singleQuote": true, 4 | "trailingComma": "all", 5 | "overrides": [ 6 | { 7 | "files": ".prettierrc", 8 | "options": { "parser": "json" } 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "10" 4 | - "12" 5 | - "14" 6 | 7 | # Workaround for Travis not being able to fetch ssh urls. 8 | # 9 | # https://stackoverflow.com/a/24600210 and https://gist.github.com/iedemam/9830045 10 | # 11 | 12 | # disable the default submodule logic 13 | git: 14 | submodules: false 15 | 16 | # use sed to replace the SSH URL with the public URL, then init and update submodules 17 | before_install: 18 | - sed -i 's/git@github.com:/git:\/\/github.com\//' .gitmodules 19 | - git submodule update --init --recursive 20 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Changes 2 | ------- 3 | 4 | ##### v6.0.0 (2024-11-22) 5 | * Update dependencies 6 | * Remove support for nodejs <= 16 7 | 8 | ##### v5.1.1 (2021-11-03) 9 | 10 | * Update dependencies. 11 | * Tweak mixing implementation. 12 | 13 | ##### v5.1.0 (2021-06-25) 14 | 15 | * Add schema tools. 16 | * Support `SSH_AUTH_SOCK` for agent integration. 17 | 18 | ##### v5.0.3 (2020-03-16) 19 | 20 | * Add version string to XAPI instance. 21 | * Fix duplicate feedback emission using WebSockets. 22 | 23 | ##### v5.0.2 (2020-02-18) 24 | 25 | * Fix TSH response parsing (#56) 26 | * Add more debug logging to ws backend 27 | 28 | ##### v5.0.1 (2019-12-19) 29 | 30 | * Fix browser field in package.json 31 | 32 | ##### v5.0.0 (2019-12-18) 33 | 34 | * New proxy-based API 35 | * Codebase migrated to TypeScript 36 | * Bundled browser-bundle for static pages using WebSockets. 37 | 38 | ##### v4.5.0 (2019-10-02) 39 | 40 | * Fix browser support (using WebSockets) 41 | 42 | ##### v4.4.0 (2019-09-23) 43 | 44 | * Fix connecting using WebSocket 45 | * Expose feedback registration promise (#33) 46 | 47 | ##### v4.3.2 (2019-06-29) 48 | 49 | * Update dependencies 50 | 51 | ##### v4.3.1 (2019-05-23) 52 | 53 | * Clean up accidental artifacts from 4.3.0 54 | 55 | ##### v4.3.0 (2019-05-23) 56 | 57 | * Improve error propagation for `config.set` 58 | * Fix utf8 body length issues in `tsh` backend 59 | * Pass the `xapi` instance when invoking the `ready` event handler 60 | 61 | ##### v4.2.0 (2018-10-31) 62 | 63 | * Change array dispatching to only dispatch once per array element 64 | * Update dependencies 65 | * Properly report uknown error types (previously reported as "Reason for XAPIError must be a string" 66 | * Fix bug where ssh not always reports closing events 67 | 68 | ##### v4.1.3 (2018-04-10) 69 | 70 | * Avoid circular dependencies in imports 71 | 72 | #### v4.1.2 (2018-02-01) 73 | 74 | * Support boolean and number as command/config parameter values 75 | 76 | #### v4.1.1 (2018-02-01) 77 | 78 | * Fix issue with setting log level in the cli 79 | 80 | #### v4.1.0 (2018-02-01) 81 | 82 | * Support multiple call signatures for `connect` 83 | * Properly handle SSH "close" events 84 | 85 | #### v4.0.0/v4.0.1 (2018-01-25) 86 | 87 | * Initial public version. 88 | 89 | #### v3.2.6 (2017-12-13) 90 | 91 | * Fix quoting issue in TSH backend 92 | 93 | #### v3.2.5 (2017-12-13) 94 | 95 | * Better feedback detection of messages 96 | 97 | #### v3.2.4 (2017-12-01) 98 | 99 | * Fix issue with TSH initialization 100 | 101 | #### v3.2.3 (2017-12-01) 102 | 103 | * Updates to dependency chain, npm-shrinkwrap.json 104 | 105 | #### v3.2.2 (2017-12-01) 106 | 107 | * Update logging library to resolve debugging issues on Node. 108 | 109 | #### v3.2.1 (2017-11-30) 110 | 111 | * Add option to specify log level in `connect`. 112 | 113 | #### v3.2.0 (2017-10-14) 114 | 115 | * Add API to allow clients to intercept feedback. 116 | 117 | #### v3.1.1 (2017-09-12) 118 | 119 | * Assert that parameters do not include newline characters. 120 | 121 | #### v3.1.0 (2017-09-11) 122 | 123 | * More graceful handling of intermittent non-JSON output from TSH. 124 | 125 | #### v3.0.3 (2017-09-08) 126 | 127 | * Fix issue with Buffer encoding in json parser 128 | 129 | #### v3.0.2 (2017-09-06) 130 | 131 | * Fix WebSocket backend so that it doesn't emit error on error responses. 132 | 133 | #### v3.0.1 (2017-08-22) 134 | 135 | * Add API changes supposed to go in v3.0.0. 136 | * Improve the feedback and feedback group concept. See docs. 137 | 138 | #### v3.0.0 (2017-08-21) !incomplete 139 | 140 | * Remove direct support for internal mode. 141 | * Add support for running remote command for users without `tsh` login shell. 142 | 143 | #### v2.1.3 (2017-06-06) 144 | 145 | * Return `undefined` instead of error for responses without proper object 146 | path. 147 | 148 | #### v2.1.2 (2017-05-18) 149 | 150 | * Fix feedback path registration with indexes. 151 | 152 | #### v2.1.1 (2017-04-19) 153 | 154 | * Fix `createCommandResponse` so that it handles "Error" errors. 155 | 156 | #### v2.1.0 (2017-04-13) 157 | 158 | From v1.4.0: 159 | 160 | * Add support for multiple values for same parameter name (arrays). 161 | 162 | ``` 163 | xapi.command('UserManagement User Add', { 164 | Username: 'foo', 165 | Role: ['Admin', 'User'], 166 | }); 167 | ``` 168 | 169 | #### v2.0.0 (2017-03-29) 170 | 171 | * New API structure: 172 | 173 | ``` 174 | // Commands should remain the same. 175 | xapi.command('...', { ... }); 176 | 177 | // Events has only feedback. 178 | xapi.event.on('...', handler); 179 | 180 | // Statuses has feedback and retrieval. 181 | xapi.status.on('...', handler); 182 | xapi.status.get('...').then(v => { ... }); 183 | 184 | // Config has feedback, retrieval and can be updated. 185 | xapi.config.on('...', handler); 186 | xapi.config.get('...').then(v => { ... }); 187 | xapi.config.set('...', value); 188 | ``` 189 | 190 | #### v1.3.2 (2016-12-08) 191 | 192 | * Add `.close()` to StreamTransport. 193 | 194 | #### v1.3.1 (2016-11-24) 195 | 196 | * Throw custom errors for better error reporting. 197 | 198 | #### v1.3.0 (2016-11-23) 199 | 200 | * Add WebSocket backend. 201 | 202 | #### v1.2.3 (2016-11-03) 203 | 204 | * Pass feedback root payload to all event listeners as second argument. 205 | 206 | #### v1.2.2 (2016-11-02) 207 | 208 | * Do not exclude lower-case attributes in feedback and command responses. 209 | 210 | #### v1.2.1 (2016-10-17) 211 | 212 | * Improve error handling in backend base class. 213 | 214 | #### v1.2.0 (2016-10-13) 215 | 216 | * Pass `connect` options down through the stack. 217 | 218 | #### v1.1.2 (2016-10-12) 219 | 220 | * Allow registering feedback to list/arrays. 221 | * Handle ghost feedback. 222 | * Do not emit feedback for lower case properties. 223 | * Fix issues with broken .once(). 224 | 225 | #### v1.1.1 (2016-10-07) 226 | 227 | * Fix issues with running the cli. 228 | * Add -V, --version to command line opts. 229 | 230 | #### v1.1.0 (2016-10-07) 231 | 232 | * Support evaluation of script files in command line interface. 233 | * Deprecate .toXML(). 234 | 235 | #### v1.0.1 (2016-09-02) 236 | 237 | * Add space after length specifier in tsh multi-line format. 238 | 239 | #### v1.0.0 (2016-08-31) 240 | 241 | * Initial release. 242 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018-2021 Cisco Systems 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JSXAPI 2 | 3 | [![Build Status](https://travis-ci.com/cisco-ce/jsxapi.svg?branch=master)](https://app.travis-ci.com/github/cisco-ce/jsxapi) 4 | 5 | [API Documentation](https://cisco-ce.github.io/jsxapi/) 6 | 7 | A set of tools to integrate with the Cisco Telepresence Endpoint APIs in 8 | JavaScript. 9 | 10 | ## Quick start examples 11 | 12 | ### Connecting using WebSockets 13 | 14 | ``` javascript 15 | const jsxapi = require('jsxapi'); 16 | 17 | jsxapi 18 | .connect('wss://host.example.com', { 19 | username: 'admin', 20 | password: 'password', 21 | }) 22 | .on('error', console.error) 23 | .on('ready', async (xapi) => { 24 | const volume = await xapi.status.get('Audio Volume'); 25 | console.log(`volume is: ${volume}`); 26 | xapi.close(); 27 | }); 28 | ``` 29 | 30 | ### Connecting using SSH 31 | 32 | ``` javascript 33 | const jsxapi = require('jsxapi'); 34 | 35 | jsxapi 36 | .connect('ssh://host.example.com', { 37 | username: 'admin', 38 | password: 'password', 39 | }) 40 | .on('error', console.error) 41 | .on('ready', async (xapi) => { 42 | const volume = await xapi.status.get('Audio Volume'); 43 | console.log(`volume is: ${volume}`); 44 | xapi.close(); 45 | }); 46 | ``` 47 | 48 | ### New style API 49 | 50 | The aim of the new style API is to improve readability, while also being more 51 | suited towards automatic type generation and auto-completion. 52 | 53 | ```javascript 54 | // Set up a call 55 | xapi.Command.Dial({ Number: 'user@example.com' }); 56 | 57 | // Fetch volume and print it 58 | xapi.Status.Audio.Volume 59 | .get() 60 | .then((volume) => { console.log(volume); }); 61 | 62 | // Set a configuration 63 | xapi.Config.SystemUnit.Name.set('My System'); 64 | 65 | // Listen to feedback 66 | const off = xapi.Event.Standby.on((event) => { 67 | // ... 68 | }); 69 | 70 | // De-register feedback 71 | off(); 72 | ``` 73 | 74 | ### Old style API 75 | 76 | ```javascript 77 | // Set up a call 78 | xapi.command('Dial', { Number: 'user@example.com' }); 79 | 80 | // Fetch volume and print it 81 | xapi.status 82 | .get('Audio Volume') 83 | .then((volume) => { console.log(volume); }); 84 | 85 | // Set a configuration 86 | xapi.config.set('SystemUnit Name', 'My System'); 87 | 88 | // Listen to feedback 89 | const off = xapi.event.on('Standby', (event) => { 90 | // ... 91 | }); 92 | 93 | // De-register feedback 94 | off(); 95 | ``` 96 | 97 | ## Documentation 98 | 99 | The full API documentation can be built by running `npm install` in a `jsxapi` 100 | module directory. Documentation will be located under `docs/` can then be opened 101 | in a browser. 102 | 103 | More specifically: 104 | 105 | ``` 106 | mkdir tmp 107 | cd tmp 108 | npm install jsxapi 109 | cd node_modules/jsxapi 110 | npm install 111 | ``` 112 | 113 | Then open `./docs/index.html`. 114 | 115 | ## Questions and support? 116 | 117 | Find more information regarding Cisco's Room Devices over at 118 | [developer.cisco.com](https://developer.cisco.com/site/roomdevices/) or the 119 | [TelePresence and Video](https://supportforums.cisco.com/t5/telepresence/bd-p/5886-discussions-telepresence) 120 | support forums. 121 | 122 | Questions about the xAPI, integrations and customizations? Using 123 | [Webex Teams](https://www.webex.com/team-collaboration.html) join the xAPI Devs 124 | space community for realtime support by [clicking this link](https://eurl.io/#rkp76XDrG) 125 | and entering your Webex Teams-registered e-mail address at the prompt. 126 | 127 | ## Development & Contribution 128 | 129 | ### Release procedure 130 | 131 | Making a release is quite simple: 132 | 133 | * Perform all changes/commits. 134 | * Determine the version change (`npm help semver`). 135 | * Update "CHANGELOG.md" with version number, date and change summary. 136 | * Run `npm version` with the appropriate version bump. 137 | * Run `npm publish` to push the package version to the registry. 138 | -------------------------------------------------------------------------------- /bump-version.js: -------------------------------------------------------------------------------- 1 | /* 2 | * This scripts write version info to ./src/version.ts to match package.json. 3 | */ 4 | 5 | const fs = require('fs'); 6 | const path = require('path'); 7 | const { version } = require('./package.json'); 8 | const dest = path.join(__dirname, 'src/version.ts'); 9 | 10 | fs.writeFileSync(dest, `/* 11 | * DO NOT WRITE OR UPDATE THIS FILE! 12 | * This file was automatically generated at: ${new Date().toISOString()} 13 | */ 14 | const VERSION = '${version}'; 15 | export default VERSION; 16 | `); 17 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1684668519, 6 | "narHash": "sha256-KkVvlXTqdLLwko9Y0p1Xv6KQ9QTcQorrU098cGilb7c=", 7 | "owner": "NixOS", 8 | "repo": "nixpkgs", 9 | "rev": "85340996ba67cc02f01ba324e18b1306892ed6f5", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "NixOS", 14 | "ref": "nixpkgs-unstable", 15 | "repo": "nixpkgs", 16 | "type": "github" 17 | } 18 | }, 19 | "root": { 20 | "inputs": { 21 | "nixpkgs": "nixpkgs" 22 | } 23 | } 24 | }, 25 | "root": "root", 26 | "version": 7 27 | } 28 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "JSXAPI flake"; 3 | nixConfig.bash-prompt-suffix = "\[nix\] "; 4 | 5 | inputs = { 6 | nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; 7 | }; 8 | 9 | outputs = { self, nixpkgs }: 10 | let 11 | # System types to support. 12 | supportedSystems = [ "x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin" ]; 13 | 14 | # Helper function to generate an attrset '{ x86_64-linux = f "x86_64-linux"; ... }'. 15 | forAllSystems = nixpkgs.lib.genAttrs supportedSystems; 16 | 17 | # Nixpkgs instantiated for supported system types. 18 | nixpkgsFor = forAllSystems (system: import nixpkgs { inherit system; }); 19 | in 20 | { 21 | # Utilized by `nix develop` 22 | devShell = forAllSystems (system: 23 | let 24 | pkgs = nixpkgsFor.${system}; 25 | my-python = pkgs.python312; 26 | python-with-my-packages = my-python.withPackages (p: with p; [ 27 | gyp 28 | setuptools 29 | # other python packages you want 30 | ]); 31 | in 32 | pkgs.mkShell { 33 | buildInputs = [ 34 | pkgs.nodePackages.npm 35 | pkgs.nodejs 36 | python-with-my-packages 37 | ]; 38 | } 39 | ); 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | }; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jsxapi", 3 | "version": "6.0.0", 4 | "description": "JavaScript bindings for XAPI", 5 | "author": { 6 | "name": "Martin Øinæs Myrseth", 7 | "email": "mmyrseth@cisco.com" 8 | }, 9 | "license": "MIT", 10 | "engines": { 11 | "node": ">=8.x", 12 | "npm": ">=5.x" 13 | }, 14 | "main": "lib/index.js", 15 | "browser": "lib/browser.js", 16 | "types": "lib/index.d.ts", 17 | "bin": { 18 | "jsxapi": "./lib/cli.js" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "git@github.com:cisco-ce/jsxapi.git" 23 | }, 24 | "scripts": { 25 | "build": "run-s build:js build:**", 26 | "build:dist:min": "parcel build --global jsxapi -o jsxapi.min.js ./src/browser.ts", 27 | "build:dist": "parcel build --no-minify --global jsxapi -o jsxapi.js ./src/browser.ts", 28 | "build:docs": "typedoc --tsconfig src/tsconfig.json --entryPointStrategy expand --out docs src", 29 | "build:js": "(cd src; tsc)", 30 | "clean": "rimraf docs lib", 31 | "lint": "tslint \"src/**/*.ts\"", 32 | "prepare": "npm run build", 33 | "prepublishOnly": "npm test", 34 | "start": "node ./lib/cli.js", 35 | "test": "npm run lint && npm run jest", 36 | "jest": "jest", 37 | "tdd": "jest --watch", 38 | "version": "node ./bump-version.js && npm run build && git add -u", 39 | "watch": "(cd src; tsc --watch)" 40 | }, 41 | "dependencies": { 42 | "@types/events": "^3.0.0", 43 | "buffer": "^6.0.3", 44 | "commander": "^10.0.1", 45 | "core-js": "^3.30.2", 46 | "duplex-passthrough": "^1.0.2", 47 | "duplexer": "^0.1.2", 48 | "events": "^3.3.0", 49 | "jsonparse": "^1.3.1", 50 | "loglevel": "^1.8.1", 51 | "redent": "^3.0.0", 52 | "ssh2": "^1.13.0", 53 | "url-parse": "^1.5.10", 54 | "ws": "^8.18.0", 55 | "xdg-basedir": "^4.0.0", 56 | "xml-escape": "^1.1.0" 57 | }, 58 | "devDependencies": { 59 | "@types/jest": "^29.5.1", 60 | "@types/ssh2": "^1.11.11", 61 | "@types/url-parse": "^1.4.8", 62 | "@types/ws": "^8.5.13", 63 | "jest": "^29.5.0", 64 | "json-loader": "^0.5.4", 65 | "npm-run-all": "^4.1.5", 66 | "parcel-bundler": "^1.12.5", 67 | "rimraf": "^3.0.2", 68 | "ts-jest": "^29.1.0", 69 | "ts-node": "^10.4.0", 70 | "tslint": "^6.1.3", 71 | "typedoc": "^0.22.7", 72 | "typescript": "^4.4.4" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/backend/index.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | 3 | import log from '../log'; 4 | import * as rpc from '../xapi/rpc'; 5 | import { XapiRequest, XapiResult } from '../xapi/types'; 6 | 7 | export interface Backend extends EventEmitter { 8 | close(): void; 9 | execute(request: XapiRequest): Promise; 10 | } 11 | 12 | /** 13 | * @external {EventEmitter} https://nodejs.org/api/events.html#events_class_eventemitter 14 | */ 15 | 16 | /** 17 | * Backend abstract class. 18 | * 19 | * ### Custom backend implementation 20 | * 21 | * ```typescript 22 | * class MyBackend extends Backend { 23 | * constructor(transport) { 24 | * this._transport = transport.on('data', this._recvMsg.bind(this)); 25 | * } 26 | * 27 | * _recvMsg(message) { 28 | * const id = ... // determine request id 29 | * const result = ... // process message 30 | * this.onResult(id, result); 31 | * } 32 | * 33 | * // `command` is passed by the method handler, e.g. `xCommand()`. 34 | * send(id, command) { 35 | * const message = ... // use id and command to construct message 36 | * this._transport.send(message); 37 | * } 38 | * 39 | * // this is dispatched by .execute() 40 | * 'xCommand()'(request, send) { 41 | * const command = ... // do stuff with request 42 | * return send(command).then(result => { 43 | * // process result 44 | * }); 45 | * } 46 | * } 47 | * ``` 48 | */ 49 | export default class BackendImpl extends EventEmitter implements Backend { 50 | 51 | /** 52 | * Promise that is resolved once the backend is ready to receive commands. 53 | * 54 | * @return {Promise} - Promised resolved when the backend is ready. 55 | */ 56 | get isReady() { 57 | return Promise.resolve(true); 58 | } 59 | private requests: { [idx: string]: (res: XapiResult) => void } = {}; 60 | 61 | /** 62 | * Close the backend connection and free up resources. The backend should not 63 | * be used after it is closed and a new instance is required in order 64 | * re-initialize. 65 | */ 66 | public close() { 67 | // No-op 68 | } 69 | 70 | /** 71 | * Transmit the given JSON-RPC payload to the backend service. The request 72 | * type is determined using {@link getRequestType} and the request is 73 | * delegated to method handlers, if they are defined for the given type. The 74 | * default handler ({@link defaultHandler}) is used if there is no handler 75 | * for the request type. 76 | * 77 | * Method handlers are defined on the sub-class, using the naming convention of 78 | * `()` (notice the '()' suffix). Method handlers are passed the 79 | * request object and a `send` function to invoke for the request. 80 | * 81 | * @param {Object} request - JSON-RPC request to execute agains the backend service. 82 | * @return {Promise} - Promise resolved when response is received. 83 | */ 84 | public execute(request: XapiRequest): Promise { 85 | const id = request.id!; // TODO 86 | const type = this.getRequestType(request); 87 | const handlerName = `${type}()`; 88 | const handler = 89 | typeof (this as any)[handlerName] === 'function' 90 | ? (this as any)[handlerName] 91 | : this.defaultHandler; 92 | 93 | return this.isReady 94 | .then(() => { 95 | const promise = new Promise((resolve) => { 96 | this.requests[id] = resolve; 97 | }); 98 | const sender = (cmd: string, body: string) => { 99 | this.send(id, cmd, body); 100 | return promise; 101 | }; 102 | log.debug('[backend] (request):', request); 103 | const result = handler.call(this, request, sender); 104 | return Promise.resolve(result); 105 | }) 106 | .then((result) => { 107 | log.debug('[backend] (success):', result); 108 | this.emit('data', rpc.createResponse(id, result)); 109 | }) 110 | .catch((error) => { 111 | log.debug('[backend] (failure):', error); 112 | this.emit('data', rpc.createErrorResponse(id, error)); 113 | }); 114 | } 115 | 116 | /** 117 | * Called when receiving feedback from the backend service. 118 | * 119 | * @param {Object} result - JSON-RPC params data for the feedback event. 120 | */ 121 | public onFeedback(result: any) { 122 | this.emit('data', rpc.createRequest(null, 'xFeedback/Event', result)); 123 | } 124 | 125 | /** 126 | * Called when the backend is done processing the response and ready to hand 127 | * it over to the XAPI frontend. The response should be a valid JSON-RPC 128 | * response. 129 | * 130 | * @param {string} id - Request id of the JSON-RPC request. 131 | * @param {Object} result - Result from the backend service. 132 | */ 133 | public onResult(id: string, result: XapiResult) { 134 | if (id) { 135 | const resolve = this.requests[id]; 136 | delete this.requests[id]; 137 | resolve(result); 138 | } 139 | } 140 | 141 | /** 142 | * Used to send the actual command to the backend service. The command 143 | * should be generated by the method handler and . 144 | * 145 | * @param {string} id - The request id. 146 | * @param {Array|Object|number|string} command - Command from method handler. 147 | * @abstract 148 | */ 149 | public send(id: string, command: string, body: string) { 150 | throw new Error('Backend class must override .send()'); 151 | } 152 | 153 | /** 154 | * Default method handler. Called if there isn't a handler specified for the 155 | * method type. The default handler dies unless it is overridden in a sub-class. 156 | * 157 | * @param {Object} request - JSON-RPC request 158 | * @param {Function} send - Function for dispatching the request to the backend service. 159 | */ 160 | public defaultHandler({ method }: any, send?: any): Promise { 161 | return Promise.reject(new Error(`Invalid request method: ${method}`)); 162 | } 163 | 164 | /** 165 | * Determine the type of the JSON-RPC request. The type is used for 166 | * dispatching the request to the different rpc handlers. Sub-classes may 167 | * override this for custom routing behavior. 168 | * 169 | * @param {Object} request - JSON-RPC request 170 | * @return {string} - Request method type. 171 | */ 172 | private getRequestType({ method }: XapiRequest) { 173 | if (method.startsWith('xCommand')) { 174 | return 'xCommand'; 175 | } 176 | return method; 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /src/backend/tsh.ts: -------------------------------------------------------------------------------- 1 | import { JSONParser } from '../json-parser'; 2 | import log from '../log'; 3 | import * as rpc from '../xapi/rpc'; 4 | import { Buffer } from 'buffer'; 5 | 6 | import { CloseableStream } from '../xapi/types'; 7 | import Backend from './'; 8 | 9 | /** 10 | * @external {Duplex} https://nodejs.org/api/stream.html#stream_class_stream_duplex 11 | */ 12 | 13 | function formatValue(value: any) { 14 | switch (typeof value) { 15 | case 'boolean': 16 | return value ? 'True' : 'False'; 17 | case 'number': 18 | case 'string': 19 | return JSON.stringify(value); 20 | default: 21 | throw new TypeError(`Invalid value ${JSON.stringify(value)}`); 22 | } 23 | } 24 | 25 | function paramString(key: string, value: string | string[]) { 26 | const values = Array.isArray(value) ? value : [value]; 27 | return values.map((v) => `${key}: ${formatValue(v)}`).join(' '); 28 | } 29 | 30 | export type State = 'idle' | 'connecting' | 'initializing' | 'closed' | 'ready'; 31 | 32 | /** 33 | * Backend to communicate with a {@link Duplex} stream talking tshell (tsh). 34 | * 35 | * @extends {Backend} 36 | */ 37 | export default class TSHBackend extends Backend { 38 | private feedbackQueries: { [idx: string]: string } = {}; 39 | private nextFeedbackId = 0; 40 | private parser = new JSONParser(); 41 | private buffer = ''; 42 | private state: State = 'idle'; 43 | /** 44 | * @param {Duplex} transport - Stream to interact with TSH. 45 | */ 46 | constructor(readonly transport: CloseableStream) { 47 | super(); 48 | 49 | this.parser.on('data', this.onParserData.bind(this)); 50 | this.parser.on('error', (error) => this.emit('error', error)); 51 | 52 | Object.defineProperty(this, 'isReady', { 53 | configurable: false, 54 | enumerable: true, 55 | value: new Promise((resolve, reject) => { 56 | if (this.state !== 'idle') { 57 | reject(new Error('TSHBackend is not in an idle state')); 58 | return; 59 | } 60 | 61 | this.connectResolve = resolve; 62 | this.setState('connecting'); 63 | }), 64 | writable: false, 65 | }); 66 | 67 | this.transport 68 | .on('data', (data) => this.onTransportData(data)) 69 | .on('error', (error) => this.emit('error', error)) 70 | .on('close', () => { 71 | this.setState('closed'); 72 | this.emit('close'); 73 | }); 74 | } 75 | 76 | /** 77 | * @override 78 | */ 79 | public close() { 80 | this.transport.close(); 81 | } 82 | 83 | /** 84 | * @override 85 | */ 86 | public send(id: string, command: string, body: string) { 87 | let cmd = `${command} | resultId="${id}"\n`; 88 | if (body !== undefined) { 89 | cmd += `${body}\n`; 90 | const length = Buffer.byteLength(cmd, 'utf8'); 91 | cmd = `{${length}} \n${cmd}`; 92 | } 93 | 94 | this.write(cmd); 95 | } 96 | 97 | public onTransportData(data: any) { 98 | switch (this.state) { 99 | case 'connecting': 100 | if (this.bufferHasOK(data)) { 101 | log.debug('[transport] (connecting)', data.toString()); 102 | this.setState('initializing'); 103 | this.write('echo off\n'); 104 | this.emit('initializing'); 105 | } 106 | break; 107 | case 'initializing': 108 | if (this.bufferHasOK(data)) { 109 | log.debug('[transport] (initializing)', data.toString()); 110 | this.buffer = ''; 111 | this.write('xpreferences outputmode json\n'); 112 | this.setState('ready'); 113 | this.connectResolve(true); 114 | this.emit('ready'); 115 | } 116 | break; 117 | case 'ready': 118 | log.debug(`to parser: "${data.toString()}"`); 119 | this.parser.write(data); 120 | break; 121 | default: 122 | this.emit( 123 | 'error', 124 | new Error('TSHBackend is in an invalid state for input'), 125 | ); 126 | } 127 | } 128 | 129 | private bufferHasOK(buffer: any) { 130 | const lines = (this.buffer + buffer.toString()).split('\n'); 131 | if (lines.length) { 132 | this.buffer = lines[lines.length - 1]; 133 | } 134 | return lines.some((line) => line === 'OK'); 135 | } 136 | 137 | private setState(newState: State) { 138 | this.state = newState; 139 | } 140 | 141 | private onParserData(data: any) { 142 | if (!{}.hasOwnProperty.call(data, 'ResultId')) { 143 | log.debug('[tsh] (feedback):', JSON.stringify(data)); 144 | this.onFeedback(rpc.parseFeedbackResponse(data)); 145 | } else { 146 | log.debug('[tsh] (result):', JSON.stringify(data)); 147 | this.onResult(data.ResultId, data); 148 | } 149 | } 150 | 151 | private write(data: string) { 152 | log.debug(`write: ${JSON.stringify(data)}`); 153 | this.transport.write(data); 154 | } 155 | 156 | // XAPI json-rpc method handlers 157 | 158 | /** 159 | * @ignore 160 | */ 161 | private ['xCommand()']({ method, params }: any, send: any) { 162 | const paramsCopy = Object.assign({}, params); 163 | const { body } = paramsCopy; 164 | delete paramsCopy.body; 165 | 166 | const tshParams = paramsCopy 167 | ? Object.keys(paramsCopy) 168 | .sort() 169 | .map((k) => paramString(k, paramsCopy[k])) 170 | : []; 171 | 172 | const cmd = method 173 | .split('/') 174 | .concat(tshParams) 175 | .join(' '); 176 | 177 | return send(cmd, body).then(rpc.createCommandResponse); 178 | } 179 | 180 | /** 181 | * @ignore 182 | */ 183 | private ['xDoc()'](request: any, send: any) { 184 | const { Path, Type } = request.params; 185 | 186 | const tshParams: any = { 187 | Format: 'JSON', 188 | Path: Path.join('/'), 189 | Schema: Type === 'Schema' ? 'True' : 'False', 190 | }; 191 | 192 | const paramsStr = Object.keys(tshParams) 193 | .sort() 194 | .map((k) => paramString(k, tshParams[k])) 195 | .join(' '); 196 | 197 | return send(`xDocument ${paramsStr}`).then((response: any) => 198 | rpc.createDocumentResponse(request, response), 199 | ); 200 | } 201 | 202 | /** 203 | * @ignore 204 | */ 205 | private ['xFeedback/Subscribe()']({ params }: any, send: any) { 206 | const query: string = params.Query.map((part: number | string) => 207 | typeof part === 'number' ? `[${part}]` : `/${part}`, 208 | ).join(''); 209 | return send(`xfeedback register ${query}`).then(() => { 210 | const id = this.nextFeedbackId; 211 | this.nextFeedbackId += 1; 212 | this.feedbackQueries[id] = query; 213 | return { Id: id }; 214 | }); 215 | } 216 | 217 | /** 218 | * @ignore 219 | */ 220 | private ['xFeedback/Unsubscribe()']({ params }: any, send: any) { 221 | const id = params.Id; 222 | 223 | if (!{}.hasOwnProperty.call(this.feedbackQueries, id)) { 224 | throw new Error(`Invalid feedback id: ${id}`); 225 | } 226 | 227 | const path = this.feedbackQueries[id]; 228 | 229 | return send(`xfeedback deregister ${path}`).then(() => { 230 | delete this.feedbackQueries[id]; 231 | return true; 232 | }); 233 | } 234 | 235 | /** 236 | * @ignore 237 | */ 238 | private ['xGet()'](request: any, send: any) { 239 | const path = request.params.Path.join(' '); 240 | return send(`x${path}`).then((response: any) => 241 | rpc.createGetResponse(request, response), 242 | ); 243 | } 244 | 245 | /** 246 | * @ignore 247 | */ 248 | private ['xSet()'](request: any, send: any) { 249 | const { params } = request; 250 | const path = params.Path.join(' '); 251 | const value = formatValue(params.Value); 252 | return send(`x${path}: ${value}`).then((response: any) => 253 | rpc.createSetResponse(request, response), 254 | ); 255 | } 256 | private connectResolve: (ok: boolean) => void = () => { 257 | /* noop */ 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /src/backend/ws.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import WS from 'ws'; 3 | 4 | import log from '../log'; 5 | import { XapiRequest } from '../xapi/types'; 6 | import { Backend } from './'; 7 | 8 | /** 9 | * @external {WebSocket} https://developer.mozilla.org/en-US/docs/Web/API/WebSocket 10 | */ 11 | 12 | /** 13 | * Backend to communicate with a WebSocket server. 14 | * 15 | * This backend expects to talk directly to a JSON-RPC WebSocket backend. 16 | * Authentication has to be handled by the transport layer, there is no 17 | * support for that on the socket itself. 18 | * 19 | * Once the socket is open, it is expected to be in the ready state. 20 | * 21 | * @implements {Backend} 22 | */ 23 | export default class WSBackend extends EventEmitter implements Backend { 24 | private ws: WS | WebSocket; 25 | private isReady: Promise; 26 | 27 | /** 28 | * @param {Object|string} urlOrWS - WebSocket object or URL of the server. 29 | */ 30 | constructor(urlOrWS: WS | WebSocket | string) { 31 | super(); 32 | /** 33 | * @type {WebSocket} 34 | */ 35 | this.ws = typeof urlOrWS !== 'string' ? urlOrWS : new WebSocket(urlOrWS); 36 | this.ws.onclose = this.handleClose; 37 | this.ws.onerror = this.handleError; 38 | this.ws.onmessage = this.handleMessage; 39 | 40 | let resolveReady: (ready: boolean) => void; 41 | /** 42 | * @type {Promise} 43 | */ 44 | this.isReady = new Promise((r) => { 45 | resolveReady = r; 46 | }); 47 | this.ws.onopen = () => { 48 | this.emit('ready'); 49 | resolveReady(true); 50 | }; 51 | } 52 | 53 | public close() { 54 | this.ws.close(); 55 | } 56 | 57 | public execute(command: XapiRequest): Promise { 58 | return this.isReady.then(() => { 59 | log.debug('[transport] (send): ', JSON.stringify(command)); 60 | this.ws.send(JSON.stringify(command)); 61 | }); 62 | } 63 | 64 | private handleClose: WebSocket['onclose'] = (event) => { 65 | if (event.code !== 1000) { 66 | this.emit('error', 'WebSocket closed unexpectedly'); 67 | } else { 68 | this.emit('close'); 69 | } 70 | } 71 | 72 | private handleError: WebSocket['onerror'] = (error) => { 73 | this.emit('error', (error as ErrorEvent).error); 74 | } 75 | 76 | private handleMessage: WebSocket['onmessage'] = (message) => { 77 | log.debug('[transport] (receive): ', message.data); 78 | const data = JSON.parse(message.data as string); 79 | this.emit('data', data); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/browser.ts: -------------------------------------------------------------------------------- 1 | import { Backend } from './backend'; 2 | import WSBackend from './backend/ws'; 3 | import connectOverload from './connect'; 4 | import websocketConnect from './transport/ws'; 5 | import { Options } from './types'; 6 | import XAPI from './xapi'; 7 | 8 | export { default as XAPI } from './xapi'; 9 | 10 | function initBackend(opts: Options) { 11 | const { protocol } = opts; 12 | switch (protocol) { 13 | case '': 14 | case 'ws:': 15 | case 'wss:': { 16 | const createWebSocket = (url: string, auth: string) => { 17 | return new WebSocket(url, auth); 18 | }; 19 | const transport = websocketConnect(createWebSocket, opts); 20 | return new WSBackend(transport); 21 | } 22 | default: 23 | throw new Error(`Invalid protocol: ${protocol}`); 24 | } 25 | } 26 | 27 | export function connectGen(xapi: new (backend: Backend) => T) { 28 | return connectOverload(initBackend, { protocol: 'wss:' })(xapi); 29 | } 30 | 31 | /** 32 | * Connect to an XAPI endpoint. 33 | * 34 | * ```typescript 35 | * const xapi = connect('ssh://host.example.com:22'); 36 | * ``` 37 | * 38 | * @param url Connection specification. 39 | * @param options Connect options. 40 | * @return XAPI interface connected to the given URI. 41 | */ 42 | export const connect = connectGen(XAPI); 43 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import {Command, Option} from 'commander'; 4 | 5 | import * as fs from 'fs'; 6 | import * as path from 'path'; 7 | import * as REPL from 'repl'; 8 | import xdgBasedir from 'xdg-basedir'; 9 | 10 | import { connect } from './'; 11 | import log from './log'; 12 | import fetch from './schema/fetch'; 13 | import generate from './schema/generate'; 14 | import version from './version'; 15 | import XAPI from './xapi/index.js'; 16 | 17 | function evalFile(source: any, xapi: XAPI) { 18 | const context = new Function('xapi', source); 19 | context(xapi); 20 | } 21 | 22 | function startRepl(xapi: XAPI) { 23 | const repl = REPL.start({}); 24 | const { cache } = xdgBasedir; 25 | if (cache && repl.setupHistory) { 26 | const jsxapiCache = path.join(cache, 'jsxapi'); 27 | fs.mkdirSync(jsxapiCache, { recursive: true }); 28 | repl.setupHistory(path.join(jsxapiCache, 'history'), () => undefined); 29 | } 30 | repl.on('exit', () => xapi.close()); 31 | repl.context.xapi = xapi; 32 | } 33 | 34 | /** 35 | * Main entrypoint for the CLI application. 36 | * 37 | * See [[Options]] for options. 38 | */ 39 | function main() { 40 | const program = new Command(); 41 | program 42 | .command('generate-api ') 43 | .description('generate a typed XAPI based on schemas on ') 44 | .action(async (hosts) => { 45 | const xapis = hosts.map((host: string) => connect(host)); 46 | const docs = await fetch(xapis); 47 | // tslint:disable-next-line no-console 48 | console.log(generate(docs)); 49 | xapis.forEach((xapi: XAPI) => xapi.close()); 50 | }); 51 | 52 | program 53 | .version(version) 54 | .arguments(' [file]') 55 | .description('connect to a codec and launch a repl') 56 | .option('-p, --port ', 'port to connect to') 57 | .option('-U, --username ', 'username to authenticate with', 'admin') 58 | .option('-P, --password ', 'password to authenticate with', '') 59 | .option('-C, --command ', 'command to execute on remote host', '') 60 | .addOption(new Option( 61 | '-l, --loglevel ', 62 | 'set application log level', 63 | ).default('warn').choices(['trace', 'debug', 'info', 'warn', 'error', 'silent'])) 64 | .action((host, file, options) => { 65 | if (!host) { 66 | log.error('Please specify a host to connect to'); 67 | program.help(); 68 | } 69 | 70 | const source = file && fs.readFileSync(file); 71 | const xapi = connect( 72 | host, 73 | options, 74 | ) 75 | .on('error', (error: any) => { 76 | log.error('xapi error:', error); 77 | }) 78 | .on('ready', () => { 79 | if (source) { 80 | evalFile(source, xapi); 81 | } else { 82 | startRepl(xapi); 83 | } 84 | }); 85 | }) 86 | .parse(process.argv); 87 | } 88 | 89 | main(); 90 | -------------------------------------------------------------------------------- /src/connect.ts: -------------------------------------------------------------------------------- 1 | import Url from 'url-parse'; 2 | 3 | import { Backend } from './backend'; 4 | import log from './log'; 5 | import { InitBackend, Options } from './types'; 6 | import XAPI from './xapi'; 7 | 8 | export const globalDefaults: Options = { 9 | command: '', 10 | host: '', 11 | loglevel: 'warn', 12 | password: '', 13 | port: 0, 14 | protocol: '', 15 | username: 'admin', 16 | }; 17 | 18 | export interface Connect { 19 | (options: Partial): T; 20 | (url: string, options?: Partial): T; 21 | } 22 | 23 | function resolveOptions( 24 | targetDefaults: Partial, 25 | url: string, 26 | options: Options, 27 | ): Options { 28 | const realOpts: Options = { 29 | ...globalDefaults, 30 | ...targetDefaults, 31 | }; 32 | 33 | const urlWithProto = url.match(/^\w+:\/\//) 34 | ? url 35 | : `${realOpts.protocol}//${url}`; 36 | const parsedUrl = new Url(urlWithProto); 37 | 38 | Object.keys(realOpts).forEach((key) => { 39 | const value = [ 40 | (options as any)[key], 41 | key === 'host' ? parsedUrl.hostname : (parsedUrl as any)[key], 42 | ].filter((v) => !!v)[0]; 43 | if (value) { 44 | (realOpts as any)[key] = value; 45 | } 46 | }); 47 | 48 | return realOpts; 49 | } 50 | 51 | export default function connectOverload( 52 | initBackend: InitBackend, 53 | defaults: Partial, 54 | ): (XAPI: new (backend: Backend) => T) => Connect { 55 | return (xapi) => (...args: any[]) => { 56 | let url: string; 57 | let options: Options; 58 | 59 | if (args.length === 1 && typeof args[0] === 'object') { 60 | options = args[0]; 61 | url = ''; 62 | } else if (args.length === 1 && typeof args[0] === 'string') { 63 | options = globalDefaults; 64 | url = args[0]; 65 | } else if (args.length === 2) { 66 | url = args[0]; 67 | options = args[1]; 68 | } else { 69 | throw new Error(`Invalid arguments to connect`); 70 | } 71 | 72 | const opts = resolveOptions(defaults, url, options); 73 | 74 | log.setLevel(opts.loglevel); 75 | log.debug('using options:', opts); 76 | log.info('connecting to', url); 77 | 78 | const backend = initBackend(opts); 79 | return new xapi(backend); 80 | }; 81 | } 82 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import WS from 'ws'; 2 | 3 | import { Backend } from './backend'; 4 | import TSHBackend from './backend/tsh'; 5 | import WSBackend from './backend/ws'; 6 | import connectOverload from './connect'; 7 | import connectSSH from './transport/ssh'; 8 | import spawnTSH from './transport/tsh'; 9 | import websocketConnect from './transport/ws'; 10 | import { Options } from './types'; 11 | import XAPI from './xapi'; 12 | 13 | export { default as XAPI } from './xapi'; 14 | 15 | function initBackend(opts: Options) { 16 | const { host, port, protocol } = opts; 17 | switch (protocol) { 18 | case '': 19 | case 'ssh:': { 20 | const transport = connectSSH(opts); 21 | return new TSHBackend(transport); 22 | } 23 | case 'tsh:': { 24 | const transport = spawnTSH(host, port); 25 | return new TSHBackend(transport); 26 | } 27 | case 'ws:': 28 | case 'wss:': { 29 | const createWebSocket = (url: string, auth: string) => { 30 | const ws = new WS(url, auth, { 31 | followRedirects: true, 32 | rejectUnauthorized: false, 33 | } as any); 34 | return ws as any; 35 | }; 36 | const transport = websocketConnect(createWebSocket, opts); 37 | return new WSBackend(transport); 38 | } 39 | default: 40 | throw new Error(`Invalid protocol: ${protocol}`); 41 | } 42 | } 43 | 44 | export function connectGen(xapi: new (backend: Backend) => T) { 45 | return connectOverload(initBackend, { protocol: 'wss:' })(xapi); 46 | } 47 | 48 | /** 49 | * Connect to an XAPI endpoint. 50 | * 51 | * ```typescript 52 | * const xapi = connect('ssh://host.example.com:22'); 53 | * ``` 54 | * 55 | * @param url Connection specification. 56 | * @param options Connect options. 57 | * @return XAPI interface connected to the given URI. 58 | */ 59 | export const connect = connectGen(XAPI); 60 | -------------------------------------------------------------------------------- /src/json-parser.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from 'stream'; 2 | 3 | /// 4 | import Parser from 'jsonparse'; 5 | 6 | /** 7 | * Streaming JSON parser. Implements the Node.js {@link Duplex} stream API. 8 | */ 9 | export class JSONParser extends Transform { 10 | private enc = 'utf8'; // Default encoding 11 | private parser: Parser = new Parser(); 12 | 13 | constructor() { 14 | super({ objectMode: true }); 15 | this.reset(); 16 | } 17 | 18 | public _flush(callback: () => void) { 19 | if (this.parser.stack.length) { 20 | this.onError(new Error('Unexpected end of input')); 21 | } 22 | callback(); 23 | } 24 | 25 | private reset() { 26 | this.parser = new Parser(); 27 | this.parser.onError = this.onError.bind(this); 28 | this.parser.onValue = this.onValue.bind(this); 29 | } 30 | 31 | private onError(e: any) { 32 | this.emit('error', e); 33 | this.reset(); 34 | } 35 | 36 | private onValue(value: any) { 37 | if (!this.parser.stack.length) { 38 | this.push(value); 39 | } 40 | } 41 | 42 | // tslint:disable-next-line 43 | public _transform(chunk: any, _encoding: any, callback: () => void) { 44 | const data: string = chunk.toString(this.enc); 45 | data.split(/\n/).forEach((line) => { 46 | try { 47 | this.parser.write(line); 48 | } catch (error) { 49 | this.onError(error); 50 | } 51 | }); 52 | callback(); 53 | } 54 | } 55 | 56 | /** 57 | * Synchronous frontend to {@link JSONparser}. 58 | * 59 | * @param json JSON string input. 60 | * @return Parsed JSON object. 61 | */ 62 | export function parseJSON(json: string) { 63 | let obj; 64 | const parser = new JSONParser(); 65 | 66 | parser.on('data', (next) => { 67 | obj = next; 68 | }); 69 | parser.end(json); 70 | 71 | return obj; 72 | } 73 | -------------------------------------------------------------------------------- /src/log.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This module basically patches the `loglevel` library with some niceities for 3 | * named loggers. Named loggers prefix the log output with their log name and 4 | * their level can be set independently. 5 | */ 6 | 7 | import loglevel from 'loglevel'; 8 | 9 | if (!(loglevel as any).isPatched) { 10 | const origMethodFactory = loglevel.methodFactory; 11 | const loggers = new Set(); 12 | 13 | /* 14 | * Bless the `log` object with custom plugins 15 | */ 16 | Object.assign(loglevel, { 17 | isPatched: true, 18 | 19 | methodFactory(methodName: loglevel.LogLevelNames, logLevel: any, loggerName: string) { 20 | if (loggerName) { 21 | loggers.add(loggerName); 22 | } 23 | const rawMethod = origMethodFactory(methodName, logLevel, loggerName); 24 | return (...args: any[]) => { 25 | rawMethod(`[${loggerName || 'root'}]`, ...args); 26 | }; 27 | }, 28 | 29 | /* 30 | * Returns a list of logger names, excluding the root logger. 31 | */ 32 | getLoggers() { 33 | return Array.from(loggers); 34 | }, 35 | 36 | setGlobalLevel(level: loglevel.LogLevelDesc) { 37 | const allLoggers = [loglevel].concat( 38 | (loglevel as any) 39 | .getLoggers() 40 | .map((name: string) => loglevel.getLogger(name)), 41 | ); 42 | 43 | allLoggers.forEach((logger) => { 44 | logger.setLevel(level); 45 | }); 46 | }, 47 | 48 | setLevelTrace() { 49 | (loglevel as any).setGlobalLevel('trace'); 50 | }, 51 | setLevelDebug() { 52 | (loglevel as any).setGlobalLevel('debug'); 53 | }, 54 | setLevelInfo() { 55 | (loglevel as any).setGlobalLevel('info'); 56 | }, 57 | setLevelWarn() { 58 | (loglevel as any).setGlobalLevel('warn'); 59 | }, 60 | setLevelError() { 61 | (loglevel as any).setGlobalLevel('error'); 62 | }, 63 | }); 64 | 65 | // Required to apply the plugin to log 66 | loglevel.setLevel(loglevel.getLevel()); 67 | } 68 | 69 | export default loglevel.getLogger('jsxapi'); 70 | -------------------------------------------------------------------------------- /src/schema/fetch.ts: -------------------------------------------------------------------------------- 1 | import XAPI from '../xapi'; 2 | import { flatten } from './utils'; 3 | 4 | export default async function fetch(xapis: XAPI | XAPI[]) { 5 | xapis = Array.isArray(xapis) ? xapis : [xapis]; 6 | 7 | const paths = [ 8 | 'Command', 9 | 'Configuration', 10 | 'Event', 11 | 'Status', 12 | ]; 13 | 14 | const requests = xapis.map((xapi) => paths.map(async (path) => { 15 | const doc = await xapi.doc(path); 16 | const key = path === 'Status' ? 'StatusSchema' : path; 17 | return { [key]: doc }; 18 | })); 19 | 20 | return await Promise.all(flatten(requests)); 21 | } 22 | -------------------------------------------------------------------------------- /src/schema/generate.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { parse } from './'; 3 | import { filter, merge } from './utils'; 4 | 5 | export default function generate(schemas: object[]) { 6 | const fullSchema = schemas.reduce((schema, json) => { 7 | return merge(schema, filter(json, ['public-api'])); 8 | }, {}); 9 | 10 | return parse(fullSchema).serialize(); 11 | } 12 | 13 | if (module === require.main) { 14 | if (process.argv.length < 3) { 15 | throw new Error('usage: ...[schema.json]'); 16 | } 17 | 18 | const json = process.argv.slice(2) 19 | .map((filename) => { 20 | const data = fs.readFileSync(filename, 'utf8'); 21 | return JSON.parse(data); 22 | }); 23 | 24 | // tslint:disable-next-line no-console 25 | console.log(generate(json)); 26 | } 27 | -------------------------------------------------------------------------------- /src/schema/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArrayTree, 3 | Command, 4 | Generic, 5 | List, 6 | Literal, 7 | Member, 8 | Node, 9 | Plain, 10 | Root, 11 | Tree, 12 | Type, 13 | } from './nodes'; 14 | 15 | export interface GenerateOpts { 16 | access: 'public-api' | 'public-api-preview'; 17 | role: 'Admin' | 'User' | 'Integrator' | 'RoomControl'; 18 | mainClass?: string; 19 | withConnect: boolean; 20 | xapiImport: string; 21 | } 22 | 23 | interface Leaf { 24 | description?: string; 25 | ValueSpace: ValueSpace; 26 | } 27 | 28 | interface CommandLeaf extends Leaf { 29 | command?: 'True'; 30 | multiline?: 'True' | 'False'; 31 | } 32 | 33 | interface EventLeaf extends Leaf { 34 | type?: 'int' | 'literal' | 'string'; 35 | } 36 | 37 | interface ValueSpace { 38 | required: 'True' | 'False'; 39 | type: 'Integer' | 'IntegerArray' | 'Literal' | 'LiteralArray' | 'String' | 'StringArray'; 40 | Value?: string[]; 41 | } 42 | 43 | function parseEventType(type: EventLeaf['type'], path: string[]): Type { 44 | switch (type) { 45 | case 'int': 46 | return new Plain('number'); 47 | case 'literal': 48 | case 'string': 49 | return new Plain('string'); 50 | default: 51 | throw new Error(`Invalid Event type: ${type}`); 52 | } 53 | } 54 | 55 | /** 56 | * Parse a valuespace into a Type definition. 57 | */ 58 | function parseValueSpace(valuespace: ValueSpace, path: string[]): Type { 59 | switch (valuespace.type) { 60 | case 'Integer': 61 | return new Plain('number'); 62 | case 'IntegerArray': 63 | return new Plain('number[]'); 64 | case 'String': 65 | return new Plain('string'); 66 | case 'StringArray': 67 | return new Plain('string[]'); 68 | case 'Literal': 69 | case 'LiteralArray': 70 | if (!valuespace.Value) { 71 | throw new Error('Missing literal valuespace values'); 72 | } 73 | if (!valuespace.Value.length) { 74 | throw new Error('Empty literal valuespace values'); 75 | } 76 | const vs = new Literal(...valuespace.Value); 77 | return valuespace.type === 'LiteralArray' ? new List(vs) : vs; 78 | default: 79 | throw new Error(`Invalid ValueSpace type: ${valuespace.type}`); 80 | } 81 | } 82 | 83 | /** 84 | * Check if an object is a command definition. 85 | * 86 | * Command have { command: 'True' } in the schema. 87 | */ 88 | function isCommandLeaf(value: unknown): value is CommandLeaf { 89 | return !!value && (value as CommandLeaf).command === 'True'; 90 | } 91 | 92 | /** 93 | * Check if an object is an event definition. 94 | * 95 | * Events have { event: 'True' } in the schema - however, the XAPI allows to 96 | * subscribe even more granularly to attributes of events. We consider a leaf to 97 | * be the attributes with scalar values. 98 | */ 99 | function isEventLeaf(value: unknown): value is EventLeaf { 100 | return 'type' in (value as Leaf); 101 | } 102 | 103 | /** 104 | * Check if an object is a configuration definition. 105 | */ 106 | function isLeaf(value: unknown): value is Leaf { 107 | return 'ValueSpace' in (value as Leaf); 108 | } 109 | 110 | /** 111 | * Check if a key of an object is considered an attribute and not a child node. 112 | * 113 | * The schema convention is that all keys starting with lowercase are considered 114 | * attributes. 115 | */ 116 | function isAttr(key: string): boolean { 117 | return !!key.match(/^[a-z]/); 118 | } 119 | 120 | function forEachEntries( 121 | schema: any, 122 | visitor: (key: string, value: any) => void, 123 | ) { 124 | Object.entries(schema) 125 | .filter(([key]) => !isAttr(key)) 126 | .forEach(([key, value]) => visitor(key, value)); 127 | } 128 | 129 | /** 130 | * Parse command parameters. 131 | */ 132 | function parseParameters(command: CommandLeaf, path: string[]): Member[] { 133 | const params: Member[] = []; 134 | 135 | forEachEntries(command, (key, value) => { 136 | const fullPath = path.concat(key); 137 | try { 138 | const ps = Array.isArray(value) ? value[0] : value; 139 | const valuespace = parseValueSpace(ps.ValueSpace, fullPath); 140 | const required = ps.required === 'True'; 141 | params.push(new Member(key, valuespace, { required })); 142 | } catch (error) { 143 | // tslint:disable-next-line no-console 144 | console.error(`warning: '${fullPath.join('/')}' error parsing valuespace: ${error}`); 145 | } 146 | }); 147 | 148 | return params; 149 | } 150 | 151 | /** 152 | * Parse the recursive tree of command definitions. 153 | */ 154 | function parseCommandTree(root: Root, schema: any, tree: Node, path: string[]) { 155 | forEachEntries(schema, (key, value) => { 156 | if (isCommandLeaf(value)) { 157 | const fullPath = path.concat(key); 158 | const params = parseParameters(value, fullPath); 159 | const paramsType = !params.length 160 | ? undefined 161 | : root.addInterface(`${fullPath.join('')}Args`); 162 | if (paramsType) { 163 | paramsType.addChildren(params); 164 | } 165 | tree.addChild(new Command(key, paramsType, undefined, { 166 | docstring: value.description || '', 167 | multiline: !!value.multiline && value.multiline === 'True', 168 | })); 169 | } else { 170 | const subTree = tree.addChild(new Tree(key)); 171 | parseCommandTree(root, value, subTree, path.concat(key)); 172 | } 173 | }); 174 | } 175 | 176 | /** 177 | * Parse the recursive tree of configuration definitions. 178 | */ 179 | function parseConfigTree(root: Root, schema: any, tree: Node, path: string[]) { 180 | forEachEntries(schema, (key, value) => { 181 | const fullPath = path.concat(key); 182 | if (isLeaf(value)) { 183 | const vs = parseValueSpace(value.ValueSpace, fullPath); 184 | tree.addChild(new Member(key, vs, { docstring: value.description || '' })); 185 | } else if (Array.isArray(value)) { 186 | const subTree = tree.addChild(new Tree(key)); 187 | for (const each of value) { 188 | const id = each.id; 189 | const idTree = subTree.addChild(new Tree(id)); 190 | parseConfigTree(root, each, idTree, path.concat([key, id])); 191 | } 192 | } else { 193 | const subTree = tree.addChild(new Tree(key)); 194 | parseConfigTree(root, value, subTree, path.concat(key)); 195 | } 196 | }); 197 | } 198 | 199 | /** 200 | * Parse the recursive tree of event definitions. 201 | */ 202 | function parseEventTree(root: Root, schema: any, tree: Node, path: string[]) { 203 | forEachEntries(schema, (key, value) => { 204 | const fullPath = path.concat(key); 205 | if (isEventLeaf(value)) { 206 | const vs = parseEventType(value.type, fullPath); 207 | tree.addChild(new Member(key, vs, { docstring: value.description || '' })); 208 | } else if (Array.isArray(value)) { 209 | const subTree = tree.addChild(new ArrayTree(key)); 210 | for (const item of value) { 211 | parseEventTree(root, item, subTree, path.concat([key])); 212 | } 213 | } else { 214 | const subTree = tree.addChild(new Tree(key)); 215 | parseEventTree(root, value, subTree, path.concat(key)); 216 | } 217 | }); 218 | } 219 | 220 | /** 221 | * Parse the recursive tree of status definitions. 222 | */ 223 | function parseStatusTree(root: Root, schema: any, tree: Node, path: string[]) { 224 | forEachEntries(schema, (key, value) => { 225 | const fullPath = path.concat(key); 226 | if (isLeaf(value)) { 227 | const vs = parseValueSpace(value.ValueSpace, fullPath); 228 | tree.addChild(new Member(key, vs, { docstring: value.description || '' })); 229 | } else if (Array.isArray(value)) { 230 | if (value.length !== 1) { 231 | throw new Error(`error: ${fullPath.join('/')} contains multiple entries`); 232 | } 233 | const subTree = tree.addChild(new ArrayTree(key)); 234 | parseStatusTree(root, value[0], subTree, path.concat([key])); 235 | } else { 236 | const subTree = tree.addChild(new Tree(key)); 237 | parseStatusTree(root, value, subTree, path.concat(key)); 238 | } 239 | }); 240 | } 241 | 242 | /** 243 | * A parsing function to parse a document subtree. 244 | */ 245 | type SchemaParser = (root: Root, schema: any, tree: Tree, path: string[]) => void; 246 | 247 | /** 248 | * Generic function to parse a schema tree. 249 | * 250 | * @type The type of document to parse in the schema. 251 | * @root The root node of the generated module. 252 | * @schema Full schema definition. 253 | * @parser A parsing function to parse a subtree of 'type'. 254 | */ 255 | function parseSchema( 256 | type: 'Command' | 'Config' | 'Event' | 'Status', 257 | root: Root, 258 | schema: any, 259 | parser: SchemaParser, 260 | ) { 261 | const { rootKey, mkType } = { 262 | Command: { 263 | mkType: (t: Type) => t, 264 | rootKey: 'Command', 265 | }, 266 | Config: { 267 | mkType: (t: Type) => new Generic('Configify', t), 268 | rootKey: 'Configuration', 269 | }, 270 | Event: { 271 | mkType: (t: Type) => new Generic('Eventify', t), 272 | rootKey: 'Event', 273 | }, 274 | Status: { 275 | mkType: (t: Type) => new Generic('Statusify', t), 276 | rootKey: 'StatusSchema', 277 | }, 278 | }[type]; 279 | const subSchema = schema[rootKey]; 280 | 281 | if (!subSchema) { 282 | return; 283 | } 284 | 285 | if (typeof subSchema !== 'object') { 286 | throw new Error(`schema.${type} is not an object`); 287 | } 288 | 289 | const tree = root.addInterface(`${type}Tree`); 290 | root.getMain().addChild(new Member(type, mkType(tree))); 291 | 292 | parser(root, subSchema, tree, [type]); 293 | } 294 | 295 | /** 296 | * Parse and generate a module of a schema definition. 297 | */ 298 | export function parse(schema: any, options: Partial = {}): Root { 299 | const opts: GenerateOpts = { 300 | access: 'public-api', 301 | role: 'Admin', 302 | withConnect: true, 303 | xapiImport: 'jsxapi', 304 | ...options, 305 | }; 306 | 307 | const root = new Root(opts.xapiImport); 308 | 309 | // Main XAPI class 310 | root.addMain(opts.mainClass, { withConnect: opts.withConnect }); 311 | root.addGenericInterfaces(); 312 | 313 | parseSchema('Command', root, schema, parseCommandTree); 314 | parseSchema('Config', root, schema, parseConfigTree); 315 | parseSchema('Event', root, schema, parseEventTree); 316 | parseSchema('Status', root, schema, parseStatusTree); 317 | 318 | return root; 319 | } 320 | 321 | /** 322 | * Serialize a TypeScript module from a schema definition. 323 | */ 324 | export function generate(schema: any, options: Partial = {}) { 325 | const root = parse(schema, options); 326 | return root.serialize(); 327 | } 328 | -------------------------------------------------------------------------------- /src/schema/nodes.ts: -------------------------------------------------------------------------------- 1 | import redent from 'redent'; 2 | 3 | export abstract class Node { 4 | protected children: Node[]; 5 | 6 | constructor() { 7 | this.children = []; 8 | } 9 | 10 | public addChild(child: T): T { 11 | this.children.push(child); 12 | return child; 13 | } 14 | 15 | public addChildren(children: T[]) { 16 | this.children = this.children.concat(children); 17 | } 18 | 19 | public abstract serialize(): string; 20 | } 21 | 22 | export class Root extends Node { 23 | private imports = new Imports(); 24 | private interfaceNames = new Set(); 25 | private main?: MainClass; 26 | 27 | constructor(readonly libName: string = 'jsxapi') { 28 | super(); 29 | this.addChild(this.imports); 30 | } 31 | 32 | public addChild(child: T): T { 33 | if (child instanceof Interface) { 34 | if (this.interfaceNames.has(child.name)) { 35 | throw new Error(`Interface already exists: ${child.name}`); 36 | } 37 | this.interfaceNames.add(child.name); 38 | } 39 | return super.addChild(child); 40 | } 41 | 42 | public addImports(path: string, imports: string[]) { 43 | const fullPath = [this.libName, path].filter((x) => !!x).join('/'); 44 | this.imports.addImports(fullPath, imports); 45 | } 46 | 47 | public addInterface(name: string, extend: string[] = []): Interface { 48 | const missing = extend.filter((e) => !this.interfaceNames.has(e)); 49 | if (missing.length) { 50 | throw new Error(`Cannot add interface ${name} due to missing interfaces: ${missing.join(', ')}`); 51 | } 52 | return this.addChild(new Interface(name, extend)); 53 | } 54 | 55 | public addMain(name?: string, options: Partial = {}): MainClass { 56 | if (this.main) { 57 | throw new Error('Main class already defined'); 58 | } 59 | const main = this.addChild(new MainClass(this, name, options)); 60 | this.main = main; 61 | return main; 62 | } 63 | 64 | public getMain(): MainClass { 65 | if (!this.main) { 66 | throw new Error('No main class defined'); 67 | } 68 | return this.main; 69 | } 70 | 71 | public addGenericInterfaces() { 72 | const templateParam = new Plain('T'); 73 | const gettable = this.addInterface('Gettable'); 74 | gettable.addChild( 75 | new Function('get', [], new Generic('Promise', templateParam)), 76 | ); 77 | 78 | const settable = this.addInterface('Settable'); 79 | settable.addChild( 80 | new Function('set', [['value', templateParam]], new Generic('Promise', 'void')), 81 | ); 82 | 83 | this.addImports('lib/xapi/feedback', ['Registration']); 84 | const registration = new Plain('Registration'); 85 | const listenable = this.addInterface('Listenable'); 86 | const handler = new Function('handler', [['value', new Plain('T')]]); 87 | listenable.addChildren([ 88 | new Function('on', [['handler', handler]], registration), 89 | new Function('once', [['handler', handler]], registration), 90 | ]); 91 | 92 | this.addChild(new class extends Node { 93 | public serialize() { 94 | return `\ 95 | type Configify = [T] extends [object] 96 | ? { [P in keyof T]: Configify; } & Gettable & Listenable 97 | : Gettable & Settable & Listenable;`; 98 | } 99 | }()); 100 | 101 | this.addChild(new class extends Node { 102 | public serialize() { 103 | return `\ 104 | type Eventify = { [P in keyof T]: Eventify; } & Listenable;`; 105 | } 106 | }()); 107 | 108 | this.addChild(new class extends Node { 109 | public serialize() { 110 | return `\ 111 | type Statusify = { [P in keyof T]: Statusify; } & Gettable & Listenable;`; 112 | } 113 | }()); 114 | } 115 | 116 | public serialize(): string { 117 | const lines = []; 118 | for (const child of this.children) { 119 | lines.push(child.serialize()); 120 | } 121 | return lines.join('\n\n'); 122 | } 123 | } 124 | 125 | export class Imports extends Node { 126 | private imports = new Map(); 127 | 128 | public addImports(path: string, imports: string[]) { 129 | let importStatement = this.imports.get(path); 130 | 131 | if (!importStatement) { 132 | importStatement = new ImportStatement(path); 133 | this.imports.set(path, importStatement); 134 | this.addChild(importStatement); 135 | } 136 | 137 | importStatement.addImports(imports); 138 | return importStatement; 139 | } 140 | 141 | public serialize() { 142 | return Array.from(this.imports.values()) 143 | .map((i) => i.serialize()) 144 | .join('\n'); 145 | } 146 | } 147 | 148 | export class ImportStatement extends Node { 149 | private imports: Set; 150 | 151 | constructor(readonly moduleName: string, imports?: string[]) { 152 | super(); 153 | this.imports = new Set(imports || []); 154 | } 155 | 156 | public addImports(imports: string[]) { 157 | for (const name of imports) { 158 | this.imports.add(name); 159 | } 160 | } 161 | 162 | public serialize(): string { 163 | const imports = Array.from(this.imports); 164 | return `import { ${imports.join(', ')} } from "${this.moduleName}";`; 165 | } 166 | } 167 | 168 | function renderTree(nodes: Node[], terminator: string) { 169 | const serialized = nodes.map((n) => `${n.serialize()}${terminator}`); 170 | if (serialized.length) { 171 | serialized.unshift(''); 172 | serialized.push(''); 173 | } 174 | return redent(serialized.join('\n'), 2); 175 | } 176 | 177 | export type Valuespace = Type | string; 178 | 179 | function vsToType(vs: Valuespace): Type { 180 | return typeof vs === 'string' ? new Plain(vs) : vs; 181 | } 182 | 183 | export interface Type { 184 | getType(): string; 185 | } 186 | 187 | export class Plain implements Type { 188 | constructor(readonly text: string) {} 189 | 190 | public getType() { 191 | return this.text; 192 | } 193 | } 194 | 195 | export class Generic implements Type { 196 | private name: Type; 197 | private inner: Type; 198 | 199 | constructor(name: Valuespace, inner: Valuespace) { 200 | this.name = vsToType(name); 201 | this.inner = vsToType(inner); 202 | } 203 | 204 | public getType() { 205 | return `${this.name.getType()}<${this.inner.getType()}>`; 206 | } 207 | } 208 | 209 | export class Function extends Node implements Type { 210 | constructor( 211 | readonly name: string, 212 | readonly args: [string, Type][] = [], 213 | readonly ret: Type = new Plain('void'), 214 | ) { 215 | super(); 216 | } 217 | 218 | public getType(separator: string = ' =>') { 219 | const args = this.args.map(([n, t]) => `${n}: ${t.getType()}`).join(', '); 220 | const ret = this.ret.getType(); 221 | return `(${args})${separator} ${ret}`; 222 | } 223 | 224 | public serialize() { 225 | return `${this.name}${this.getType(':')}`; 226 | } 227 | } 228 | 229 | export class List implements Type { 230 | constructor(readonly elementType: Type) {} 231 | 232 | public getType() { 233 | const elemType = this.elementType.getType(); 234 | const withParens = 235 | this.elementType instanceof Literal ? `(${elemType})` : elemType; 236 | return `${withParens}[]`; 237 | } 238 | } 239 | 240 | export class Literal implements Type { 241 | private members: Type[]; 242 | 243 | constructor(...members: Valuespace[]) { 244 | this.members = members.map((m) => { 245 | if (typeof m === 'string') { 246 | return new Plain(`'${m}'`); 247 | } 248 | return m; 249 | }); 250 | } 251 | 252 | public getType() { 253 | return this.members.map((m) => m.getType()).join(' | '); 254 | } 255 | } 256 | 257 | export class Interface extends Node implements Type { 258 | constructor(readonly name: string, readonly extend: string[] = []) { 259 | super(); 260 | } 261 | 262 | public getType(): string { 263 | return this.name; 264 | } 265 | 266 | public allOptional(): boolean { 267 | return !this.children.some((child) => { 268 | return !(child instanceof Member) || child.isRequired; 269 | }); 270 | } 271 | 272 | public serialize(): string { 273 | const ext = this.extend.length ? ` extends ${this.extend.join(', ')}` : ''; 274 | const tree = renderTree(this.children, ';'); 275 | return `export interface ${this.name}${ext} {${tree}}`; 276 | } 277 | } 278 | 279 | export interface MainOptions { 280 | base: string; 281 | withConnect: boolean; 282 | } 283 | 284 | export class MainClass extends Interface { 285 | private connectGen = 'connectGen'; 286 | private readonly options: MainOptions; 287 | 288 | constructor(root: Root, readonly name: string = 'TypedXAPI', options: Partial = {}) { 289 | super(name); 290 | this.options = { 291 | base: 'XAPI', 292 | withConnect: true, 293 | ...options, 294 | }; 295 | const imports = [this.options.base]; 296 | if (this.options.withConnect) { 297 | imports.push(this.connectGen); 298 | } 299 | root.addImports('', imports); 300 | } 301 | 302 | public serialize(): string { 303 | const exports = [`export default ${this.name};`]; 304 | if (this.options.withConnect) { 305 | exports.push(`export const connect = ${this.connectGen}(${this.name});`); 306 | } 307 | return `\ 308 | export class ${this.name} extends ${this.options.base} {} 309 | 310 | ${exports.join('\n')} 311 | 312 | ${super.serialize()} `; 313 | } 314 | } 315 | 316 | export interface MemberOpts { 317 | docstring: string; 318 | required: boolean; 319 | } 320 | 321 | export class Member extends Node { 322 | private type: Type; 323 | private options: MemberOpts; 324 | 325 | constructor( 326 | readonly name: string, 327 | type: Valuespace, 328 | options?: Partial, 329 | ) { 330 | super(); 331 | this.type = vsToType(type); 332 | this.options = { 333 | docstring: '', 334 | required: true, 335 | ...options, 336 | }; 337 | } 338 | 339 | get isRequired() { 340 | return this.options.required; 341 | } 342 | 343 | public formatDocstring() { 344 | if (!this.options.docstring) { 345 | return ''; 346 | } 347 | 348 | return `/** 349 | ${this.options.docstring} 350 | */ 351 | `; 352 | } 353 | 354 | public serialize(): string { 355 | const optional = !('required' in this.options) || this.options.required ? '' : '?'; 356 | const name = this.name.match(/^[a-z][a-z0-9]*$/i) 357 | ? this.name 358 | : `"${this.name}"`; 359 | return `${this.formatDocstring()}${name}${optional}: ${this.type.getType()}`; 360 | } 361 | } 362 | 363 | export class Tree extends Node { 364 | constructor(readonly name: string) { 365 | super(); 366 | } 367 | 368 | public serialize(): string { 369 | const tree = renderTree(this.children, ','); 370 | return `${this.name}: {${tree}}`; 371 | } 372 | } 373 | 374 | export class ArrayTree extends Tree { 375 | public serialize(): string { 376 | return `${super.serialize()}[]`; 377 | } 378 | } 379 | 380 | export interface CommandOpts { 381 | docstring: string; 382 | multiline: boolean; 383 | } 384 | 385 | export class Command extends Node { 386 | private retval?: Type; 387 | private options: CommandOpts; 388 | 389 | constructor( 390 | readonly name: string, 391 | readonly params?: Interface, 392 | retval?: Valuespace, 393 | options?: Partial, 394 | ) { 395 | super(); 396 | if (retval) { 397 | this.retval = vsToType(retval); 398 | } 399 | this.options = { 400 | docstring: '', 401 | multiline: false, 402 | ...options, 403 | }; 404 | } 405 | 406 | public formatDocstring(): string { 407 | if (!this.options || !this.options.docstring) { 408 | return ''; 409 | } 410 | 411 | return `/** 412 | ${this.options.docstring} 413 | */ 414 | `; 415 | } 416 | 417 | public serialize(): string { 418 | const args = []; 419 | const hasBody = this.options.multiline; 420 | if (this.params) { 421 | const argsType = this.params.getType(); 422 | const optional = !hasBody && this.params.allOptional() ? '?' : ''; 423 | args.push(`args${optional}: ${argsType}`); 424 | } 425 | if (hasBody) { 426 | args.push('body: string'); 427 | } 428 | const argString = args.join(', '); 429 | const retval = this.retval ? this.retval.getType() : 'any'; 430 | return `${this.formatDocstring()}${this.name}(${argString}): Promise`; 431 | } 432 | } 433 | -------------------------------------------------------------------------------- /src/schema/utils.ts: -------------------------------------------------------------------------------- 1 | export function filter(schema: object, access: string[]) { 2 | const result: any = {}; 3 | 4 | for (const [key, value] of Object.entries(schema)) { 5 | if (Array.isArray(value)) { 6 | result[key] = value.map((v) => filter(v, access)); 7 | } else if (typeof value !== 'object') { 8 | result[key] = value; 9 | } else if (!value.hasOwnProperty('access')) { 10 | const subtree = filter(value, access); 11 | if (Object.keys(subtree).length) { 12 | result[key] = subtree; 13 | } 14 | } else if (access.includes(value.access)) { 15 | result[key] = value; 16 | } 17 | } 18 | 19 | return result; 20 | } 21 | 22 | export function flatten(arr: T[][]): T[] { 23 | return ([] as T[]).concat(...arr); 24 | } 25 | 26 | export function merge(a: object, b: object, path: string[] = []) { 27 | const result: any = { ...a }; 28 | 29 | for (const [key, value] of Object.entries(b)) { 30 | const fullPath = path.concat(key); 31 | const pathStr = fullPath.join('.'); 32 | 33 | if (!result.hasOwnProperty(key)) { 34 | result[key] = value; 35 | } else if (typeof value !== typeof result[key]) { 36 | throw new Error(`Mismatching types: ${pathStr}`); 37 | } else if (Array.isArray(value)) { 38 | if (!Array.isArray(result[key])) { 39 | throw new Error(`Unexpected array: ${pathStr}`); 40 | } 41 | if (typeof result[key][0] === 'object') { 42 | result[key][0] = merge(result[key][0], value[0], fullPath); 43 | } else { 44 | for (const entry of value) { 45 | if (!result[key].includes(entry)) { 46 | result[key].push(entry); 47 | } 48 | } 49 | } 50 | } else if (typeof value === 'object') { 51 | result[key] = merge(result[key], value, fullPath); 52 | } else if (result[key] !== value) { 53 | // tslint:disable-next-line no-console 54 | console.error(`Warning: Mismatch on value for ${pathStr}`); 55 | } 56 | } 57 | 58 | return result; 59 | } 60 | -------------------------------------------------------------------------------- /src/transport/duplexer.d.ts: -------------------------------------------------------------------------------- 1 | declare type Stream = import('stream').Stream; 2 | declare type Duplex = import('stream').Duplex; 3 | 4 | declare module 'duplexer' { 5 | function duplex(stdin: Stream, stdout: Stream): Duplex; 6 | export default duplex; 7 | } 8 | -------------------------------------------------------------------------------- /src/transport/ssh.ts: -------------------------------------------------------------------------------- 1 | import DuplexPassThrough from 'duplex-passthrough'; 2 | import { Client, ConnectConfig } from 'ssh2'; 3 | 4 | import { Stream } from 'stream'; 5 | import log from '../log'; 6 | 7 | /* 8 | * Patch DuplexPassThrough.prototype.on to always return "this". 9 | * For some event types it delegates and return the return value of 10 | * the contained reader/writer instances. 11 | */ 12 | if (!DuplexPassThrough.prototype.on.isPatched) { 13 | const origOn = DuplexPassThrough.prototype.on; 14 | const patchedFunc = function on(...args: any[]) { 15 | origOn.call(this, ...args); 16 | return this; 17 | }; 18 | (patchedFunc as any).isPatched = true; 19 | DuplexPassThrough.prototype.on = patchedFunc; 20 | DuplexPassThrough.prototype.addListener = patchedFunc; 21 | } 22 | 23 | export interface SshOptions { 24 | username?: string; 25 | client: any; 26 | password?: string; 27 | transport: any; 28 | command?: string; 29 | } 30 | 31 | /** 32 | * Creates a {@link Duplex} SSH stream. 33 | * 34 | * @param {object} options 35 | * @param {string} options.host - Hostname or IP address. 36 | * @param {number} options.port - Port to connect to. 37 | * @param {string} options.username - Username used in authentication. 38 | * @param {string} options.password - Password used in authentication. 39 | * @param {string} options.command 40 | * - If command is specified, it is executed on the remote host instead of a login shell. 41 | * @return {Duplex} - SSH stream. 42 | */ 43 | export default function connectSSH(options: Partial) { 44 | let closing = false; 45 | 46 | const mergedOpts: SshOptions & ConnectConfig = Object.assign( 47 | { 48 | client: new Client(), 49 | transport: new DuplexPassThrough(), 50 | }, 51 | options, 52 | ); 53 | 54 | const { client, password, transport } = mergedOpts; 55 | delete mergedOpts.password; 56 | 57 | function onKeyboardInteractive( 58 | n: any, 59 | i: any, 60 | il: any, 61 | p: any, 62 | finish: (args: any[]) => void, 63 | ) { 64 | finish([password]); 65 | } 66 | 67 | function onReady() { 68 | log.debug('[SSH] connection ready'); 69 | client.shell(false, (err: any, sshStream: any) => { 70 | if (err) { 71 | log.error('[SSH] shell error:', err); 72 | transport.emit('error', err); 73 | return; 74 | } 75 | 76 | log.debug('[SSH] shell ready'); 77 | sshStream 78 | .on('error', (error: any) => { 79 | transport.emit('error', error); 80 | }) 81 | .on('end', () => { 82 | if (!closing) { 83 | transport.emit('error', 'Connection terminated remotely'); 84 | } 85 | }) 86 | .on('close', () => { 87 | transport.emit('close'); 88 | }); 89 | 90 | if (options.command) { 91 | client.exec( 92 | options.command, 93 | (binaryErr: string, binaryStream: Stream) => { 94 | if (binaryErr) { 95 | log.error('[SSH] exec error:', err); 96 | transport.emit('error', binaryErr); 97 | return; 98 | } 99 | binaryStream.on('error', (error) => { 100 | log.error('[SSH] stream error:', error); 101 | transport.emit('error', error); 102 | }); 103 | log.debug('[SSH] exec ready'); 104 | transport.wrapStream(binaryStream); 105 | }, 106 | ); 107 | return; 108 | } 109 | 110 | transport.wrapStream(sshStream); 111 | }); 112 | } 113 | 114 | const agentSock = process.env.SSH_AUTH_SOCK; 115 | if (agentSock) { 116 | log.info(`Using SSH agent socket "${agentSock}"`); 117 | mergedOpts.agent = agentSock; 118 | } 119 | 120 | client 121 | .on('keyboard-interactive', onKeyboardInteractive) 122 | .on('ready', onReady) 123 | .on('error', (error: any) => { 124 | transport.emit('error', error.level); 125 | }) 126 | .on('close', () => { 127 | transport.emit('close'); 128 | }) 129 | .connect(Object.assign({ tryKeyboard: true }, mergedOpts)); 130 | 131 | transport.close = () => { 132 | closing = true; 133 | client.end(); 134 | }; 135 | 136 | return transport; 137 | } 138 | -------------------------------------------------------------------------------- /src/transport/stream.ts: -------------------------------------------------------------------------------- 1 | import { Duplex, DuplexOptions } from 'stream'; 2 | 3 | /** 4 | * Duplex stream transport for integrations where JSXAPI does not have direct 5 | * access to the network or local ipc. E.g. for sandboxing or test stubs. 6 | */ 7 | export default class StreamTransport extends Duplex { 8 | private buffer: string[] = []; 9 | private canPush = false; 10 | /** 11 | * Creates a {@link Duplex} stream. 12 | * 13 | * @param {function(data: string)} send - Callback for outbound data 14 | * @return {Duplex} - Duplex stream. 15 | */ 16 | constructor(readonly send: any, options?: DuplexOptions) { 17 | super(options); 18 | this.on('finish', () => { 19 | this.emit('close'); 20 | }); 21 | } 22 | 23 | /** 24 | * @param {string} data - Push inbound data from the XAPI service to JSXAPI. 25 | * @return {boolean} - Boolean signaling if the stream can receive more data. 26 | */ 27 | public push(data: string) { 28 | this.buffer.push(data); 29 | return this.attemptFlush(); 30 | } 31 | 32 | public _read() { 33 | this.canPush = true; 34 | this.attemptFlush(); 35 | } 36 | 37 | public _write(chunk: any, encoding: string, callback: any) { 38 | this.send(chunk, encoding, callback); 39 | } 40 | 41 | /** 42 | * Closes the stream transport 43 | */ 44 | public close() { 45 | this.end(); 46 | } 47 | 48 | private attemptFlush() { 49 | while (this.canPush && this.buffer.length) { 50 | this.canPush = super.push(this.buffer.shift()); 51 | } 52 | return this.canPush; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/transport/tsh.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from 'child_process'; 2 | 3 | /// 4 | import duplex from 'duplexer'; 5 | import { CloseableStream } from '../xapi/types'; 6 | 7 | const TSH_BIN = 'tsh'; 8 | 9 | /** 10 | * Use the TSH binary to connect to a TSH server. 11 | * 12 | * @param {string} host - Host to connect to. 13 | * @param {number} port - Port to connect to. 14 | * @return {Promise} - TSH {@link Duplex} stream. 15 | */ 16 | export default function spawnTSH(host: string, port: number) { 17 | const child = spawn(TSH_BIN, ['--port', port.toString()]); 18 | const stream: CloseableStream = duplex(child.stdin, child.stdout) as any; 19 | stream.close = () => child.kill(); 20 | return stream; 21 | } 22 | -------------------------------------------------------------------------------- /src/transport/ws.ts: -------------------------------------------------------------------------------- 1 | import Url from 'url-parse'; 2 | 3 | import { Buffer } from 'buffer'; 4 | import { Options } from '../types'; 5 | 6 | export type CreateWebSocket = (url: string, auth: string) => WebSocket; 7 | 8 | function base64Enc(input: string) { 9 | return (typeof btoa === 'function') 10 | ? btoa(input) 11 | : Buffer.from(input).toString('base64'); 12 | } 13 | 14 | function generateAuthSubProto(username: string, password: string): string { 15 | const replaceChars: any = { '+': '-', '/': '_', '=': '' }; 16 | const authHash = Buffer 17 | .from(`${username}:${password}`) 18 | .toString('base64') 19 | .replace(/[/+=]/g, (c) => replaceChars[c]); 20 | return `auth-${authHash}`; 21 | } 22 | 23 | export default function websocketConnect(createWebSocket: CreateWebSocket, opts: Options) { 24 | const { host, username, password, port, protocol } = opts; 25 | const url = new Url(''); 26 | url.set('pathname', '/ws'); 27 | url.set('host', host); 28 | url.set('protocol', protocol); 29 | url.set('port', `${port}`); 30 | 31 | const auth = generateAuthSubProto(username, password); 32 | return createWebSocket(url.href, auth); 33 | } 34 | -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "esModuleInterop": true, 5 | "lib": ["es2015", "dom"], 6 | "module": "commonjs", 7 | "declaration": true, 8 | "declarationDir": "../lib", 9 | "strict": true, 10 | "noImplicitThis": false, 11 | "removeComments": true, 12 | "outDir": "../lib", 13 | "typeRoots": ["../typings", "../node_modules/@types"] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { LogLevelDesc } from 'loglevel'; 2 | import { Backend } from './backend'; 3 | 4 | /** 5 | * Connection options. 6 | */ 7 | export interface Options { 8 | /** 9 | * SSH command used to initialize TSH (e.g. /bin/tsh) 10 | */ 11 | command: string; 12 | /** 13 | * Hostname to connec to. 14 | */ 15 | host: string; 16 | /** 17 | * Log level. 18 | */ 19 | loglevel: LogLevelDesc; 20 | /** 21 | * Password used for authorization. 22 | */ 23 | password: string; 24 | /** 25 | * Port number. 26 | */ 27 | port: number; 28 | /** 29 | * Protocol for the connection (e.g. ssh:, wss:) 30 | */ 31 | protocol: string; 32 | /** 33 | * Username used for authorization. 34 | */ 35 | username: string; 36 | } 37 | 38 | export type InitBackend = (opts: Options) => Backend; 39 | -------------------------------------------------------------------------------- /src/version.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * DO NOT WRITE OR UPDATE THIS FILE! 3 | * This file was automatically generated at: 2024-11-22T12:12:47.125Z 4 | */ 5 | const VERSION = '6.0.0'; 6 | export default VERSION; 7 | -------------------------------------------------------------------------------- /src/xapi/components.ts: -------------------------------------------------------------------------------- 1 | import { Registration } from './feedback'; 2 | import { Component, Gettable, Listenable, Settable } from './mixins'; 3 | import { Listener, Path } from './types'; 4 | 5 | /** 6 | * Interface to XAPI configurations. 7 | */ 8 | export class Config extends Listenable(Settable(Gettable(Component))) { 9 | public prefix = 'Configuration'; 10 | 11 | // fake mixins 12 | public normalizePath!: (path: Path) => (string | number)[]; 13 | 14 | public on!: (path: Path, listener: Listener) => Registration; 15 | public once!: (path: Path, listener: Listener) => Registration; 16 | public off!: () => void; 17 | 18 | public get!: (path: Path) => Promise; 19 | public set!: (path: Path, value: number | string) => Promise; 20 | } 21 | 22 | /** 23 | * Interface to XAPI events. 24 | */ 25 | export class Event extends Listenable(Component) { 26 | public prefix = 'Event'; 27 | 28 | // fake mixins 29 | public normalizePath!: (path: Path) => (string | number)[]; 30 | 31 | public on!: (path: Path, listener: Listener) => Registration; 32 | public once!: (path: Path, listener: Listener) => Registration; 33 | public off!: () => void; 34 | } 35 | 36 | /** 37 | * Interface to XAPI statuses. 38 | */ 39 | export class Status extends Listenable(Gettable(Component)) { 40 | public prefix = 'Status'; 41 | 42 | // fake mixins 43 | public normalizePath!: (path: Path) => (string | number)[]; 44 | 45 | public on!: (path: Path, listener: Listener) => Registration; 46 | public once!: (path: Path, listener: Listener) => Registration; 47 | public off!: () => void; 48 | 49 | public get!: (path: Path) => Promise; 50 | } 51 | -------------------------------------------------------------------------------- /src/xapi/exc.ts: -------------------------------------------------------------------------------- 1 | export const UNKNOWN_ERROR = 0; 2 | export const COMMAND_ERROR = 1; 3 | export const ILLEGAL_VALUE = 2; 4 | export const INVALID_PATH = 3; 5 | export const PARAMETER_ERROR = 4; 6 | export const INVALID_RESPONSE = 5; 7 | export const INVALID_STATUS = 6; 8 | export const METHOD_NOT_FOUND = -32601; 9 | 10 | export class XAPIError extends Error { 11 | public data?: any; 12 | 13 | constructor(readonly code: number, reason: string, data?: any) { 14 | super(reason); 15 | Object.setPrototypeOf(this, XAPIError.prototype); 16 | if (data !== undefined) { 17 | this.data = data; 18 | } 19 | 20 | if (typeof reason !== 'string') { 21 | throw new Error('Reason for XAPIError must be a string'); 22 | } 23 | 24 | if (typeof code !== 'number') { 25 | throw new Error('Error code for XAPIError must be a number'); 26 | } 27 | } 28 | } 29 | 30 | export class IllegalValueError extends XAPIError { 31 | constructor(reason: string) { 32 | super(ILLEGAL_VALUE, reason); 33 | Object.setPrototypeOf(this, IllegalValueError.prototype); 34 | } 35 | } 36 | 37 | export class InvalidPathError extends XAPIError { 38 | constructor(reason: string, xpath: string) { 39 | super(INVALID_PATH, reason, { xpath }); 40 | Object.setPrototypeOf(this, InvalidPathError.prototype); 41 | } 42 | } 43 | 44 | export class ParameterError extends XAPIError { 45 | constructor() { 46 | super(PARAMETER_ERROR, 'Invalid or missing parameters'); 47 | Object.setPrototypeOf(this, ParameterError.prototype); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/xapi/feedback.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | 3 | import log from '../log'; 4 | 5 | import XAPI from '.'; 6 | import normalizePath from './normalizePath'; 7 | import { Handler, Listener, Path } from './types'; 8 | 9 | /** 10 | * A function used to inspect and emit feedback data. 11 | * 12 | * The interceptor is free to change feedback payloads or discard them 13 | * entirely. 14 | */ 15 | export type FeedbackInterceptor = 16 | /** 17 | * @param payload The feedback payload. 18 | * @param emit Function to dispatch a payload. 19 | */ 20 | (payload: any, emit: (payload: any) => void) => void; 21 | 22 | /** 23 | * Type representing a feedback id. 24 | */ 25 | export interface FeedbackId { 26 | Id: number; 27 | } 28 | 29 | /** 30 | * Type for a feedback registration request. 31 | */ 32 | export interface Registration { 33 | /** 34 | * De-register the feedback registration. 35 | */ 36 | (): void; 37 | 38 | /** 39 | * Promise resolved with a feedback id on successful registration. 40 | */ 41 | registration: Promise; 42 | } 43 | 44 | /** 45 | * Group feedback deregister handlers for bookkeeping. 46 | */ 47 | export class FeedbackGroup { 48 | private handlers: Handler[]; 49 | 50 | constructor(handlers: Handler[]) { 51 | this.handlers = handlers; 52 | } 53 | 54 | /** 55 | * Add a deregister handler function to the feedback group. 56 | * 57 | * @param handler - Handler to add to the group. 58 | * @return - this for chaining. 59 | */ 60 | public add(handler: Handler) { 61 | this.handlers.push(handler); 62 | return this; 63 | } 64 | 65 | /** 66 | * Remove a deregister handler function from the feedback group. 67 | * 68 | * @param handler - Handler to remove from the group. 69 | * @return - this for chaining. 70 | */ 71 | public remove(handler: Handler) { 72 | this.handlers = this.handlers.filter((h) => h !== handler); 73 | return this; 74 | } 75 | 76 | /** 77 | * Call the deregister handler functions associated with this group. 78 | * 79 | * @return - this for chaining. 80 | */ 81 | public off() { 82 | this.handlers.forEach((handler) => { 83 | handler(); 84 | }); 85 | this.handlers = []; 86 | return this; 87 | } 88 | } 89 | 90 | function defaultInterceptor(payload: T, emit: (payload: T) => void) { 91 | emit(payload); 92 | } 93 | 94 | function dispatch( 95 | feedback: Feedback, 96 | data: any, 97 | root = data, 98 | path: string[] = [], 99 | ) { 100 | if (Array.isArray(data)) { 101 | data.forEach((child) => { 102 | dispatch(feedback, child, root, path); 103 | dispatch(feedback, child, root, path.concat(child.id)); 104 | }); 105 | return; 106 | } 107 | 108 | const emitPath = path.join('/').toLowerCase(); 109 | feedback.eventEmitter.emit(emitPath, data, root, root.Id); 110 | 111 | if (typeof data === 'object') { 112 | Object.keys(data).forEach((key) => { 113 | dispatch(feedback, data[key], root, path.concat(key)); 114 | }); 115 | } 116 | } 117 | 118 | /** 119 | * Feedback handler for the XAPI. 120 | * 121 | * ### Register a feedback listener 122 | * 123 | * ```typescript 124 | * xapi.feedback.on('Status/Audio/Volume', data => { 125 | * console.log(`Received feedback data: ${data}`); 126 | * }); 127 | * ``` 128 | * 129 | * ### Get the feedback root payload 130 | * 131 | * ```typescript 132 | * xapi.feedback.on('Status/Audio/Volume', (data, payload) => { 133 | * console.log(`System volume changed to: ${data}`); 134 | * JSON.stringify(payload) // => { Status: { Audio: { Volume: data } } } 135 | * }); 136 | * ``` 137 | * 138 | * ### Listen to array elements 139 | * 140 | * ```typescript 141 | * xapi.feedback.on('Status/Call[42]/Status', callStatus => { 142 | * console.log(`Call status for call number 42 is: ${callStatus}`); 143 | * }); 144 | * ``` 145 | * 146 | * ### Bundle feedback listeners for easy unsubscription 147 | * 148 | * ```typescript 149 | * const feedbackGroup = xapi.feedback.group([ 150 | * xapi.status.on('Audio/Volume', volumeListener), 151 | * xapi.status.on('Call', callListener), 152 | * ]); 153 | * 154 | * // Disable feedback listening for all listeners of the group. 155 | * feedbackGroup.off(); 156 | * ``` 157 | * 158 | * ### Register listener with Array path 159 | * 160 | * ```typescript 161 | * const off = xapi.feedback.on('Status/Audio/Volume', listener); 162 | * off(); // De-register feedback 163 | * ``` 164 | */ 165 | export default class Feedback { 166 | /** 167 | * @param xapi XAPI instance. 168 | * @param interceptor Feedback interceptor. 169 | */ 170 | public readonly eventEmitter = new EventEmitter(); 171 | constructor(readonly xapi: XAPI, readonly interceptor: FeedbackInterceptor = defaultInterceptor) {} 172 | 173 | /** 174 | * Registers a feedback listener with the backend service which is invoked 175 | * when there is feedback matching the subscription query. 176 | * 177 | * @param path Path to subscribe to. 178 | * @param listener Listener invoked on feedback. 179 | * @return Feedback cancellation function. 180 | */ 181 | public on(path: Path, listener: Listener): Registration { 182 | log.info(`new feedback listener on: ${path}`); 183 | const eventPath = normalizePath(path) 184 | .join('/') 185 | .toLowerCase(); 186 | 187 | const registration = this.xapi.execute('xFeedback/Subscribe', { 188 | Query: normalizePath(path), 189 | }); 190 | 191 | let wrapper: (ev: T, root: any, id?: number) => void; 192 | 193 | const idP = registration.then(({ Id }) => { 194 | wrapper = (ev, root, id) => { 195 | if (typeof id !== 'undefined' && id !== Id) { 196 | return; 197 | } 198 | listener(ev, root); 199 | }; 200 | this.eventEmitter.on(eventPath, wrapper); 201 | return Id; 202 | }); 203 | 204 | const off = () => { 205 | if (!wrapper) { 206 | return; 207 | } 208 | 209 | idP.then((Id) => { 210 | this.xapi.execute('xFeedback/Unsubscribe', { Id }); 211 | }); 212 | 213 | this.eventEmitter.removeListener(eventPath, wrapper); 214 | }; 215 | 216 | off.registration = registration; 217 | return off; 218 | } 219 | 220 | /** 221 | * Registers a feedback listener similar to {@link on}, but the subscription 222 | * is removed after the first invocation of the listener. 223 | * 224 | * @param path Path to subscribe to. 225 | * @param listener Listener invoked on feedback. 226 | * @return Feedback cancellation function. 227 | */ 228 | public once(path: Path, listener: Listener): Registration { 229 | let off: Registration; 230 | const wrapped = (ev: T, root: any) => { 231 | if (typeof off === 'function') { 232 | off(); 233 | } 234 | listener.call(this, ev, root); 235 | }; 236 | wrapped.listener = listener; 237 | off = this.on(path, wrapped); 238 | return off; 239 | } 240 | 241 | /** 242 | * Remove feedback registration. 243 | * 244 | * @deprecated use deactivation handler from `.on()` and `.once()` instead. 245 | */ 246 | public off() { 247 | throw new Error( 248 | '.off() is deprecated. Use return value deactivate handler from .on() instead.', 249 | ); 250 | } 251 | 252 | /** 253 | * Dispatches feedback data to the registered handlers. 254 | * 255 | * @param data JSON data structure of feedback data. 256 | * @return Returns self for chaining. 257 | */ 258 | public dispatch(data: any) { 259 | this.interceptor(data, (d = data) => dispatch(this, d)); 260 | return this; 261 | } 262 | 263 | /** 264 | * Creates a grouper object which tracks which tracks the feedback paths and 265 | * listeners being added to it. 266 | * 267 | * ### Bundle feedback listeners for easy unsubscription 268 | * 269 | * ```typescript 270 | * // Create a group 271 | * const group = xapi.feedback.group([ 272 | * xapi.status.on('Audio Volume', (volume) => { 273 | * // ... 274 | * }), 275 | * xapi.config.on('Audio DefaultVolume', (volume) => { 276 | * // ... 277 | * }), 278 | * ]); 279 | * 280 | * const handler = xapi.status.on('Call', (call) => { ... }); 281 | * 282 | * // Add handler to the group 283 | * group.add(handler); 284 | * 285 | * // Remove handler from the group 286 | * group.remove(handler); 287 | * 288 | * // Unregister from all feedback handlers 289 | * group.off(); 290 | * ``` 291 | * 292 | * @return Proxy object for xapi.feedback. 293 | */ 294 | public group(handlers: Handler[]) { 295 | return new FeedbackGroup(handlers); 296 | } 297 | } 298 | -------------------------------------------------------------------------------- /src/xapi/index.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | 3 | import log from '../log'; 4 | import normalizePath from './normalizePath'; 5 | import * as rpc from './rpc'; 6 | 7 | import { Backend } from '../backend'; 8 | import version from '../version'; 9 | import { Config, Event, Status } from './components'; 10 | import Feedback from './feedback'; 11 | import createXapiProxy from './proxy'; 12 | import { Path, XapiError, XapiOptions, XapiResponse } from './types'; 13 | 14 | export interface Requests { 15 | [idx: string]: { 16 | resolve(result: any): void; 17 | reject(result: XapiError): void; 18 | }; 19 | } 20 | 21 | export declare interface XAPI { 22 | on(event: 'error', listener: (error: Error) => void): this; 23 | on(event: 'ready', listener: (xapi: XAPI) => void): this; 24 | on(event: string, listener: () => void): this; 25 | } 26 | 27 | /** 28 | * User-facing API towards the XAPI. Requires a backend for communicating 29 | * with an XAPI instance. It should be possible to write backends for all kinds 30 | * of transports (TSH over SSH, Websockets, HTTP, plain sockets, etc.) 31 | * 32 | * ### Initialization 33 | * 34 | * ```typescript 35 | * const xapi = new XAPI(backend); 36 | * ``` 37 | * 38 | * ### Invoke command 39 | * 40 | * ```typescript 41 | * xapi 42 | * .command('Dial', { Number: 'johndoe@example.com' }) 43 | * .then(onSuccess, onFailure); 44 | * 45 | * // Alternate notation 46 | * xapi 47 | * .Command.Dial({ Number: 'johndoe@example.com' }) 48 | * .then(onSuccess, onFailure); 49 | * ``` 50 | * 51 | * ### Fetch a configuration 52 | * 53 | * ```typescript 54 | * xapi 55 | * .config.get('Audio DefaultVolume') 56 | * .then((volume) => console.log(`default volume is: ${volume}`)); 57 | * 58 | * // Alternate notation 59 | * xapi.Audio.DefaultVolume 60 | * .get() 61 | * .then((volume) => console.log(`default volume is: ${volume}`)); 62 | * ``` 63 | * 64 | * ### Set a configuration 65 | * 66 | * ```typescript 67 | * xapi.config.set('Audio DefaultVolume', 100); 68 | * 69 | * // Alternate notation 70 | * xapi.Audio.DefaultVolume.set(100); 71 | * ``` 72 | * 73 | * ### Fetch a status 74 | * 75 | * ```typescript 76 | * xapi 77 | * .status.get('Audio Volume') 78 | * .then((volume) => { console.log(`volume is: ${volume}`); }); 79 | * 80 | * // Alternate notation 81 | * xapi.Status.Audio.Volume 82 | * .get() 83 | * .then((volume) => { console.log(`volume is: ${volume}`); }); 84 | * ``` 85 | * 86 | * ### Listen to an event 87 | * 88 | * ```typescript 89 | * xapi.event.on('Message Send Text', (text) => { 90 | * console.log(`Received message text: ${text}`); 91 | * }); 92 | * 93 | * // Alternate notation 94 | * xapi.Event.Message.Send.Text.on((text) => { 95 | * console.log(`Received message text: ${text}`); 96 | * }); 97 | * ``` 98 | */ 99 | export class XAPI extends EventEmitter { 100 | public version: string = version; 101 | 102 | /** 103 | * Interface to XAPI feedback registration. 104 | */ 105 | public feedback: Feedback; 106 | 107 | /** 108 | * Interface to XAPI configurations. 109 | */ 110 | public config = new Config(this); 111 | 112 | /** 113 | * Interface to XAPI events. 114 | */ 115 | public event = new Event(this); 116 | 117 | /** 118 | * Interface to XAPI statuses. 119 | */ 120 | public status = new Status(this); 121 | 122 | /** 123 | * Proxy for XAPI Status. 124 | */ 125 | public Command: any; 126 | 127 | /** 128 | * Proxy for XAPI Status. 129 | */ 130 | 131 | public Config: any; 132 | /** 133 | * Proxy for XAPI Status. 134 | */ 135 | 136 | public Status: any; 137 | 138 | /** 139 | * Proxy for XAPI Status. 140 | */ 141 | public Event: any; 142 | 143 | /** 144 | * @param backend Backend connected to an XAPI instance. 145 | * @param options XAPI object options. 146 | */ 147 | 148 | private requestId = 1; 149 | private requests: Requests = {}; 150 | 151 | constructor( 152 | private readonly backend: Backend, 153 | options: XapiOptions = {}) { 154 | super(); 155 | 156 | this.feedback = new Feedback(this, options.feedbackInterceptor); 157 | this.Command = createXapiProxy(this, this.command); 158 | this.Config = createXapiProxy(this, this.config); 159 | this.Event = createXapiProxy(this, this.event); 160 | this.Status = createXapiProxy(this, this.status); 161 | 162 | // Restrict object mutation 163 | if (!options.hasOwnProperty('seal') || options.seal) { 164 | Object.defineProperties(this, { 165 | Command: { writable: false }, 166 | Config: { writable: false }, 167 | Event: { writable: false }, 168 | Status: { writable: false }, 169 | config: { writable: false }, 170 | event: { writable: false }, 171 | feedback: { writable: false }, 172 | status: { writable: false }, 173 | }); 174 | Object.seal(this); 175 | } 176 | 177 | backend 178 | .on('close', () => { 179 | this.emit('close'); 180 | }) 181 | .on('error', (error) => { 182 | this.emit('error', error); 183 | }) 184 | .on('ready', () => { 185 | this.emit('ready', this); 186 | }) 187 | .on('data', this.handleResponse.bind(this)); 188 | } 189 | 190 | /** 191 | * Close the XAPI connection. 192 | */ 193 | public close(): XAPI { 194 | this.backend.close(); 195 | return this; 196 | } 197 | 198 | /** 199 | * Executes the command specified by the given path. 200 | * 201 | * ```typescript 202 | * // Space delimited 203 | * xapi.command('Presentation Start'); 204 | * 205 | * // Slash delimited 206 | * xapi.command('Presentation/Start'); 207 | * 208 | * // Array path 209 | * xapi.command(['Presentation', 'Start']); 210 | * 211 | * // With parameters 212 | * xapi.command('Presentation Start', { PresentationSource: 1 }); 213 | * 214 | * // Multi-line 215 | * xapi.command('UserInterface Extensions Set', { ConfigId: 'example' }, ` 216 | * 217 | * 1.1 218 | * 219 | * Lightbulb 220 | * Statusbar 221 | * 222 | * Foo 223 | * 224 | * Bar 225 | * 226 | * widget_3 227 | * ToggleButton 228 | * 229 | * 230 | * 231 | * 232 | * 233 | * `); 234 | * ``` 235 | * 236 | * @param path Path to command node. 237 | * @param params Object containing named command arguments. 238 | * @param body Multi-line body for commands requiring it. 239 | * @return Resolved with the command response when ready. 240 | */ 241 | public command(path: Path, params?: object | string, body?: string): Promise { 242 | const apiPath = normalizePath(path).join('/'); 243 | const method = `xCommand/${apiPath}`; 244 | 245 | let executeParams; 246 | if (typeof params === 'string' && typeof body === 'undefined') { 247 | executeParams = { body: params }; 248 | } else if ((typeof params === 'object' || !params) && typeof body === 'string') { 249 | executeParams = Object.assign({ body }, params); 250 | } else { 251 | executeParams = params; 252 | } 253 | 254 | return this.execute(method, executeParams); 255 | } 256 | 257 | /** 258 | * Interface to XAPI documents. 259 | * 260 | * @param path Path to xDocument. 261 | * @return xDocument as specified by path. 262 | */ 263 | public doc(path: Path) { 264 | return this.execute('xDoc', { 265 | Path: normalizePath(path), 266 | Type: 'Schema', 267 | }); 268 | } 269 | 270 | /** 271 | * Execute the given JSON-RPC request on the backend. 272 | * 273 | * ```typescript 274 | * xapi.execute('xFeedback/Subscribe', { 275 | * Query: ['Status', 'Audio'], 276 | * }); 277 | * ``` 278 | * 279 | * @param method Name of RPC method to invoke. 280 | * @param params Parameters to add to the request. 281 | * @typeparam T Return type. 282 | * @return Resolved with the command response. 283 | */ 284 | public execute(method: string, params: any): Promise { 285 | return new Promise((resolve, reject) => { 286 | const id = this.nextRequestId(); 287 | const request = rpc.createRequest(id, method, params); 288 | this.backend.execute(request); 289 | this.requests[id] = { resolve, reject }; 290 | }); 291 | } 292 | 293 | private handleResponse(response: XapiResponse) { 294 | const { id, method } = response; 295 | if (method === 'xFeedback/Event') { 296 | log.debug('feedback:', response); 297 | this.feedback.dispatch(response.params); 298 | } else { 299 | if ({}.hasOwnProperty.call(response, 'result')) { 300 | log.debug('result:', response); 301 | const { resolve } = this.requests[id]; 302 | resolve(response.result); 303 | } else { 304 | log.debug('error:', response); 305 | const { reject } = this.requests[id]; 306 | reject(response.error); 307 | } 308 | delete this.requests[id]; 309 | } 310 | } 311 | 312 | private nextRequestId() { 313 | const requestId = this.requestId; 314 | this.requestId += 1; 315 | return requestId.toString(); 316 | } 317 | } 318 | 319 | export default XAPI; 320 | -------------------------------------------------------------------------------- /src/xapi/mixins.ts: -------------------------------------------------------------------------------- 1 | import XAPI from '.'; 2 | import { Registration } from './feedback'; 3 | import normalizePath from './normalizePath'; 4 | import { Listener, Path } from './types'; 5 | 6 | export type AnyConstructor = new (...args: any[]) => T; 7 | 8 | /** 9 | * Common base class for XAPI section types (commands, configs, events, statuses). 10 | */ 11 | export class Component { 12 | /** 13 | * Prefix to add to all paths for the component. 14 | */ 15 | public prefix = ''; 16 | 17 | constructor(public readonly xapi: XAPI) {} 18 | 19 | /** 20 | * Normalizes a path including the component prefix. 21 | * 22 | * @param path Path to normalize. 23 | * @return Normalized path. 24 | */ 25 | public normalizePath(path: Path) { 26 | const normalized = normalizePath(path); 27 | const { prefix } = this; 28 | return !prefix 29 | ? normalized 30 | : ([prefix] as (string | number)[]).concat(normalized); 31 | } 32 | } 33 | 34 | /** 35 | * Mixin for XAPI sections that can trigger feedback. 36 | */ 37 | export interface Listenable { 38 | on(path: Path, listener: Listener): Registration; 39 | once(path: Path, listener: Listener): Registration; 40 | off(): void; 41 | } 42 | 43 | export function Listenable, T = any>(Base: B) 44 | : B & AnyConstructor> 45 | { 46 | return class Child extends Base implements Listenable { 47 | /** 48 | * Register a new listener on the given path. 49 | * 50 | * @param path Path to XAPI entry. 51 | * @param listener Callback handler called on changes. 52 | * @typeparam T Event type. 53 | * @return Handler to deregister the feedback registration. 54 | */ 55 | public on(path: Path, listener: Listener) { 56 | return this.xapi.feedback.on(this.normalizePath(path) as any, listener); 57 | } 58 | 59 | /** 60 | * Register a new listener on the given path, de-register 61 | * after the first change happened. 62 | * 63 | * @param path Path to XAPI entry. 64 | * @param listener Callback handler called on changes. 65 | * @typeparam T Event type. 66 | * @return Handler to deregister the feedback registration. 67 | */ 68 | public once(path: Path, listener: Listener) { 69 | return this.xapi.feedback.once(this.normalizePath(path) as any, listener); 70 | } 71 | 72 | /** 73 | * De-register the given listener on the given path. 74 | * 75 | * @deprecated Use deactivation handler from `.on()` and `.once()` instead. 76 | */ 77 | public off() { 78 | this.xapi.feedback.off(); 79 | } 80 | }; 81 | } 82 | 83 | export interface Gettable { 84 | get(path: Path): Promise; 85 | } 86 | 87 | /** 88 | * Mixin for XAPI sections that can hold a value that may be fetched. 89 | */ 90 | export function Gettable, T = any>(Base: B) 91 | : B & AnyConstructor> 92 | { 93 | return class Child extends Base implements Gettable { 94 | /** 95 | * Gets the value of the given path. 96 | * 97 | * ```typescript 98 | * xapi.status 99 | * .get('Audio Volume') 100 | * .then((volume) => { console.log(volume); }); 101 | * ``` 102 | * 103 | * ```typescript 104 | * xapi.config 105 | * .get('Audio DefaultVolume') 106 | * .then((volume) => { console.log(volume); }); 107 | * ``` 108 | * 109 | * @param path Path to configuration node. 110 | * @typeparam T The return type of the get request. 111 | * @return Resolved to the configuration value when ready. 112 | */ 113 | public get(path: Path): Promise { 114 | return this.xapi.execute('xGet', { 115 | Path: this.normalizePath(path), 116 | }); 117 | } 118 | }; 119 | } 120 | 121 | export interface Settable { 122 | set(path: Path, value: T): Promise; 123 | } 124 | 125 | /** 126 | * Mixin for XAPI sections that can hold a value that may be fetched. 127 | */ 128 | export function Settable, T = number | string>(Base: B) 129 | : B & AnyConstructor> 130 | { 131 | return class Child extends Base implements Settable { 132 | /** 133 | * Sets the path to the given value. 134 | * 135 | * ```typescript 136 | * xapi 137 | * .config.set('SystemUnit Name', 'My System'); 138 | * ``` 139 | * 140 | * @param path Path to status node. 141 | * @param value Configuration value. 142 | * @return Resolved to the status value when ready. 143 | */ 144 | public set(path: Path, value: T) { 145 | return this.xapi.execute('xSet', { 146 | Path: this.normalizePath(path), 147 | Value: value, 148 | }); 149 | } 150 | }; 151 | } 152 | -------------------------------------------------------------------------------- /src/xapi/normalizePath.ts: -------------------------------------------------------------------------------- 1 | import { NormalizedPath, Path } from './types'; 2 | 3 | /** 4 | * Normalizes a path by turning it into an Array of strings. 5 | * Removes empty parts of the path and ignores redundant whitespace or 6 | * slashes. Each path element is also capitalized. 7 | * 8 | * @param path Array or string path to normalize. 9 | * @return Array of path segments. 10 | */ 11 | export default function normalizePath(path: Path): NormalizedPath { 12 | const split = Array.isArray(path) ? path : path.match(/(\w+)/g); 13 | return !split 14 | ? [] 15 | : split.map((element) => { 16 | if (/^\d+$/.test(element)) { 17 | return parseInt(element, 10); 18 | } 19 | return element.charAt(0).toUpperCase() + element.slice(1); 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /src/xapi/proxy.ts: -------------------------------------------------------------------------------- 1 | // Proxy types inspired by: 2 | // https://www.typescriptlang.org/docs/handbook/advanced-types.html 3 | 4 | const ACTIONS = ['get', 'set', 'on', 'once']; 5 | 6 | export default function createProxy(thisArg: any, root: any, path: string[] = []): any { 7 | const handlers = { 8 | apply(target: any, _: any, args: any[]) { 9 | if (typeof root === 'function') { 10 | return root.call(thisArg, path.join('/'), ...args); 11 | } 12 | throw new TypeError(`Object is not callable: ${root}`); 13 | }, 14 | 15 | get(target: any, property: string) { 16 | if (ACTIONS.includes(property)) { 17 | if (typeof root[property] !== 'function') { 18 | throw new TypeError(`Property is not callable: ${root}[${property}]`); 19 | } 20 | return (...args: any[]) => root[property](path.join('/'), ...args); 21 | } 22 | 23 | return createProxy(thisArg, root, path.concat(property)); 24 | }, 25 | }; 26 | 27 | return new Proxy(root, handlers); 28 | } 29 | -------------------------------------------------------------------------------- /src/xapi/rpc.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IllegalValueError, 3 | INVALID_RESPONSE, 4 | INVALID_STATUS, 5 | InvalidPathError, 6 | ParameterError, 7 | UNKNOWN_ERROR, 8 | XAPIError, 9 | } from './exc'; 10 | import { XapiRequest } from './types'; 11 | 12 | const scalars = ['number', 'string']; 13 | const isScalar = (value: any) => scalars.indexOf(typeof value) >= 0; 14 | 15 | const VERSION = '2.0'; 16 | 17 | /* 18 | * Collapse "Value" into parent + skip lowercase props 19 | */ 20 | export function collapse(data: any): any { 21 | if (Array.isArray(data)) { 22 | return data.map(collapse); 23 | } 24 | if (isScalar(data)) { 25 | return data; 26 | } 27 | if ({}.hasOwnProperty.call(data, 'Value') && isScalar(data.Value)) { 28 | return data.Value; 29 | } 30 | 31 | const result: { [idx: string]: any } = {}; 32 | 33 | Object.keys(data).forEach((key) => { 34 | result[key] = collapse(data[key]); 35 | }); 36 | 37 | return result; 38 | } 39 | 40 | export function createRequest(id: string | null, method: string, params: any) { 41 | const request: XapiRequest = { jsonrpc: VERSION, method }; 42 | 43 | if (id) { 44 | request.id = id; 45 | } 46 | 47 | if (params) { 48 | request.params = {}; 49 | 50 | Object.keys(params).forEach((key) => { 51 | const value = params[key]; 52 | 53 | if ( 54 | key !== 'body' && 55 | typeof value === 'string' && 56 | value.indexOf('\n') !== -1 57 | ) { 58 | throw new Error('Parameters may not contain newline characters'); 59 | } 60 | 61 | request.params[key] = params[key]; 62 | }); 63 | } 64 | 65 | return request; 66 | } 67 | 68 | export function createResponse(id: string, result: any) { 69 | return { jsonrpc: VERSION, id, result }; 70 | } 71 | 72 | export function createErrorResponse(id: string, error: any) { 73 | let payload: { [idx: string]: any }; 74 | 75 | if (error instanceof XAPIError) { 76 | payload = { 77 | code: error.code, 78 | message: error.message, 79 | }; 80 | } else { 81 | payload = { 82 | code: UNKNOWN_ERROR, 83 | message: (error.message || error).toString(), 84 | }; 85 | } 86 | 87 | if ({}.hasOwnProperty.call(error, 'data')) { 88 | payload.data = error.data; 89 | } 90 | 91 | return { jsonrpc: VERSION, id, error: payload }; 92 | } 93 | 94 | export function parseFeedbackResponse(response: any) { 95 | return collapse(response); 96 | } 97 | 98 | function assertResponseSuccess(response: any): any { 99 | const keys = Object.keys(response).filter((k) => k !== 'ResultId'); 100 | if (keys.length > 1) { 101 | throw new XAPIError( 102 | INVALID_RESPONSE, 103 | `Invalid command response: Wrong number of keys (${keys.length})`, 104 | ); 105 | } 106 | 107 | if ({}.hasOwnProperty.call(response, 'CommandResponse')) { 108 | return assertResponseSuccess(response.CommandResponse); 109 | } 110 | 111 | const root = response[keys[0]]; 112 | if (!root || !{}.hasOwnProperty.call(root, 'status')) { 113 | return root; 114 | } 115 | 116 | switch (root.status) { 117 | case 'Error': { 118 | const body = collapse(root); 119 | const { Error, Reason, XPath } = body; 120 | const reason = Error || Reason || keys[0]; 121 | 122 | if (XPath) { 123 | throw new InvalidPathError(reason, XPath); 124 | } 125 | 126 | throw new XAPIError(UNKNOWN_ERROR, reason, body); 127 | } 128 | case 'ParameterError': 129 | throw new ParameterError(); 130 | case 'OK': 131 | return root; 132 | default: 133 | throw new XAPIError( 134 | INVALID_STATUS, 135 | `Invalid command status: ${root.status}`, 136 | ); 137 | } 138 | } 139 | 140 | function assertValidCommandResponse(response: any) { 141 | if (!{}.hasOwnProperty.call(response, 'CommandResponse')) { 142 | throw new XAPIError( 143 | INVALID_RESPONSE, 144 | 'Invalid command response: Missing "CommandResponse" attribute', 145 | ); 146 | } 147 | 148 | return assertResponseSuccess(response); 149 | } 150 | 151 | export function createCommandResponse(response: any) { 152 | const root = assertValidCommandResponse(response); 153 | const collapsed = collapse(root); 154 | return Object.keys(collapsed).length ? collapsed : null; 155 | } 156 | 157 | function digObj(path: (string | number)[], obj: any) { 158 | const parts = path.slice(); 159 | let value = obj; 160 | 161 | while (parts.length) { 162 | const part = parts.shift()!; 163 | if (Array.isArray(value)) { 164 | value = value.find((v) => parseInt(v.id, 10) === part); 165 | } else if (!{}.hasOwnProperty.call(value, part)) { 166 | return undefined; 167 | } else { 168 | value = value[part]; 169 | } 170 | } 171 | 172 | return value; 173 | } 174 | 175 | export function createDocumentResponse(request: any, response: any) { 176 | const { Path, Type } = request.params; 177 | const isSchema = Type === 'Schema'; 178 | const path = [...Path]; 179 | const document = path[0].toLowerCase(); 180 | 181 | // Shim document/query inconsistencies 182 | if (isSchema && 'status'.startsWith(document)) { 183 | path[0] = 'StatusSchema'; 184 | } else if ('configuration'.startsWith(document)) { 185 | path[0] = 'Configuration'; 186 | } 187 | 188 | return digObj(path, response); 189 | } 190 | 191 | export function createGetResponse(request: any, response: any) { 192 | if ({}.hasOwnProperty.call(response, 'CommandResponse')) { 193 | assertResponseSuccess(response.CommandResponse); 194 | } else { 195 | assertResponseSuccess(response); 196 | } 197 | 198 | return digObj(request.params.Path, collapse(response)); 199 | } 200 | 201 | export function createSetResponse(request: any, response: any) { 202 | if ({}.hasOwnProperty.call(response, 'CommandResponse')) { 203 | assertResponseSuccess(response.CommandResponse); 204 | } else { 205 | assertResponseSuccess(response); 206 | } 207 | 208 | if (Object.keys(response).length > 1) { 209 | const leaf = digObj(request.params.Path, response); 210 | if (leaf.error === 'True') { 211 | throw new IllegalValueError(leaf.Value); 212 | } 213 | } 214 | return null; 215 | } 216 | -------------------------------------------------------------------------------- /src/xapi/types.ts: -------------------------------------------------------------------------------- 1 | import { Duplex } from 'stream'; 2 | import { FeedbackInterceptor } from './feedback'; 3 | 4 | export interface CloseableStream extends Duplex { 5 | close(): void; 6 | } 7 | 8 | export type Canceler = () => void; 9 | export type Handler = () => void; 10 | 11 | export type Path = string | string[]; 12 | 13 | /** 14 | * A normalized path is an array of path elements. 15 | */ 16 | export type NormalizedPath = (string | number)[]; 17 | 18 | export type Listener = (ev: T, root: any) => void; 19 | 20 | export interface XapiOptions { 21 | feedbackInterceptor?: FeedbackInterceptor; 22 | seal?: boolean; 23 | } 24 | 25 | export interface XapiRequest { 26 | id?: string; 27 | method: string; 28 | jsonrpc: string; 29 | params?: any; 30 | } 31 | 32 | export interface XapiResponse { 33 | id: string; 34 | method: string; 35 | params: any; 36 | result: XapiResult; 37 | error: XapiError; 38 | } 39 | 40 | export interface XapiResult { 41 | Id: number; 42 | } 43 | 44 | export interface XapiError { 45 | message: string; 46 | } 47 | -------------------------------------------------------------------------------- /test/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true, 4 | "node": true 5 | }, 6 | "globals": { 7 | "expect": true, 8 | "sinon": true 9 | }, 10 | "rules": { 11 | "import/no-extraneous-dependencies": [ 12 | "error", { "devDependencies": true } 13 | ] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /test/backend/index.spec.ts: -------------------------------------------------------------------------------- 1 | import Backend from '../../src/backend'; 2 | import { XAPIError } from '../../src/xapi/exc'; 3 | 4 | describe('Backend', () => { 5 | let backend: Backend; 6 | 7 | beforeEach(() => { 8 | backend = new Backend(); 9 | }); 10 | 11 | describe('.defaultHandler()', () => { 12 | it('returns a rejected promise', () => { 13 | const result = backend.defaultHandler({ 14 | method: 'xCommand/Dial', 15 | params: { Number: 'user@example.com' }, 16 | }); 17 | 18 | return expect(result).rejects.toThrow( 19 | 'Invalid request method', 20 | ); 21 | }); 22 | }); 23 | 24 | describe('.execute()', () => { 25 | it('calls .defaultHandler() when no handlers exist', (done) => { 26 | const request = { 27 | method: 'xCommand/Dial', 28 | params: { Number: 'user@example.com' }, 29 | jsonrpc: '2.0', 30 | }; 31 | 32 | jest.spyOn(backend, 'defaultHandler').mockImplementation((actual) => { 33 | expect(actual).toEqual(request); 34 | done(); 35 | return Promise.resolve(); 36 | }); 37 | 38 | backend.execute(request); 39 | }); 40 | 41 | it('handler can return plain values', (done) => { 42 | (backend as any)['xCommand()'] = () => 42; 43 | 44 | backend.on('data', (result) => { 45 | expect(result).toEqual({ 46 | jsonrpc: '2.0', 47 | id: 'request-1', 48 | result: 42, 49 | }); 50 | done(); 51 | }); 52 | 53 | backend.execute({ 54 | jsonrpc: '2.0', 55 | id: 'request-1', 56 | method: 'xCommand/Foo', 57 | params: { Bar: 'Baz' }, 58 | }); 59 | }); 60 | 61 | const testCases = [ 62 | { 63 | name: 'xCommand', 64 | method: 'xCommand/Dial', 65 | params: { Number: 'user@example.com' }, 66 | }, 67 | { 68 | name: 'xFeedback/Subscribe', 69 | method: 'xFeedback/Subscribe', 70 | params: { Query: ['Status', 'Audio', 'Volume'] }, 71 | }, 72 | { 73 | name: 'xFeedback/Unsubscribe', 74 | method: 'xFeedback/Unsubscribe', 75 | params: { Id: 1 }, 76 | }, 77 | { 78 | name: 'xGet', 79 | method: 'xGet', 80 | params: { Path: ['Status', 'Audio', 'Volume'] }, 81 | }, 82 | { 83 | name: 'xSet', 84 | method: 'xSet', 85 | params: { 86 | Path: ['Configuration', 'Audio', 'DefaultVolume'], 87 | Value: 50, 88 | }, 89 | }, 90 | ]; 91 | 92 | testCases.forEach(({ name, method, params }) => { 93 | it(`calls .${name}() handler`, () => { 94 | const send = jest.spyOn(backend, 'send'); 95 | const handler = jest.fn().mockImplementation((_r, _send) => _send()); 96 | const request = { jsonrpc: '2.0', id: 'request-1', method, params }; 97 | 98 | (backend as any)[`${name}()`] = handler; 99 | backend.execute(request); 100 | 101 | expect(handler).not.toHaveBeenCalled(); 102 | expect(send).not.toHaveBeenCalled(); 103 | 104 | return backend.isReady.then(() => { 105 | expect(handler.mock.calls[0]).toContain(request); 106 | expect(send.mock.calls[0]).toContain('request-1'); 107 | }); 108 | }); 109 | }); 110 | 111 | it('handles error', (done) => { 112 | const send = jest.spyOn(backend, 'send').mockImplementation(() => { 113 | throw new XAPIError(0, 'Some XAPI thing went wrong') 114 | }); 115 | (backend as any)['xCommand()'] = jest.fn().mockImplementation((_r, _send) => _send()); 116 | 117 | backend.on('data', (data) => { 118 | expect(data.error).toMatchObject({ 119 | code: 0, 120 | message: 'Some XAPI thing went wrong', 121 | }); 122 | done(); 123 | }); 124 | 125 | backend.execute({ 126 | jsonrpc: '2.0', 127 | id: 'request', 128 | method: 'xCommand/Foo/Bar', 129 | }); 130 | }); 131 | }); 132 | }); 133 | -------------------------------------------------------------------------------- /test/connect.spec.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import connect, { globalDefaults } from '../src/connect'; 3 | import log from '../src/log'; 4 | import XAPI from '../src/xapi'; 5 | 6 | describe('connect()', () => { 7 | let initBackend: jest.Mock; 8 | 9 | beforeEach(() => { 10 | initBackend = jest.fn(); 11 | initBackend.mockReturnValue(new EventEmitter()); 12 | }); 13 | 14 | afterEach(() => { 15 | log.disableAll(); // connect sets log-level 16 | }); 17 | 18 | describe('args', () => { 19 | it('throws with no arguments', () => { 20 | const doConnect = connect(initBackend, {})(XAPI) as any; 21 | expect(() => doConnect()).toThrow(/invalid arguments/i); 22 | }); 23 | 24 | it('allows invoking with a single string URL', () => { 25 | const doConnect = connect(initBackend, {})(XAPI); 26 | 27 | doConnect('ssh://host.example.com'); 28 | 29 | expect(initBackend).toHaveBeenCalledWith({ 30 | ...globalDefaults, 31 | host: 'host.example.com', 32 | protocol: 'ssh:', 33 | }); 34 | }); 35 | 36 | it('allows invoking with a single object', () => { 37 | const doConnect = connect(initBackend, { 38 | protocol: 'ssh:', 39 | })(XAPI); 40 | 41 | doConnect({ 42 | host: 'host.example.com', 43 | }); 44 | 45 | expect(initBackend).toHaveBeenCalledWith({ 46 | ...globalDefaults, 47 | host: 'host.example.com', 48 | protocol: 'ssh:', 49 | }); 50 | }); 51 | }); 52 | 53 | describe('options', () => { 54 | it('allows passing defaults', () => { 55 | const doConnect = connect(initBackend, { 56 | protocol: 'ssh:', 57 | })(XAPI); 58 | 59 | doConnect(''); 60 | 61 | expect(initBackend).toHaveBeenCalledWith({ 62 | ...globalDefaults, 63 | protocol: 'ssh:', 64 | }); 65 | }); 66 | it('merges defaults and passed options', () => { 67 | const doConnect = connect(initBackend, { 68 | port: 22, 69 | protocol: 'ssh:', 70 | username: 'integrator', 71 | })(XAPI); 72 | 73 | doConnect({ 74 | port: 80, 75 | protocol: 'ws:', 76 | }); 77 | 78 | expect(initBackend).toHaveBeenCalledWith({ 79 | ...globalDefaults, 80 | port: 80, 81 | protocol: 'ws:', 82 | username: 'integrator', 83 | }); 84 | }); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /test/data/echo_response.txt: -------------------------------------------------------------------------------- 1 | 2 | OK 3 | -------------------------------------------------------------------------------- /test/data/welcome.txt: -------------------------------------------------------------------------------- 1 | Welcome to mmyrseth-sx20 2 | Cisco Codec Release ce8.1.0 PreAlpha1 (TEST SW, ce-8.0.0-alpha3-7128-g16c75fd) 3 | SW Release Date: 2015-08-10 18:28:24, matchbox 4 | *r Login successful 5 | 6 | OK 7 | 8 | -------------------------------------------------------------------------------- /test/index.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | const dataDir = `${__dirname}/data`; 5 | 6 | export function readFile(filePath) { 7 | return fs.readFileSync(filePath, 'utf8'); 8 | } 9 | 10 | export const ECHO_RESPONSE = readFile(path.join(dataDir, 'echo_response.txt')); 11 | export const WELCOME_TEXT = readFile(path.join(dataDir, 'welcome.txt')); 12 | -------------------------------------------------------------------------------- /test/json-parser.spec.ts: -------------------------------------------------------------------------------- 1 | import { JSONParser, parseJSON } from '../src/json-parser'; 2 | 3 | describe('parseJSON', () => { 4 | it('can parse top-level status', () => { 5 | expect(parseJSON('{"StatusSchema":null}')).toEqual({ 6 | StatusSchema: null, 7 | }); 8 | }); 9 | 10 | it('can parse text', () => { 11 | expect(parseJSON('{ "StatusSchema": "text" }')).toEqual({ 12 | StatusSchema: 'text', 13 | }); 14 | }); 15 | 16 | it('can parse nested structure', () => { 17 | expect( 18 | parseJSON(`{ 19 | "StatusSchema": { 20 | "Audio": { 21 | "Input": null 22 | } 23 | } 24 | }`), 25 | ).toEqual({ 26 | StatusSchema: { 27 | Audio: { 28 | Input: null, 29 | }, 30 | }, 31 | }); 32 | }); 33 | 34 | it('can parse siblings', () => { 35 | expect( 36 | parseJSON(`{ 37 | "StatusSchema": { 38 | "Audio": null, 39 | "Call": null 40 | } 41 | }`), 42 | ).toEqual({ 43 | StatusSchema: { 44 | Audio: null, 45 | Call: null, 46 | }, 47 | }); 48 | }); 49 | 50 | it('can parse tree', () => { 51 | expect( 52 | parseJSON(`{ 53 | "StatusSchema": { 54 | "Audio": { 55 | "public": "True", 56 | "Microphones": { 57 | "public": "True", 58 | "LedIndicator": { 59 | "public": "False" 60 | }, 61 | "Mute": { 62 | "public": "True" 63 | } 64 | } 65 | } 66 | } 67 | }`), 68 | ).toEqual({ 69 | StatusSchema: { 70 | Audio: { 71 | public: 'True', 72 | Microphones: { 73 | public: 'True', 74 | LedIndicator: { 75 | public: 'False', 76 | }, 77 | Mute: { 78 | public: 'True', 79 | }, 80 | }, 81 | }, 82 | }, 83 | }); 84 | }); 85 | }); 86 | 87 | describe('Parser', () => { 88 | it('can parse incremental chunks', () => { 89 | const parser = new JSONParser(); 90 | const spy = jest.fn(); 91 | 92 | parser.on('data', spy); 93 | 94 | parser.write('{"Status'); 95 | parser.write('Schema":'); 96 | parser.write('{"Audio"'); 97 | parser.write(': null\n'); 98 | expect(spy).not.toHaveBeenCalled(); 99 | 100 | parser.write('}}\n\n {"Configuration":{'); 101 | expect(spy).toHaveBeenCalledWith(expect.objectContaining({ 102 | StatusSchema: { 103 | Audio: null, 104 | }, 105 | })); 106 | 107 | spy.mockReset(); 108 | 109 | parser.write('"Audio'); 110 | parser.write('": null }}'); 111 | 112 | expect(spy).toHaveBeenCalledWith(expect.objectContaining({ 113 | Configuration: { 114 | Audio: null, 115 | }, 116 | })); 117 | }); 118 | 119 | it('emits error on premature end', () => { 120 | const spy = jest.fn(); 121 | const parser = new JSONParser(); 122 | 123 | parser.on('error', spy); 124 | parser.write('{"StatusSchema'); 125 | parser.end(); 126 | 127 | expect(spy).toHaveBeenCalled(); 128 | }); 129 | 130 | it('can parse consecutive schemas with valuespace', () => { 131 | const spy = jest.fn(); 132 | const parser = new JSONParser(); 133 | 134 | parser.on('data', spy); 135 | parser.write(`{ 136 | "Command": { 137 | "Update": { 138 | "command": "True", 139 | "role": "Admin", 140 | "public": "False", 141 | "FilterType": { 142 | "item": "1", 143 | "required": "True", 144 | "type": "Literal", 145 | "ValueSpace": { 146 | "type": "Literal", 147 | "Value": [ 148 | "highpass", 149 | "highshelf" 150 | ] 151 | } 152 | } 153 | } 154 | } 155 | }`); 156 | parser.write(`{ 157 | "Configuration": { 158 | "Mode": { 159 | "role": "Admin", 160 | "public": "True", 161 | "ValueSpace": { 162 | "type": "Literal", 163 | "default": "Off", 164 | "Value": [ 165 | "Off", 166 | "On" 167 | ] 168 | } 169 | } 170 | } 171 | }`); 172 | 173 | expect(spy).toHaveBeenCalledTimes(2); 174 | }); 175 | 176 | it('can parse consecutive documents in same write', () => { 177 | const spy = jest.fn(); 178 | const parser = new JSONParser(); 179 | 180 | parser.on('data', spy); 181 | parser.write(`{ 182 | "ResultId": "1", 183 | "CommandResponse": { 184 | "StandbyDeactivateResult": { 185 | "status": "OK" 186 | } 187 | } 188 | } 189 | { 190 | "ResultId": "2", 191 | "Status": { 192 | "Standby": { 193 | "Active": { 194 | "Value": "Off" 195 | } 196 | } 197 | } 198 | }`); 199 | 200 | expect(spy).toHaveBeenCalledTimes(2); 201 | }); 202 | 203 | it('can handle invalid input in between objects/results', () => { 204 | const dataSpy = jest.fn(); 205 | const errorSpy = jest.fn(); 206 | const parser = new JSONParser(); 207 | 208 | parser.on('data', dataSpy); 209 | parser.on('error', errorSpy); 210 | 211 | parser.write(`{ 212 | "ResultId": "1", 213 | "CommandResponse": { 214 | "StandbyDeactivateResult": { 215 | "status": "OK" 216 | } 217 | } 218 | } 219 | Command not recognized 220 | { 221 | "ResultId": "2", 222 | "Status": { 223 | "Standby": { 224 | "Active": { 225 | "Value": "Off" 226 | } 227 | } 228 | } 229 | }`); 230 | 231 | expect(dataSpy).toHaveBeenCalledTimes(2); 232 | expect(errorSpy).toHaveBeenCalledTimes(1); 233 | }); 234 | }); 235 | -------------------------------------------------------------------------------- /test/mock_transport.ts: -------------------------------------------------------------------------------- 1 | import { Duplex } from 'stream'; 2 | 3 | import TSHBackend from '../src/backend/tsh'; 4 | import { XapiResult } from '../src/xapi/types'; 5 | 6 | interface Requests { 7 | [idx: string]: { 8 | resolve(result: any): void; 9 | reject(result: any): void; 10 | }; 11 | } 12 | 13 | export default class MockTransport extends Duplex { 14 | public writeBuffer: string[]; 15 | private dataToPromiseMap: Requests; 16 | 17 | constructor() { 18 | super(); 19 | this.dataToPromiseMap = {}; 20 | this.writeBuffer = []; 21 | } 22 | 23 | public _read() { 24 | // 25 | } 26 | 27 | public _write: Duplex['_write'] = (data, _encoding, done) => { 28 | this.writeBuffer.unshift(data.toString('utf8').trim()); 29 | done(); 30 | } 31 | 32 | // Intercept input passed to the backend so that we know which input has been 33 | // consumed. Map this to the promises from .send() and resolve them. 34 | public stubBackend(backend: TSHBackend) { 35 | const origMethod = backend.onTransportData; 36 | jest.spyOn(backend, 'onTransportData').mockImplementation((data) => { 37 | const returnValue = origMethod.call(backend, data); 38 | if ({}.hasOwnProperty.call(this.dataToPromiseMap, data)) { 39 | const { resolve } = this.dataToPromiseMap[data]; 40 | delete this.dataToPromiseMap[data]; 41 | resolve(null as unknown as XapiResult); 42 | } 43 | return returnValue; 44 | }); 45 | } 46 | 47 | // Use promises to ensure that we can know that the TSH backend has consumed 48 | // the input we're expecting. 49 | public send(data: string) { 50 | return new Promise((resolve, reject) => { 51 | this.dataToPromiseMap[data] = { resolve, reject }; 52 | this.push(data); 53 | }); 54 | } 55 | 56 | public init() { 57 | return this.sendWelcomeText().then(() => this.sendEchoResponse()); 58 | } 59 | 60 | private sendEchoResponse() { 61 | return this.send(` 62 | OK 63 | 64 | `); 65 | } 66 | 67 | public sendWelcomeText() { 68 | return this.send(` 69 | Welcome to somehost 70 | Cisco Codec Release ce 8.1.0 PreAlpha0 afda72b 2015-12-13 71 | SW Release Date: 2015-12-13 23:22:33, matchbox 72 | *r Login successful 73 | 74 | OK 75 | 76 | `); 77 | } 78 | 79 | public close() { 80 | // 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /test/schema/__snapshots__/nodes.spec.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`schema nodes Root can build entire module 1`] = ` 4 | "import { XAPI, connectGen } from "jsxapi"; 5 | import { Registration } from "jsxapi/lib/xapi/feedback"; 6 | 7 | export class TypedXAPI extends XAPI {} 8 | 9 | export default TypedXAPI; 10 | export const connect = connectGen(TypedXAPI); 11 | 12 | export interface TypedXAPI { 13 | Command: CommandTree; 14 | Config: ConfigTree; 15 | Status: StatusTree; 16 | } 17 | 18 | export interface Gettable { 19 | get(): Promise; 20 | } 21 | 22 | export interface Settable { 23 | set(value: T): Promise; 24 | } 25 | 26 | export interface Listenable { 27 | on(handler: (value: T) => void): Registration; 28 | once(handler: (value: T) => void): Registration; 29 | } 30 | 31 | type Configify = [T] extends [object] 32 | ? { [P in keyof T]: Configify; } & Gettable & Listenable 33 | : Gettable & Settable & Listenable; 34 | 35 | type Eventify = { [P in keyof T]: Eventify; } & Listenable; 36 | 37 | type Statusify = { [P in keyof T]: Statusify; } & Gettable & Listenable; 38 | 39 | export interface CommandTree { 40 | Audio: { 41 | Microphones: { 42 | Mute(): Promise, 43 | }, 44 | Sound: { 45 | Play(args: AudioPlayArgs): Promise, 46 | }, 47 | }; 48 | Dial(args: DialArgs): Promise; 49 | SystemUnit: { 50 | FactoryReset(args: SystemUnitFactoryResetArgs): Promise, 51 | }; 52 | } 53 | 54 | export interface ConfigTree { 55 | SystemUnit: { 56 | Name: string, 57 | }; 58 | } 59 | 60 | export interface StatusTree { 61 | Audio: { 62 | Volume: number, 63 | }; 64 | } 65 | 66 | export interface AudioPlayArgs { 67 | Sound: 'Alert' | 'Busy' | 'CallInitiate'; 68 | Loop?: 'On' | 'Off'; 69 | } 70 | 71 | export interface DialArgs { 72 | Number: string; 73 | } 74 | 75 | export interface SystemUnitFactoryResetArgs { 76 | Confirm: Yes; 77 | Keep?: ('LocalSetup' | 'Network' | 'Provisioning')[]; 78 | }" 79 | `; 80 | 81 | exports[`schema nodes Tree renders levels of nesting 1`] = `"Audio: {}"`; 82 | 83 | exports[`schema nodes Tree renders levels of nesting 2`] = ` 84 | "Audio: { 85 | Microphones: {}, 86 | }" 87 | `; 88 | 89 | exports[`schema nodes Tree renders levels of nesting 3`] = ` 90 | "Audio: { 91 | Microphones: { 92 | LedIndicator: 'On' | 'Off', 93 | }, 94 | }" 95 | `; 96 | -------------------------------------------------------------------------------- /test/schema/access_test.ts: -------------------------------------------------------------------------------- 1 | type Role = "Admin" | "User" | "Guest"; 2 | 3 | type Access = 4 | Extract extends never ? never : T; 5 | 6 | type ExistingProps = { [K in keyof T]: T[K] extends never ? never : K }[keyof T]; 7 | type FilterProps = { [P in keyof T]: Filter } 8 | type Filter = 9 | T extends any[] ? T : 10 | T extends object ? Pick, ExistingProps> : 11 | T; 12 | 13 | type FilterAPI = Filter>; 14 | 15 | interface API { 16 | roles: Role[]; 17 | // internal: Access; 18 | app: Access; 20 | }>, 21 | /** 22 | * User session information. 23 | */ 24 | session: Access; 26 | }>, 27 | unprivileged: Access; 28 | } 29 | 30 | const apiData: FilterAPI = { 31 | roles: ["Admin"], 32 | app: { 33 | secret: 1337, 34 | }, 35 | session: { 36 | username: "username", 37 | }, 38 | unprivileged: true, 39 | }; 40 | 41 | const api = apiData as FilterAPI; 42 | 43 | function hasRole(role: T, api: any): api is FilterAPI { 44 | // instanceof check? 45 | return "roles" in api && role in api.roles; 46 | } 47 | 48 | console.log('roles', api.roles); 49 | // console.log('secret is', api.app.secret); // Inaccessible here 50 | // console.log('username is', api.session.username); // Inaccessible here 51 | // console.log('unprivileged is', api.unprivileged); // Inaccessible here 52 | 53 | if (hasRole("Guest", api)) { 54 | // console.log('secret is', api.app.secret); // Inaccessible here 55 | // console.log('username is', api.session.username); // Inaccessible here 56 | console.log('unprivileged is', api.unprivileged); 57 | } 58 | 59 | if (hasRole("User", api)) { 60 | // console.log('secret is', api.app.secret); // Inaccessible here 61 | console.log('username is', api.session.username); 62 | console.log('unprivileged is', api.unprivileged); 63 | } 64 | 65 | if (hasRole("Admin", api)) { 66 | console.log('secret is', api.app.secret); 67 | console.log('username is', api.session.username); 68 | console.log('unprivileged is', api.unprivileged); 69 | } 70 | -------------------------------------------------------------------------------- /test/schema/example.ts: -------------------------------------------------------------------------------- 1 | import XAPI from '../../src/xapi'; 2 | 3 | export class TypedXAPI extends XAPI {} 4 | 5 | export default TypedXAPI; 6 | 7 | export interface TypedXAPI { 8 | Command: CommandTree; 9 | Config: ConfigTree; 10 | Status: StatusTree; 11 | } 12 | 13 | export interface CommandTree { 14 | Audio: { 15 | Microphones: { 16 | Mute(): Promise; 17 | }, 18 | Sound: { 19 | Play(args: AudioPlayArgs): Promise, 20 | }, 21 | }; 22 | Dial(args: DialArgs): Promise; 23 | } 24 | 25 | export interface DialArgs { 26 | Number: string; 27 | } 28 | 29 | export interface AudioPlayArgs { 30 | Loop?: 'On' | 'Off'; 31 | Sound: 'Alert' | 'Busy' | 'CallInitiate' 32 | } 33 | 34 | export interface ConfigTree { 35 | SystemUnit: { 36 | Name: { 37 | get(): Promise; 38 | set(name: string): Promise; 39 | } 40 | } 41 | } 42 | 43 | export interface StatusTree { 44 | Audio: { 45 | Volume: { 46 | get(): Promise; 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /test/schema/fetch-schemas.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | if [ "$#" -lt 1 ]; then 6 | echo "usage: fetch-schemas hostname [format]" >&2 7 | echo " format: 'json' (default) or 'xml'" >&2 8 | exit 1 9 | fi 10 | 11 | fmt=${2:-json} 12 | 13 | get_schema() { 14 | # "xpref outputmode json" - to avoid printing "OK" at the end 15 | # "xpref accessmode internal" - because xdoc is internal 16 | # piping to sed drops all lines until the first "OK" - skip welcome text 17 | ssh -T "$1" < "$doc.$fmt" 27 | done 28 | -------------------------------------------------------------------------------- /test/schema/nodes.spec.ts: -------------------------------------------------------------------------------- 1 | import redent from 'redent'; 2 | 3 | import { 4 | Command, 5 | ImportStatement, 6 | List, 7 | Literal, 8 | Member, 9 | Root, 10 | Tree, 11 | } from '../../src/schema/nodes'; 12 | 13 | describe('schema nodes', () => { 14 | describe('Root', () => { 15 | it('empty document serializes to empty string', () => { 16 | const root = new Root(); 17 | expect(root.serialize()).toEqual(''); 18 | }); 19 | 20 | it('allows creating new interfaces', () => { 21 | const root = new Root(); 22 | root.addInterface('DialArgs'); 23 | expect(root.serialize()).toMatch('export interface DialArgs {}'); 24 | }); 25 | 26 | it('interface names must be unique', () => { 27 | const root = new Root(); 28 | root.addInterface('DialArgs'); 29 | expect(() => root.addInterface('DialArgs')).toThrow( 30 | /interface already exists/i, 31 | ); 32 | }); 33 | 34 | it('can only add a single Main class', () => { 35 | const root = new Root(); 36 | root.addMain(); 37 | expect(() => root.addMain()).toThrow(/main class already defined/i); 38 | }); 39 | 40 | describe('getMain()', () => { 41 | it('throws with no main defined', () => { 42 | const root = new Root(); 43 | expect(() => root.getMain()).toThrow(/no main class defined/i); 44 | }); 45 | 46 | it('can get main class', () => { 47 | const root = new Root(); 48 | const main = root.addMain(); 49 | expect(root.getMain()).toEqual(main); 50 | }); 51 | }); 52 | 53 | describe('addGenericInterfaces', () => { 54 | it('adds generic interfaces', () => { 55 | const root = new Root(); 56 | root.addGenericInterfaces(); 57 | const serialized = root.serialize(); 58 | expect(serialized).toMatch('export interface Gettable'); 59 | expect(serialized).toMatch('export interface Settable'); 60 | expect(serialized).toMatch('export interface Listenable'); 61 | }); 62 | 63 | it('adds Configify type', () => { 64 | const root = new Root(); 65 | root.addGenericInterfaces(); 66 | expect(root.serialize()).toMatch('type Configify ='); 67 | }); 68 | 69 | it('adds Statusify type', () => { 70 | const root = new Root(); 71 | root.addGenericInterfaces(); 72 | expect(root.serialize()).toMatch('type Statusify ='); 73 | }); 74 | }); 75 | 76 | it('can build entire module', () => { 77 | // .ts module 78 | const root = new Root('jsxapi'); 79 | 80 | // Main XAPI class + generic interfaces 81 | const main = root.addMain(); 82 | root.addGenericInterfaces(); 83 | 84 | const commandTree = root.addInterface('CommandTree'); 85 | main.addChild(new Member('Command', commandTree)); 86 | 87 | const configTree = root.addInterface('ConfigTree'); 88 | main.addChild(new Member('Config', configTree)); 89 | 90 | const statusTree = root.addInterface('StatusTree'); 91 | main.addChild(new Member('Status', statusTree)); 92 | 93 | // XAPI command APIs 94 | const audio = commandTree.addChild(new Tree('Audio')); 95 | audio.addChild(new Tree('Microphones')).addChild(new Command('Mute')); 96 | const audioPlayArgs = root.addInterface('AudioPlayArgs'); 97 | const soundLiteral = new Literal('Alert', 'Busy', 'CallInitiate'); 98 | const onOffLiteral = new Literal('On', 'Off'); 99 | audioPlayArgs.addChildren([ 100 | new Member('Sound', soundLiteral), 101 | new Member('Loop', onOffLiteral, { required: false }), 102 | ]); 103 | audio 104 | .addChild(new Tree('Sound')) 105 | .addChild(new Command('Play', audioPlayArgs)); 106 | const dialArgs = root.addInterface('DialArgs'); 107 | dialArgs.addChild(new Member('Number', 'string')); 108 | commandTree.addChild(new Command('Dial', dialArgs)); 109 | 110 | const resetArgs = root.addInterface('SystemUnitFactoryResetArgs'); 111 | resetArgs.addChild( 112 | new Member('Confirm', 'Yes', { required: true }), 113 | ); 114 | resetArgs.addChild( 115 | new Member( 116 | 'Keep', 117 | new List(new Literal('LocalSetup', 'Network', 'Provisioning')), 118 | { required: false }, 119 | ), 120 | ); 121 | commandTree 122 | .addChild(new Tree('SystemUnit')) 123 | .addChild(new Command('FactoryReset', resetArgs)); 124 | 125 | // XAPI config APIs 126 | configTree 127 | .addChild(new Tree('SystemUnit')) 128 | .addChild(new Member('Name', 'string')); 129 | 130 | // XAPI status APIs 131 | statusTree 132 | .addChild(new Tree('Audio')) 133 | .addChild(new Member('Volume', 'number')); 134 | 135 | // It dumps the shit 136 | expect(root.serialize()).toMatchSnapshot(); 137 | }); 138 | }); 139 | 140 | describe('ImportStatement', () => { 141 | it('serializes import child', () => { 142 | const node = new ImportStatement('jsxapi', ['XAPI', 'connectGen']); 143 | expect(node.serialize()).toMatch('import { XAPI, connectGen } from "jsxapi";'); 144 | }); 145 | 146 | it('can customize module', () => { 147 | const node = new ImportStatement('../../xapi', ['TypedXAPI']); 148 | expect(node.serialize()).toMatch('import { TypedXAPI } from "../../xapi";'); 149 | }); 150 | }); 151 | 152 | describe('MainClass', () => { 153 | it('extends base class', () => { 154 | const main = new Root().addMain(); 155 | expect(main.serialize()).toMatch( 156 | 'export class TypedXAPI extends XAPI {}', 157 | ); 158 | }); 159 | 160 | it('supports passing custom names', () => { 161 | const main = new Root().addMain('XapiWithTypes', { base: 'JSXAPI' }); 162 | expect(main.serialize()).toMatch( 163 | 'export class XapiWithTypes extends JSXAPI {}', 164 | ); 165 | }); 166 | 167 | it('exports as default', () => { 168 | const main = new Root().addMain(); 169 | expect(main.serialize()).toMatch('export default TypedXAPI'); 170 | }); 171 | 172 | it('uses connectGen to export connect by default', () => { 173 | const root = new Root(); 174 | root.addMain(); 175 | const serialized = root.serialize(); 176 | expect(serialized).toMatch(/import.*connect.*from.*jsxapi/); 177 | expect(serialized).toMatch('export const connect = connectGen(TypedXAPI);'); 178 | }); 179 | 180 | it('can skip generating connect export', () => { 181 | const root = new Root(); 182 | root.addMain(undefined, { withConnect: false }); 183 | const serialized = root.serialize(); 184 | expect(serialized).not.toMatch(/import.*connect/); 185 | expect(serialized).not.toMatch(/export.*connect/); 186 | }); 187 | 188 | it('exports an interface with name', () => { 189 | const main = new Root().addMain(); 190 | expect(main.serialize()).toMatch('export interface TypedXAPI {}'); 191 | }); 192 | }); 193 | 194 | describe('Interface', () => { 195 | it('can extend', () => { 196 | const root = new Root(); 197 | root.addInterface('Gettable'); 198 | const iface = root.addInterface('Config', ['Gettable']); 199 | expect(iface.serialize()).toMatch('export interface Config extends Gettable {}'); 200 | }); 201 | 202 | it('extending from an interface requires it to exist', () => { 203 | const root = new Root(); 204 | expect(() => root.addInterface('Config', ['Gettable'])).toThrow( 205 | /cannot add interface Config.*missing interfaces: Gettable/i, 206 | ); 207 | }); 208 | 209 | it('can add command (function)', () => { 210 | const iface = new Root().addInterface('CommandTree'); 211 | iface.addChild(new Command('Dial')); 212 | expect(iface.serialize()).toMatch( 213 | redent(` 214 | export interface CommandTree { 215 | Dial(): Promise; 216 | } 217 | `).trim(), 218 | ); 219 | }); 220 | 221 | it('can add tree', () => { 222 | const iface = new Root().addInterface('CommandTree'); 223 | iface 224 | .addChild(new Tree('Audio')) 225 | .addChild(new Tree('Microphones')) 226 | .addChild(new Command('Mute')); 227 | expect(iface.serialize()).toMatch( 228 | redent(` 229 | export interface CommandTree { 230 | Audio: { 231 | Microphones: { 232 | Mute(): Promise, 233 | }, 234 | }; 235 | } 236 | `).trim(), 237 | ); 238 | }); 239 | }); 240 | 241 | describe('List', () => { 242 | it('places literal in parentheses', () => { 243 | const literalArray = new List(new Literal('Foo', 'Bar', 'Baz')); 244 | expect(literalArray.getType()).toMatch("('Foo' | 'Bar' | 'Baz')[]"); 245 | }) 246 | }); 247 | 248 | describe('Member', () => { 249 | it('quotes members with names containing special characters', () => { 250 | const option = new Member('Option.1', 'string'); 251 | expect(option.serialize()).toMatch('"Option.1": string'); 252 | }); 253 | 254 | it('can add docstring', () => { 255 | const docstring = 'Define the default volume for the speakers.'; 256 | const command = new Member('Microphones', 'number', { docstring }); 257 | expect(command.serialize()).toMatch(docstring); 258 | }); 259 | }); 260 | 261 | describe('Tree', () => { 262 | it('renders levels of nesting', () => { 263 | const audio = new Tree('Audio'); 264 | expect(audio.serialize()).toMatchSnapshot(); 265 | 266 | const mic = audio.addChild(new Tree('Microphones')); 267 | expect(audio.serialize()).toMatchSnapshot(); 268 | 269 | mic.addChild(new Member('LedIndicator', new Literal('On', 'Off'))); 270 | expect(audio.serialize()).toMatchSnapshot(); 271 | }); 272 | }); 273 | 274 | describe('Command', () => { 275 | it('can add docstring', () => { 276 | const command = new Command('Microphones', undefined, undefined, { 277 | docstring: 'Mute all microphones.', 278 | }); 279 | expect(command.serialize()).toMatch('Mute all microphones.'); 280 | }); 281 | 282 | it('params arg optional if all members are optional (no body)', () => { 283 | const httpArgs = new Root().addInterface('HttpArgs'); 284 | httpArgs.addChild( 285 | new Member('Url', 'string', { required: false }), 286 | ); 287 | httpArgs.addChild( 288 | new Member('ResultBody', new Literal('None', 'PlainText'), { required: false }), 289 | ); 290 | 291 | const getCmd = new Command('Get', httpArgs, undefined); 292 | expect(getCmd.serialize()).toMatch(/\(args\?: HttpArgs\)/); 293 | 294 | const postCmd = new Command('Post', httpArgs, undefined, { 295 | multiline: true, 296 | }); 297 | expect(postCmd.serialize()).toMatch(/\(args: HttpArgs, body: string\)/); 298 | }); 299 | 300 | it('can be multiline (without params)', () => { 301 | const command = new Command('Post', undefined, undefined, { 302 | multiline: true, 303 | }); 304 | expect(command.serialize()).toMatch(/\(body: string\)/); 305 | }); 306 | 307 | it('can be multiline (with params)', () => { 308 | const postArgs = new Root().addInterface('PostArgs'); 309 | postArgs.addChild( 310 | new Member('Url', 'string', { 311 | required: true, 312 | }), 313 | ); 314 | 315 | const command = new Command('Post', postArgs, undefined, { 316 | multiline: true, 317 | }); 318 | expect(command.serialize()).toMatch(/\(args: PostArgs, body: string\)/); 319 | }); 320 | }); 321 | 322 | // There is no guarantee in xstatus that a node is present, so the result type 323 | // of a getting a status leaf or sub-tree should reflect this. 324 | it.todo('Status results should be optional/partial'); 325 | }); 326 | -------------------------------------------------------------------------------- /test/schema/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { filter, merge } from '../../src/schema/utils'; 2 | 3 | describe('schemas', () => { 4 | describe('filter()', () => { 5 | it('filters arrays', () => { 6 | const a = { 7 | node: [ 8 | { 9 | foo: { bar: 'baz', access: 'public' }, 10 | bar: { baz: 'quux', access: 'private' }, 11 | }, 12 | ], 13 | }; 14 | 15 | expect(filter(a, ['public'])).toEqual({ 16 | node: [{ 17 | foo: { bar: 'baz', access: 'public' }, 18 | }], 19 | }); 20 | }); 21 | 22 | it('filters objects', () => { 23 | const a = { 24 | foo: { bar: 'baz', access: 'public' }, 25 | bar: { baz: 'quux', access: 'private' }, 26 | }; 27 | 28 | expect(filter(a, ['public'])).toEqual({ 29 | foo: { bar: 'baz', access: 'public' }, 30 | }); 31 | }); 32 | }); 33 | 34 | describe('merge()', () => { 35 | it('left identity', () => { 36 | const a = { 37 | foo: { 38 | bar: 'baz', 39 | }, 40 | }; 41 | expect(merge({}, a)).toEqual(a); 42 | }); 43 | 44 | it('right identity', () => { 45 | const a = { 46 | foo: { 47 | bar: 'baz', 48 | }, 49 | }; 50 | expect(merge(a, {})).toEqual(a); 51 | }); 52 | 53 | it('merges two schemas', () => { 54 | const a = { 55 | foo: { bar: 10 }, 56 | }; 57 | 58 | const b = { 59 | foo: { baz: 20 }, 60 | }; 61 | 62 | expect(merge(a, b)).toEqual({ 63 | foo: { 64 | bar: 10, 65 | baz: 20, 66 | }, 67 | }); 68 | }); 69 | 70 | it('dies on mismatching nested types', () => { 71 | const a = { 72 | foo: 'bar', 73 | }; 74 | 75 | const b = { 76 | foo: 42, 77 | }; 78 | 79 | expect(() => merge(a, b)).toThrow(/mismatching types/i); 80 | }); 81 | 82 | it('merges arrays of scalars', () => { 83 | const a = { 84 | foo: ['bar', 'baz'], 85 | }; 86 | 87 | const b = { 88 | foo: ['bar', 'quux' ], 89 | }; 90 | 91 | expect(merge(a, b)).toEqual({ 92 | foo: ['bar', 'baz', 'quux'], 93 | }); 94 | }); 95 | 96 | it('merges nested objects in arrays', () => { 97 | const a = { 98 | foo: [{ 99 | bar: ['foo', 'bar'], 100 | baz: 10, 101 | }], 102 | }; 103 | 104 | const b = { 105 | foo: [{ 106 | bar: ['baz'], 107 | quux: 42, 108 | }], 109 | }; 110 | 111 | expect(merge(a, b)).toEqual({ 112 | foo: [{ 113 | bar: ['foo', 'bar', 'baz'], 114 | baz: 10, 115 | quux: 42, 116 | }], 117 | }); 118 | }); 119 | }); 120 | }); 121 | -------------------------------------------------------------------------------- /test/transport/ssh.spec.ts: -------------------------------------------------------------------------------- 1 | import { Client } from 'ssh2'; 2 | import { Duplex } from 'stream'; 3 | 4 | import logger from '../../src/log'; 5 | import connectSSH from '../../src/transport/ssh'; 6 | 7 | describe('connectSSH', () => { 8 | let client: Client; 9 | let dataSpy: jest.Mock; 10 | let errorSpy: jest.Mock; 11 | let closeSpy: jest.Mock; 12 | let clientConnectStub: jest.SpyInstance; 13 | let clientShellStub: jest.SpyInstance; 14 | let clientExecStub: jest.SpyInstance; 15 | let clientEndStub: jest.SpyInstance; 16 | 17 | beforeEach(() => { 18 | logger.disableAll(); 19 | 20 | client = new Client(); 21 | clientConnectStub = jest.spyOn(client, 'connect').mockImplementation(() => { return client; }); 22 | clientShellStub = jest.spyOn(client, 'shell').mockImplementation(() => { return client; }); 23 | clientExecStub = jest.spyOn(client, 'exec').mockImplementation(() => { return client; }); 24 | clientEndStub = jest.spyOn(client, 'end').mockImplementation(() => { return client; }); 25 | 26 | dataSpy = jest.fn(); 27 | errorSpy = jest.fn(); 28 | closeSpy = jest.fn(); 29 | }); 30 | 31 | describe('emits "error" on transport stream', () => { 32 | beforeEach(() => { 33 | connectSSH({ client }) 34 | .on('data', dataSpy) 35 | .on('error', errorSpy) 36 | .on('close', closeSpy); 37 | }); 38 | 39 | it('on client error (extracts .level property)', () => { 40 | const error: any = new Error('some error'); 41 | error.level = 'client-error'; 42 | client.emit('error', error); 43 | 44 | expect(errorSpy).toHaveBeenCalledTimes(1); 45 | expect(errorSpy.mock.calls[0]).toContain('client-error'); 46 | }); 47 | 48 | it('on shell error', () => { 49 | const error = new Error('some error'); 50 | clientShellStub.mockImplementation((_, fn) => { fn(error); }); 51 | 52 | client.emit('ready'); 53 | 54 | expect(errorSpy).toHaveBeenCalledTimes(1); 55 | expect(errorSpy.mock.calls[0]).toContain(error); 56 | }); 57 | 58 | it('on ssh stream error', () => { 59 | const error = new Error('some error'); 60 | const sshStream = new Duplex({ read: () => {} }); 61 | clientShellStub.mockImplementation((_, fn) => { fn(null, sshStream); }); 62 | 63 | client.emit('ready'); 64 | sshStream.emit('error', error); 65 | 66 | expect(errorSpy).toHaveBeenCalledTimes(1); 67 | expect(errorSpy.mock.calls[0]).toContain(error); 68 | }); 69 | }); 70 | 71 | describe('emits "close" on transport stream', () => { 72 | beforeEach(() => { 73 | connectSSH({ client }) 74 | .on('data', dataSpy) 75 | .on('error', errorSpy) 76 | .on('close', closeSpy); 77 | }); 78 | 79 | it('on client close', () => { 80 | client.emit('close'); 81 | 82 | expect(closeSpy).toHaveBeenCalledTimes(1); 83 | }); 84 | 85 | it('on ssh stream close', () => { 86 | const sshStream = new Duplex({ read: () => {} }); 87 | clientShellStub.mockImplementation((_, fn) => { fn(null, sshStream); }); 88 | 89 | client.emit('ready'); 90 | sshStream.emit('close'); 91 | 92 | expect(closeSpy).toHaveBeenCalledTimes(1); 93 | }); 94 | }); 95 | 96 | describe('stream', () => { 97 | beforeEach(() => { 98 | connectSSH({ client }) 99 | .on('data', dataSpy) 100 | .on('error', errorSpy) 101 | .on('close', closeSpy); 102 | }); 103 | 104 | it('passes through ssh stream data', () => { 105 | const sshStream = new Duplex({ read: () => {} }); 106 | clientShellStub.mockImplementation((_, fn) => { fn(null, sshStream); }); 107 | 108 | client.emit('ready'); 109 | sshStream.push('foo bar baz'); 110 | 111 | expect(dataSpy).toHaveBeenCalledTimes(1); 112 | expect(dataSpy.mock.calls[0][0].toString()).toEqual('foo bar baz'); 113 | }); 114 | 115 | it('"close" is emitted on stream end', () => { 116 | const sshStream = new Duplex({ read: () => {} }); 117 | clientShellStub.mockImplementation((_, fn) => { fn(null, sshStream); }); 118 | 119 | client.emit('ready'); 120 | sshStream.emit('close'); 121 | 122 | expect(closeSpy).toHaveBeenCalledTimes(1); 123 | }); 124 | }); 125 | 126 | describe('remote command', () => { 127 | beforeEach(() => { 128 | connectSSH({ client, command: '/bin/foo' }) 129 | .on('data', dataSpy) 130 | .on('error', errorSpy) 131 | .on('close', closeSpy); 132 | }); 133 | 134 | it('passes through ssh binary stream data', () => { 135 | const sshStream = new Duplex({ read: () => {} }); 136 | const binaryStream = new Duplex({ read: () => {} }); 137 | clientShellStub.mockImplementation((_, fn) => { fn(null, sshStream); }); 138 | clientExecStub.mockImplementation((_, fn) => { fn(null, binaryStream); }); 139 | 140 | client.emit('ready'); 141 | sshStream.push('boing boing'); 142 | binaryStream.push('foo bar baz'); 143 | 144 | expect(dataSpy).toHaveBeenCalledTimes(1); 145 | expect(dataSpy.mock.calls[0][0].toString()).toEqual('foo bar baz'); 146 | }); 147 | 148 | it('correct command is sent', () => { 149 | const sshStream = new Duplex({ read: () => {} }); 150 | clientShellStub.mockImplementation((_, fn) => { fn(null, sshStream); }); 151 | 152 | client.emit('ready'); 153 | 154 | expect(clientExecStub).toHaveBeenCalledTimes(1); 155 | expect(clientExecStub.mock.calls[0][0].toString()).toEqual('/bin/foo'); 156 | }); 157 | }); 158 | 159 | it('does not pass on password to .connect()', () => { 160 | connectSSH({ 161 | client, 162 | username: 'admin', 163 | password: 'password', 164 | }); 165 | 166 | const options = clientConnectStub.mock.calls[0][0]; 167 | expect(options).toHaveProperty('username', 'admin'); 168 | expect(options).not.toHaveProperty('password'); 169 | }); 170 | 171 | it('closing with ".close()" does not emit error', () => { 172 | const transport = connectSSH({ 173 | client, 174 | }); 175 | 176 | const sshStream = new Duplex({ read: () => {} }); 177 | clientShellStub.mockImplementation((_, fn) => { fn(null, sshStream); }); 178 | clientEndStub.mockImplementation(() => { 179 | sshStream.emit('end'); 180 | }); 181 | transport.on('error', errorSpy); 182 | 183 | client.emit('ready'); 184 | transport.close(); 185 | 186 | expect(errorSpy).not.toHaveBeenCalled(); 187 | }); 188 | 189 | it('remotely ended ssh stream emits errors', () => { 190 | const transport = connectSSH({ 191 | client, 192 | }); 193 | 194 | const sshStream = new Duplex({ read: () => {} }); 195 | clientShellStub.mockImplementation((_, fn) => { fn(null, sshStream); }); 196 | transport.on('error', errorSpy); 197 | client.emit('ready'); 198 | 199 | sshStream.emit('end'); 200 | 201 | expect(errorSpy).toHaveBeenCalledTimes(1); 202 | expect(errorSpy.mock.calls[0]).toContain( 203 | 'Connection terminated remotely', 204 | ); 205 | }); 206 | }); 207 | -------------------------------------------------------------------------------- /test/transport/stream.spec.ts: -------------------------------------------------------------------------------- 1 | import StreamTransport from '../../src/transport/stream'; 2 | 3 | describe('StreamTransport', () => { 4 | it('can push before listening', (done) => { 5 | const stream = new StreamTransport(null); 6 | 7 | stream.push('foo bar baz'); 8 | 9 | stream.on('data', (data) => { 10 | expect(data.toString()).toEqual('foo bar baz'); 11 | done(); 12 | }); 13 | }); 14 | 15 | it('can push after listening', (done) => { 16 | const stream = new StreamTransport(null); 17 | 18 | stream.on('data', (data) => { 19 | expect(data.toString()).toEqual('foo bar baz'); 20 | done(); 21 | }); 22 | 23 | stream.push('foo bar baz'); 24 | }); 25 | 26 | it('emits close on .close()', (done) => { 27 | const stream = new StreamTransport(null); 28 | 29 | stream.on('close', () => { 30 | done(); 31 | }); 32 | 33 | stream.close(); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /test/xapi/feedback.spec.ts: -------------------------------------------------------------------------------- 1 | import Backend from '../../src/backend'; 2 | import XAPI from '../../src/xapi'; 3 | import Feedback, { FeedbackGroup } from '../../src/xapi/feedback'; 4 | 5 | function getPath(obj: any, ...path: any[]) { 6 | let tmp = obj; 7 | 8 | while (typeof tmp === 'object' && path.length) { 9 | const key = path.shift(); 10 | tmp = tmp[key]; 11 | } 12 | 13 | return tmp; 14 | } 15 | 16 | describe('Feedback', () => { 17 | let interceptor: jest.SpyInstance; 18 | let feedback: Feedback; 19 | let xapi: XAPI; 20 | let executeStub: jest.SpyInstance; 21 | 22 | beforeEach(() => { 23 | interceptor = jest.fn().mockImplementation((_, fn) => { 24 | fn(); 25 | }); 26 | xapi = new XAPI(new Backend(), { 27 | feedbackInterceptor: interceptor, 28 | seal: true, 29 | } as any); 30 | ({ feedback } = xapi); 31 | 32 | let nextSubscriptionId = 0; 33 | executeStub = jest 34 | .spyOn(XAPI.prototype, 'execute') 35 | .mockImplementation((method) => { 36 | switch (method) { 37 | case 'xFeedback/Subscribe': { 38 | const id = nextSubscriptionId; 39 | nextSubscriptionId += 1; 40 | return Promise.resolve({ Id: id }); 41 | } 42 | default: 43 | return Promise.resolve({ Id: 52 }); 44 | } 45 | }); 46 | }); 47 | 48 | afterEach(() => { 49 | executeStub.mockRestore(); 50 | }); 51 | 52 | it('is instance of Feedback', () => { 53 | expect(feedback).toBeInstanceOf(Feedback); 54 | }); 55 | 56 | describe('.dispatch()', () => { 57 | it('returns this', () => { 58 | expect(feedback.dispatch({ Status: 'foo' })).toEqual(feedback); 59 | }); 60 | 61 | it('fires event', async () => { 62 | const spy = jest.fn(); 63 | await feedback.on('Status', spy).registration; 64 | feedback.dispatch({ Status: 'foo' }); 65 | expect(spy).toHaveBeenCalledTimes(1); 66 | expect(spy).toHaveBeenCalledWith('foo', expect.anything()); 67 | }); 68 | 69 | it('fires events recursively', async () => { 70 | const data = { Status: { Audio: { Volume: '50' } } }; 71 | const spies = [jest.fn(), jest.fn(), jest.fn(), jest.fn()]; 72 | 73 | await Promise.all([ 74 | feedback.on('', spies[0]).registration, 75 | feedback.on('Status', spies[1]).registration, 76 | feedback.on('Status/Audio', spies[2]).registration, 77 | feedback.on('Status/Audio/Volume', spies[3]).registration, 78 | ]); 79 | 80 | feedback.dispatch(data); 81 | 82 | [data, data.Status, data.Status.Audio, data.Status.Audio.Volume].forEach( 83 | (d, i) => { 84 | expect(spies[i]).toHaveBeenNthCalledWith(1, d, expect.anything()); 85 | }, 86 | ); 87 | }); 88 | 89 | it('does not invoke unrelated handlers', async () => { 90 | const spy1 = jest.fn(); 91 | const spy2 = jest.fn(); 92 | 93 | await Promise.all([ 94 | feedback.on('Status/Audio/Volume', spy1).registration, 95 | feedback.on('Status/Audio/VolumeMute', spy2).registration, 96 | ]); 97 | 98 | feedback.dispatch({ Status: { Audio: { VolumeMute: 'off' } } }); 99 | 100 | expect(spy1).not.toHaveBeenCalled(); 101 | expect(spy2).toHaveBeenCalledTimes(1); 102 | expect(spy2).toHaveBeenCalledWith('off', expect.anything()); 103 | }); 104 | 105 | it('dispatches original feedback payload as second argument', async () => { 106 | const spy = jest.fn(); 107 | const data = { Status: { Call: [{ id: 42, Status: 'Connected' }] } }; 108 | 109 | await feedback.on('Status/Call/Status', spy).registration; 110 | feedback.dispatch(data); 111 | 112 | expect(spy).toHaveBeenCalledWith('Connected', data); 113 | }); 114 | 115 | it('can listen to lower-case events', async () => { 116 | const spy = jest.fn(); 117 | const data = { Status: { Call: [{ id: 42, ghost: 'True' }] } }; 118 | 119 | await feedback.on('Status/Call/ghost', spy).registration; 120 | feedback.dispatch(data); 121 | 122 | expect(spy).toHaveBeenCalledWith('True', data); 123 | }); 124 | 125 | it('fires only on matching Id', async () => { 126 | const spy1 = jest.fn(); 127 | const spy2 = jest.fn(); 128 | 129 | await Promise.all([ 130 | feedback.on('Status/Audio/Volume', spy1).registration, 131 | feedback.on('Status/Audio/Volume', spy2).registration, 132 | ]); 133 | 134 | feedback.dispatch({ 135 | Id: 0, 136 | Status: { Audio: { Volume: '50' } }, 137 | }); 138 | 139 | expect(spy1).toHaveBeenCalledTimes(1); 140 | expect(spy2).not.toHaveBeenCalled(); 141 | }); 142 | }); 143 | 144 | describe('.on()', () => { 145 | it('registers handler for events', async () => { 146 | const spy = jest.fn(); 147 | 148 | await feedback.on('Status/Audio/Volume', spy).registration; 149 | feedback.dispatch({ Status: { Audio: { Volume: 50 } } }); 150 | 151 | expect(spy).toHaveBeenCalledWith(50, expect.anything()); 152 | }); 153 | 154 | it('returns handler for disabling feedback', async () => { 155 | const spy = jest.fn(); 156 | 157 | const handler = feedback.on('Status/Audio/Volume', spy); 158 | await handler.registration; 159 | feedback.dispatch({ Status: { Audio: { Volume: 50 } } }); 160 | 161 | expect(spy).toHaveBeenCalledWith(50, expect.anything()); 162 | spy.mockClear(); 163 | 164 | handler(); 165 | 166 | feedback.dispatch({ Status: { Audio: { Volume: 50 } } }); 167 | expect(spy).not.toHaveBeenCalled(); 168 | }); 169 | 170 | it('off handler contains registration promise', async () => { 171 | const spy = jest.fn(); 172 | 173 | const regs = await Promise.all([ 174 | feedback.on('Status/Audio/Volume', spy).registration, 175 | feedback.on('Status/Audio/Volume', spy).registration, 176 | ]); 177 | 178 | expect(regs).toEqual([{ Id: 0 }, { Id: 1 }]); 179 | }); 180 | 181 | it('registers feedback with the backend', () => { 182 | const path = 'Status/Audio/Volume'; 183 | 184 | feedback.on(path, () => {}); 185 | 186 | expect(xapi.execute).toHaveBeenCalledWith('xFeedback/Subscribe', { 187 | Query: ['Status', 'Audio', 'Volume'], 188 | }); 189 | }); 190 | 191 | it('cancelling double registration leaves one listener', async () => { 192 | const spy = jest.fn(); 193 | const path = 'Status/Audio/Volume'; 194 | 195 | await feedback.on(path, spy).registration; 196 | const off = feedback.on(path, spy); 197 | await off.registration; 198 | off(); 199 | 200 | feedback.dispatch({ Status: { Audio: { Volume: 50 } } }); 201 | 202 | expect(spy).toHaveBeenCalledTimes(1); 203 | expect(spy).toHaveBeenCalledWith(50, expect.anything()); 204 | }); 205 | 206 | it('normalizes path', () => { 207 | const path = 'status/audio volume'; 208 | 209 | feedback.on(path, () => {}); 210 | 211 | expect(xapi.execute).toHaveBeenCalledWith('xFeedback/Subscribe', { 212 | Query: ['Status', 'Audio', 'Volume'], 213 | }); 214 | }); 215 | 216 | it('can dispatch to normalized path', async () => { 217 | const spy = jest.fn(); 218 | 219 | await feedback.on('status/audio volume', spy).registration; 220 | feedback.dispatch({ Status: { Audio: { Volume: 50 } } }); 221 | 222 | expect(spy).toHaveBeenNthCalledWith(1, 50, expect.anything()); 223 | }); 224 | 225 | it('can use crazy casing', async () => { 226 | const spy = jest.fn(); 227 | 228 | await feedback.on('fOO Bar BaZ', spy).registration; 229 | feedback.dispatch({ Foo: { Bar: { Baz: 50 } } }); 230 | 231 | expect(spy).toHaveBeenNthCalledWith(1, 50, expect.anything()); 232 | }); 233 | 234 | it('handles arrays', async () => { 235 | const spy1 = jest.fn(); 236 | const spy2 = jest.fn(); 237 | const spy3 = jest.fn(); 238 | const spy4 = jest.fn(); 239 | 240 | await Promise.all([ 241 | feedback.on('Status/Peripherals/ConnectedDevice/Status', spy1).registration, 242 | feedback.on('Status/Peripherals/ConnectedDevice[]/Status', spy2).registration, 243 | feedback.on('Status/Peripherals/ConnectedDevice/1115/Status', spy3).registration, 244 | feedback.on('Status/Peripherals/ConnectedDevice[1115]/Status', spy4).registration, 245 | ]); 246 | 247 | feedback.dispatch({ 248 | Status: { 249 | Peripherals: { 250 | ConnectedDevice: [ 251 | { id: '1115', Status: 'LostConnection' }, 252 | { id: '1020', Status: 'Connected' }, 253 | ], 254 | }, 255 | }, 256 | }); 257 | 258 | expect(spy1).toHaveBeenNthCalledWith(1, 'LostConnection', expect.anything()); 259 | expect(spy1).toHaveBeenNthCalledWith(2, 'Connected', expect.anything()); 260 | 261 | expect(spy2).toHaveBeenNthCalledWith(1, 'LostConnection', expect.anything()); 262 | expect(spy2).toHaveBeenNthCalledWith(2, 'Connected', expect.anything()); 263 | 264 | expect(spy3).toHaveBeenCalledTimes(1); 265 | expect(spy3).toHaveBeenCalledWith('LostConnection', expect.anything()); 266 | expect(spy3).not.toHaveBeenCalledWith('Connected', expect.anything()); 267 | 268 | expect(spy4).toHaveBeenCalledTimes(1); 269 | expect(spy4).toHaveBeenCalledWith('LostConnection', expect.anything()); 270 | expect(spy4).not.toHaveBeenCalledWith('Connected', expect.anything()); 271 | }); 272 | 273 | it('dispatches array elements one-by-one', async () => { 274 | const spy = jest.fn(); 275 | 276 | await feedback.on('foo/bar', spy).registration; 277 | 278 | feedback.dispatch({ 279 | foo: { bar: [{ baz: 'quux' }] }, 280 | }); 281 | 282 | expect(spy).toHaveBeenCalledTimes(1); 283 | expect(spy).toHaveBeenCalledWith({ baz: 'quux' }, expect.anything()); 284 | }); 285 | 286 | it('handles ghost events', async () => { 287 | const spy = jest.fn(); 288 | 289 | await feedback 290 | .on('Status/Peripherals/ConnectedDevice', spy) 291 | .registration; 292 | 293 | feedback.dispatch({ 294 | Status: { 295 | Peripherals: { 296 | ConnectedDevice: [ 297 | { 298 | id: '1115', 299 | ghost: 'True', 300 | }, 301 | ], 302 | }, 303 | }, 304 | }); 305 | 306 | expect(spy).toHaveBeenNthCalledWith( 307 | 1, 308 | { 309 | id: '1115', 310 | ghost: 'True', 311 | }, 312 | expect.anything(), 313 | ); 314 | }); 315 | 316 | it('is called by .once()', () => { 317 | const path = 'Status/Audio/Volume'; 318 | const listener = () => {}; 319 | 320 | const spy = jest.spyOn(feedback, 'on'); 321 | feedback.once(path, listener); 322 | 323 | const [callPath, callListener] = spy.mock.calls[0]; 324 | 325 | expect(callPath).toEqual(path); 326 | expect((callListener as any).listener).toEqual(listener); 327 | }); 328 | }); 329 | 330 | describe('.once()', () => { 331 | it('deregisters after emit', async () => { 332 | const spy = jest.fn(); 333 | 334 | await feedback.once('Status/Audio/Volume', spy).registration; 335 | 336 | feedback.dispatch({ Status: { Audio: { Volume: '50' } } }); 337 | feedback.dispatch({ Status: { Audio: { Volume: '70' } } }); 338 | 339 | expect(spy).toHaveBeenCalledWith('50', expect.anything()); 340 | expect(spy).not.toHaveBeenCalledWith('70', expect.anything()); 341 | }); 342 | }); 343 | 344 | describe('.off()', () => { 345 | it('is now deprecated/removed and throws Error', () => { 346 | const spy = jest.fn(); 347 | const path = 'Status/Audio/Volume'; 348 | 349 | feedback.on(path, spy); 350 | 351 | const off = () => { 352 | feedback.off(); 353 | }; 354 | 355 | expect(off).toThrow(/deprecated/); 356 | }); 357 | }); 358 | 359 | describe('interceptor', () => { 360 | beforeEach(() => { 361 | interceptor.mockReset(); 362 | }); 363 | 364 | it('can reject feedback', async () => { 365 | const volumeSpy = jest.fn(); 366 | const data = { Status: { Audio: { Volume: '50' } } }; 367 | 368 | interceptor 369 | .mockImplementationOnce(() => {}) 370 | .mockImplementationOnce((_, fn) => { 371 | fn(); 372 | }); 373 | 374 | await feedback.on('Status Audio Volume', volumeSpy).registration; 375 | 376 | feedback.dispatch(data); 377 | expect(volumeSpy).not.toHaveBeenCalled(); 378 | 379 | feedback.dispatch(data); 380 | expect(volumeSpy).toHaveBeenCalledTimes(1); 381 | expect(volumeSpy).toHaveBeenCalledWith('50', data); 382 | }); 383 | 384 | it('can change the data', async () => { 385 | const spy = jest.fn(); 386 | 387 | interceptor.mockImplementation( 388 | (data: any, dispatch: (d: any) => void) => { 389 | const item = getPath(data, 'Status', 'Audio', 'Volume'); 390 | if (item) { 391 | data.Status.Audio.Volume = '100'; 392 | dispatch(data); 393 | } 394 | }, 395 | ); 396 | 397 | await feedback.on('Status Audio Volume', spy).registration; 398 | 399 | const data = { Status: { Audio: { Volume: '50' } } }; 400 | feedback.dispatch(data); 401 | 402 | expect(spy).toHaveBeenCalledTimes(1); 403 | expect(spy).toHaveBeenCalledWith('100', data); 404 | }); 405 | }); 406 | 407 | describe('.group()', () => { 408 | let group: FeedbackGroup; 409 | let muteSpy: jest.Mock; 410 | let volumeSpy: jest.Mock; 411 | 412 | beforeEach(() => { 413 | muteSpy = jest.fn(); 414 | volumeSpy = jest.fn(); 415 | group = feedback.group([ 416 | xapi.status.on('Audio/Volume', volumeSpy), 417 | xapi.status.on('Audio/VolumeMute', muteSpy), 418 | ]); 419 | }); 420 | 421 | it('can register and dispatch as normal', () => { 422 | feedback.dispatch({ 423 | Status: { 424 | Audio: { 425 | Volume: '50', 426 | VolumeMute: 'On', 427 | }, 428 | }, 429 | }); 430 | 431 | expect(volumeSpy).toHaveBeenNthCalledWith(1, '50', expect.anything()); 432 | expect(muteSpy).toHaveBeenNthCalledWith(1, 'On', expect.anything()); 433 | }); 434 | 435 | it('only deregisters feedback of the group', async () => { 436 | const rootSpy = jest.fn(); 437 | await xapi.status.on('Audio/Volume', rootSpy).registration; 438 | 439 | group.off(); 440 | 441 | feedback.dispatch({ 442 | Status: { 443 | Audio: { 444 | Volume: '50', 445 | VolumeMute: 'On', 446 | }, 447 | }, 448 | }); 449 | 450 | expect(rootSpy).toHaveBeenNthCalledWith(1, '50', expect.anything()); 451 | expect(volumeSpy).not.toHaveBeenCalled(); 452 | expect(muteSpy).not.toHaveBeenCalled(); 453 | }); 454 | 455 | it('supports .once()', async () => { 456 | const spy = jest.fn(); 457 | 458 | group.off(); 459 | 460 | const subscription = xapi.status.once('Audio/Volume', spy); 461 | group.add(subscription); 462 | 463 | await subscription.registration; 464 | 465 | feedback.dispatch({ Status: { Audio: { Volume: '50' } } }); 466 | feedback.dispatch({ Status: { Audio: { Volume: '70' } } }); 467 | 468 | expect(spy).toHaveBeenCalledTimes(1); 469 | expect(spy).toHaveBeenCalledWith('50', expect.anything()); 470 | }); 471 | 472 | it('can clean up .once() before emit with .off()', () => { 473 | const spy = jest.fn(); 474 | 475 | group.add(xapi.status.once('Status/Audio/Volume', spy)); 476 | group.off(); 477 | 478 | feedback.dispatch({ Status: { Audio: { Volume: '50' } } }); 479 | 480 | expect(spy).not.toHaveBeenCalled(); 481 | }); 482 | }); 483 | }); 484 | -------------------------------------------------------------------------------- /test/xapi/index.spec.ts: -------------------------------------------------------------------------------- 1 | import Backend from '../../src/backend'; 2 | import XAPI from '../../src/xapi'; 3 | import * as rpc from '../../src/xapi/rpc'; 4 | import { XapiRequest } from '../../src/xapi/types'; 5 | import { METHOD_NOT_FOUND } from '../../src/xapi/exc'; 6 | 7 | describe('XAPI', () => { 8 | let backend: Backend; 9 | let xapi: XAPI; 10 | 11 | beforeEach(() => { 12 | backend = new Backend(); 13 | xapi = new XAPI(backend); 14 | }); 15 | 16 | describe('property', () => { 17 | const props = [ 18 | 'command', 19 | 'Command', 20 | 'config', 21 | 'Config', 22 | 'doc', 23 | 'event', 24 | 'Event', 25 | 'feedback', 26 | 'status', 27 | 'Status', 28 | ]; 29 | 30 | props.forEach((prop) => { 31 | it(`${prop} is not writable by default`, () => { 32 | const fn = () => { 33 | (xapi as any)[prop] = {}; 34 | }; 35 | expect(fn).toThrow(TypeError); 36 | }); 37 | 38 | it(`${prop} is not writable with non-default XAPI options`, () => { 39 | const fn = () => { 40 | const xapi = new XAPI(backend, { feedbackInterceptor: () => {} }); 41 | (xapi as any)[prop] = {}; 42 | }; 43 | expect(fn).toThrow(TypeError); 44 | }); 45 | 46 | it(`${prop} is writable if overridden`, () => { 47 | const fn = () => { 48 | const xapi = new XAPI(backend, { seal: false }); 49 | (xapi as any)[prop] = {}; 50 | }; 51 | expect(fn).not.toThrow(TypeError); 52 | }); 53 | }); 54 | }); 55 | 56 | describe('events', () => { 57 | it('emits "ready" when backend is ready', () => { 58 | const readySpy = jest.fn(); 59 | 60 | xapi.on('ready', readySpy); 61 | 62 | backend.emit('ready'); 63 | 64 | expect(readySpy).toHaveBeenCalledTimes(1); 65 | expect(readySpy).toHaveBeenCalledWith(xapi); 66 | }); 67 | 68 | it('emits "error" on backend error', () => { 69 | const error = new Error('some error'); 70 | const errorSpy = jest.fn(); 71 | 72 | xapi.on('error', errorSpy); 73 | 74 | backend.emit('error', error); 75 | 76 | expect(errorSpy).toHaveBeenCalledTimes(1); 77 | expect(errorSpy).toHaveBeenCalledWith(error); 78 | }); 79 | 80 | it('emits "close" on backend close', () => { 81 | const closeSpy = jest.fn(); 82 | 83 | xapi.on('close', closeSpy); 84 | 85 | backend.emit('close'); 86 | 87 | expect(closeSpy).toHaveBeenCalledTimes(1); 88 | }); 89 | }); 90 | 91 | describe('.feedback', () => { 92 | it('property is enumerable', () => { 93 | expect(Object.keys(xapi)).toContain('feedback'); 94 | }); 95 | 96 | it('property is not writable', () => { 97 | const fn = () => { 98 | xapi.feedback = {} as any; 99 | }; 100 | expect(fn).toThrow(TypeError); 101 | }); 102 | 103 | it('is dispatched to feedback handler', () => { 104 | const stub = jest.spyOn(xapi.feedback, 'dispatch').mockImplementation(function () { return this; }); 105 | const params = { Status: { Audio: { Volume: 50 } } }; 106 | 107 | backend.emit('data', { 108 | jsonrpc: '2.0', 109 | method: 'xFeedback/Event', 110 | params, 111 | }); 112 | 113 | expect(stub).toHaveBeenCalledWith(params); 114 | }); 115 | }); 116 | 117 | describe('.execute()', () => { 118 | beforeEach(() => { 119 | backend = new Backend(); 120 | xapi = new XAPI(backend); 121 | }); 122 | 123 | type Response = { 124 | result: any; 125 | } | { 126 | error: { 127 | code: any; 128 | message: string; 129 | } 130 | }; 131 | 132 | const asyncResponse = (backend_: Backend, response: Response) => (request: XapiRequest) => { 133 | return new Promise(resolve => { 134 | setTimeout(() => { 135 | backend_.emit( 136 | 'data', 137 | Object.assign( 138 | { 139 | jsonrpc: '2.0', 140 | id: request.id, 141 | }, 142 | response, 143 | ), 144 | ); 145 | resolve(); 146 | }, 0); 147 | }); 148 | }; 149 | 150 | it('returns a Promise object', () => { 151 | jest.spyOn(backend, 'execute').mockResolvedValue(undefined); 152 | 153 | const result = xapi.execute('xCommand/Dial', { 154 | Number: 'user@example.com', 155 | }); 156 | 157 | expect(result).toBeInstanceOf(Promise); 158 | }); 159 | 160 | it('resolves promise when backend emits success response', async () => { 161 | jest.spyOn(backend, 'execute').mockImplementation( 162 | asyncResponse(backend, { 163 | result: { 164 | CallId: 3, 165 | ConferenceId: 2, 166 | }, 167 | }), 168 | ); 169 | 170 | const result = await xapi.execute('xCommand/Dial', { 171 | Number: 'user@example.com', 172 | }); 173 | 174 | expect(result).toEqual({ 175 | CallId: 3, 176 | ConferenceId: 2, 177 | }); 178 | }); 179 | 180 | it('rejects promise when backend emits error response', () => { 181 | jest.spyOn(backend, 'execute').mockImplementation( 182 | asyncResponse(backend, { 183 | error: { 184 | code: METHOD_NOT_FOUND, 185 | message: 'Unknown command', 186 | }, 187 | }), 188 | ); 189 | 190 | const result = xapi.execute('xCommand/Foo/Bar', { Baz: 'quux' }); 191 | 192 | return expect(result).rejects.toMatchObject({ message: 'Unknown command' }); 193 | }); 194 | }); 195 | 196 | describe('Components', () => { 197 | let execStub: jest.SpyInstance; 198 | 199 | beforeEach(() => { 200 | let nextFeedbackId = 0; 201 | execStub = jest.spyOn(XAPI.prototype, 'execute'); 202 | jest 203 | .spyOn(backend, 'execute') 204 | .mockImplementation(async (request) => { 205 | switch (request.method) { 206 | case 'xFeedback/Subscribe': { 207 | setImmediate(() => { 208 | backend.emit('data', rpc.createResponse(request.id!, { 209 | Id: nextFeedbackId++, 210 | })); 211 | }); 212 | } 213 | } 214 | }); 215 | }); 216 | 217 | afterEach(() => { 218 | execStub.mockRestore(); 219 | }) 220 | 221 | describe('.command()', () => { 222 | it('invokes and returns .execute()', () => { 223 | const result = xapi.command('Dial', { Number: 'user@example.com' }); 224 | 225 | expect(execStub).toHaveBeenCalledTimes(1); 226 | expect(execStub).toHaveBeenCalledWith('xCommand/Dial', { 227 | Number: 'user@example.com', 228 | }); 229 | 230 | expect(execStub).toHaveNthReturnedWith(1, result); 231 | }); 232 | 233 | it('converts Array path to json-rpc method string', () => { 234 | xapi.command(['Presentation', 'Start'], { PresentationSource: 1 }); 235 | 236 | expect(execStub).toHaveBeenCalledWith( 237 | 'xCommand/Presentation/Start', 238 | { 239 | PresentationSource: 1, 240 | }, 241 | ); 242 | }); 243 | 244 | it('accepts whitespace delimited command paths', () => { 245 | xapi.command('Foo Bar\n Baz \t'); 246 | 247 | expect(execStub).toHaveBeenCalledWith( 248 | 'xCommand/Foo/Bar/Baz', 249 | undefined, 250 | ); 251 | }); 252 | 253 | it('rejects newline in regular params', () => { 254 | const result = xapi.command('UserInterface Message Echo', { 255 | Text: 'foo \n bar \n', 256 | }); 257 | 258 | return expect(result).rejects.toThrow( 259 | /may not contain newline/, 260 | ); 261 | }); 262 | 263 | it('supports multi-line commands using the third parameter', () => { 264 | const body = ` 265 | 266 | 1.1 267 | 268 | Lightbulb 269 | Statusbar 270 | 271 | Foo 272 | 273 | Bar 274 | 275 | widget_3 276 | ToggleButton 277 | 278 | 279 | 280 | asdf 281 | 282 | 283 | 284 | 285 | `; 286 | 287 | xapi.command( 288 | 'UserInterface Extensions Set', 289 | { ConfigId: 'example' }, 290 | body, 291 | ); 292 | 293 | expect(execStub).toHaveBeenCalledWith( 294 | 'xCommand/UserInterface/Extensions/Set', 295 | { 296 | ConfigId: 'example', 297 | body, 298 | }, 299 | ); 300 | }); 301 | 302 | it('supports passing body as second argument', () => { 303 | const body = ``; 304 | 305 | xapi.command('Bookings Update', body); 306 | 307 | expect(execStub).toHaveBeenCalledWith( 308 | 'xCommand/Bookings/Update', 309 | { 310 | body, 311 | }, 312 | ); 313 | }); 314 | 315 | it('supports passing empty params and then a body', () => { 316 | const body = ``; 317 | 318 | xapi.command('Bookings Update', undefined, body); 319 | 320 | expect(execStub).toHaveBeenCalledWith( 321 | 'xCommand/Bookings/Update', 322 | { 323 | body, 324 | }, 325 | ); 326 | }); 327 | }); 328 | 329 | describe('.config', () => { 330 | describe('.get()', () => { 331 | it('invokes and returns xapi.execute', () => { 332 | const result = xapi.config.get('Audio DefaultVolume'); 333 | 334 | expect(execStub).toHaveBeenCalledTimes(1); 335 | expect(execStub).toHaveBeenCalledWith('xGet', { 336 | Path: ['Configuration', 'Audio', 'DefaultVolume'], 337 | }); 338 | 339 | expect(execStub).toHaveNthReturnedWith(1, result); 340 | }); 341 | }); 342 | 343 | describe('.set()', () => { 344 | it('invokes and returns xapi.execute', () => { 345 | const result = xapi.config.set('Audio DefaultVolume', 100); 346 | 347 | expect(execStub).toHaveBeenCalledTimes(1); 348 | expect(execStub).toHaveBeenCalledWith('xSet', { 349 | Path: ['Configuration', 'Audio', 'DefaultVolume'], 350 | Value: 100, 351 | }); 352 | 353 | expect(execStub).toHaveNthReturnedWith(1, result); 354 | }); 355 | }); 356 | }); 357 | 358 | describe('.doc', () => { 359 | it('invokes and returns .execute()', () => { 360 | const result = xapi.doc('Configuration'); 361 | 362 | expect(execStub).toHaveBeenCalledTimes(1); 363 | expect(execStub).toHaveBeenCalledWith('xDoc', { 364 | Path: ['Configuration'], 365 | Type: 'Schema', 366 | }); 367 | 368 | expect(execStub).toHaveNthReturnedWith(1, result); 369 | }); 370 | }); 371 | 372 | describe('.event', () => { 373 | describe('.on()', () => { 374 | it('registers feedback with feedback handler', async () => { 375 | const handler = jest.fn(); 376 | await xapi.event.on('Standby', handler).registration; 377 | 378 | xapi.feedback.dispatch({ Event: { Standby: 'Active' } }); 379 | 380 | expect(handler).toHaveBeenCalledTimes(1); 381 | expect(handler).toHaveBeenCalledWith('Active', expect.anything()); 382 | }); 383 | }); 384 | 385 | describe('.off()', () => { 386 | it('can de-register feedback', async () => { 387 | const handler = jest.fn(); 388 | const off = xapi.event.on('Standby', handler); 389 | await off.registration; 390 | 391 | xapi.feedback.dispatch({ Event: { Standby: 'Active' } }); 392 | off(); 393 | xapi.feedback.dispatch({ Event: { Standby: 'Deactive' } }); 394 | 395 | expect(handler).toHaveBeenCalledTimes(1); 396 | expect(handler).toHaveBeenCalledWith('Active', expect.anything()); 397 | }); 398 | }); 399 | }); 400 | 401 | describe('.status', () => { 402 | describe('.get()', () => { 403 | it('invokes and returns xapi.execute', () => { 404 | const result = xapi.status.get('Audio Volume'); 405 | 406 | expect(execStub).toHaveBeenCalledTimes(1); 407 | expect(execStub).toHaveBeenCalledWith('xGet', { 408 | Path: ['Status', 'Audio', 'Volume'], 409 | }); 410 | 411 | expect(execStub).toHaveNthReturnedWith(1, result); 412 | }); 413 | }); 414 | }); 415 | }); 416 | }); 417 | -------------------------------------------------------------------------------- /test/xapi/proxy.spec.ts: -------------------------------------------------------------------------------- 1 | import Backend from '../../src/backend'; 2 | import XAPI from '../../src/xapi'; 3 | 4 | describe('Proxy', () => { 5 | let execute: jest.SpyInstance; 6 | let xapi: XAPI; 7 | 8 | beforeEach(() => { 9 | xapi = new XAPI(new Backend(), { seal: false }); 10 | execute = jest.spyOn(xapi, 'execute').mockImplementation(() => Promise.resolve({})); 11 | }); 12 | 13 | describe('command', () => { 14 | it('can proxy xapi.command', () => { 15 | xapi.Command.Audio.Volume.Mute(); 16 | 17 | expect(execute).toHaveBeenCalledTimes(1); 18 | expect(execute).toHaveBeenCalledWith('xCommand/Audio/Volume/Mute', undefined); 19 | }); 20 | 21 | it('can proxy xapi.command with args', () => { 22 | xapi.Command.Dial({ Number: 'user@example.com' }); 23 | 24 | expect(execute).toHaveBeenCalledTimes(1); 25 | expect(execute).toHaveBeenCalledWith('xCommand/Dial', { 26 | Number: 'user@example.com', 27 | }); 28 | }); 29 | }); 30 | 31 | describe('config', () => { 32 | it('can proxy xapi.config..get()', () => { 33 | xapi.Config.Audio.DefaultVolume.get(); 34 | 35 | expect(execute).toHaveBeenCalledTimes(1); 36 | expect(execute).toHaveBeenCalledWith('xGet', { 37 | Path: ['Configuration', 'Audio', 'DefaultVolume'], 38 | }); 39 | }); 40 | 41 | it('can proxy xapi.config..set()', () => { 42 | xapi.Config.Audio.DefaultVolume.set(50); 43 | 44 | expect(execute).toHaveBeenCalledTimes(1); 45 | expect(execute).toHaveBeenCalledWith('xSet', { 46 | Path: ['Configuration', 'Audio', 'DefaultVolume'], 47 | Value: 50, 48 | }); 49 | }); 50 | 51 | it('can proxy xapi.config..set() with array index', () => { 52 | xapi.Config.FacilityService.Service[3].Number.set('user@example.com'); 53 | 54 | expect(execute).toHaveBeenCalledTimes(1); 55 | expect(execute).toHaveBeenCalledWith('xSet', { 56 | Path: ['Configuration', 'FacilityService', 'Service', 3, 'Number'], 57 | Value: 'user@example.com', 58 | }); 59 | }); 60 | 61 | it('can proxy feedback registration xapi.config..on()', () => { 62 | const spy = jest.fn(); 63 | xapi.Config.Audio.DefaultVolume.on(spy); 64 | 65 | expect(execute).toHaveBeenCalledTimes(1); 66 | expect(execute).toHaveBeenCalledWith('xFeedback/Subscribe', { 67 | Query: ['Configuration', 'Audio', 'DefaultVolume'], 68 | }); 69 | }); 70 | }); 71 | 72 | describe('event', () => { 73 | it('can proxy feedback registration xapi.event..on()', () => { 74 | const spy = jest.fn(); 75 | xapi.Event.Foo.Bar.on(spy); 76 | 77 | expect(execute).toHaveBeenCalledTimes(1); 78 | expect(execute).toHaveBeenCalledWith('xFeedback/Subscribe', { 79 | Query: ['Event', 'Foo', 'Bar'], 80 | }); 81 | }); 82 | }); 83 | 84 | describe('status', () => { 85 | it('can proxy xapi.status..get()', () => { 86 | xapi.Status.Audio.Volume.get(); 87 | 88 | expect(execute).toHaveBeenCalledTimes(1); 89 | expect(execute).toHaveBeenCalledWith('xGet', { 90 | Path: ['Status', 'Audio', 'Volume'], 91 | }); 92 | }); 93 | 94 | it('can proxy xapi.status..get() with array index', () => { 95 | xapi.Status.Video.Input.Connector[2].Type.get(); 96 | 97 | expect(execute).toHaveBeenCalledTimes(1); 98 | expect(execute).toHaveBeenCalledWith('xGet', { 99 | Path: ['Status', 'Video', 'Input', 'Connector', 2, 'Type'], 100 | }); 101 | }); 102 | 103 | it('can proxy feedback registration xapi.status..on()', () => { 104 | const spy = jest.fn(); 105 | xapi.Status.Audio.Volume.on(spy); 106 | 107 | expect(execute).toHaveBeenCalledTimes(1); 108 | expect(execute).toHaveBeenCalledWith('xFeedback/Subscribe', { 109 | Query: ['Status', 'Audio', 'Volume'], 110 | }); 111 | }); 112 | }); 113 | }); 114 | -------------------------------------------------------------------------------- /test/xapi/rpc.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | collapse, 3 | createCommandResponse, 4 | createGetResponse, 5 | createRequest, 6 | createSetResponse, 7 | createDocumentResponse, 8 | } from '../../src/xapi/rpc'; 9 | 10 | import { ParameterError, XAPIError } from '../../src/xapi/exc'; 11 | 12 | describe('xapi/rpc', () => { 13 | describe('createRequest', () => { 14 | it('escapes newlines in string parameters', () => { 15 | const fn = () => 16 | createRequest('1', 'xCommand/UserInterface/Message/Echo', { 17 | Text: 'foo \n bar \n', 18 | }); 19 | 20 | expect(fn).toThrow('may not contain newline'); 21 | }); 22 | }); 23 | 24 | describe('createCommandResponse', () => { 25 | it('handles valid responses', () => { 26 | const data = JSON.parse(` 27 | { 28 | "CommandResponse":{ 29 | "OptionKeyListResult":{ 30 | "status":"OK", 31 | "OptionKey":[{ 32 | "id":"1", 33 | "Active":{"Value":"False"}, 34 | "Installed":{"Value":"False"}, 35 | "Type":{"Value":"MultiSite"} 36 | }] 37 | } 38 | } 39 | } 40 | `); 41 | 42 | expect(createCommandResponse(data)).toEqual({ 43 | status: 'OK', 44 | OptionKey: [ 45 | { 46 | id: '1', 47 | Active: 'False', 48 | Installed: 'False', 49 | Type: 'MultiSite', 50 | }, 51 | ], 52 | }); 53 | }); 54 | 55 | it('handles parameter error', () => { 56 | const data = JSON.parse(` 57 | { 58 | "CommandResponse":{ 59 | "OptionKeyRemoveResult":{ 60 | "status":"ParameterError", 61 | "Type":{ 62 | "Value":"Invalid value" 63 | } 64 | } 65 | } 66 | } 67 | `); 68 | 69 | expect(() => createCommandResponse(data)).toThrow(ParameterError); 70 | }); 71 | 72 | it('handles Reason error', () => { 73 | const data = JSON.parse(` 74 | { 75 | "CommandResponse":{ 76 | "Result":{ 77 | "status":"Error", 78 | "Reason":{ 79 | "Value":"Unknown command" 80 | }, 81 | "XPath":{ 82 | "Value":"/foo/bar" 83 | } 84 | } 85 | } 86 | } 87 | `); 88 | 89 | expect(() => createCommandResponse(data)).toThrow('Unknown command'); 90 | }); 91 | 92 | it('handles Error error', () => { 93 | const data = JSON.parse(` 94 | { 95 | "CommandResponse":{ 96 | "OptionKeyRemoveResult":{ 97 | "status":"Error", 98 | "Error":{ 99 | "Value":"No Encryption optionkey is installed" 100 | } 101 | } 102 | } 103 | } 104 | `); 105 | 106 | expect(() => createCommandResponse(data)).toThrow( 107 | 'No Encryption optionkey is installed', 108 | ); 109 | }); 110 | 111 | it('handles unknown error', () => { 112 | const data = JSON.parse(` 113 | { 114 | "CommandResponse":{ 115 | "FooBarResult":{ 116 | "status":"Error", 117 | "ThisIsNotKnown":{ 118 | "Value": "Some text" 119 | } 120 | } 121 | } 122 | } 123 | `); 124 | 125 | expect(() => createCommandResponse(data)).toThrow('FooBarResult'); 126 | }); 127 | 128 | it('propagates Error body', () => { 129 | const data = JSON.parse(` 130 | { 131 | "CommandResponse":{ 132 | "OptionKeyRemoveResult":{ 133 | "status":"Error", 134 | "Error":{ 135 | "Value":"No Encryption optionkey is installed" 136 | }, 137 | "Messages":[ 138 | { 139 | "Message":{"Value":"Message 1"} 140 | }, 141 | { 142 | "Message":{"Value":"Message 2"} 143 | } 144 | ] 145 | } 146 | } 147 | } 148 | `); 149 | 150 | try { 151 | createCommandResponse(data); 152 | } catch (error) { 153 | expect(error).toBeInstanceOf(XAPIError); 154 | expect(error instanceof XAPIError && error.data).toEqual( 155 | collapse(data.CommandResponse.OptionKeyRemoveResult), 156 | ); 157 | return; 158 | } 159 | 160 | throw new Error('Should not get here'); 161 | }); 162 | 163 | describe('handles invalid structure', () => { 164 | it('no "CommandResponse"', () => { 165 | const data = JSON.parse(` 166 | { 167 | "Foo": "Bar" 168 | } 169 | `); 170 | 171 | expect(() => createCommandResponse(data)).toThrow( 172 | /Missing "CommandResponse" attribute/, 173 | ); 174 | }); 175 | 176 | it('wrong number of keys', () => { 177 | const data = JSON.parse(` 178 | { 179 | "CommandResponse": {}, 180 | "Foo": "Bar", 181 | "Baz": "Quux" 182 | } 183 | `); 184 | 185 | expect(() => createCommandResponse(data)).toThrow( 186 | /Wrong number of keys/, 187 | ); 188 | }); 189 | }); 190 | }); 191 | 192 | describe('createDocumentResponse', () => { 193 | it('maps status schema to StatusSchema', () => { 194 | const request = { 195 | method: 'xDoc', 196 | params: { 197 | Path: ['Stat', 'Audio', 'Volume'], 198 | Type: 'Schema', 199 | }, 200 | }; 201 | 202 | const data = JSON.parse(` 203 | { 204 | "StatusSchema": { 205 | "Audio": { 206 | "Volume": { 207 | "ValueSpace": { 208 | "type": "Integer" 209 | }, 210 | "access": "public-api", 211 | "description": "Shows the volume level (dB) of the loudspeaker output.", 212 | "read": "Admin;Integrator;User" 213 | }, 214 | "VolumeMute": { 215 | "ValueSpace": { 216 | "Value": [ 217 | "On", 218 | "Off" 219 | ], 220 | "type": "Literal" 221 | }, 222 | "access": "public-api", 223 | "description": "Shows whether the endpoint volume is set to mute.", 224 | "read": "Admin;User" 225 | } 226 | } 227 | } 228 | }`); 229 | 230 | expect(createDocumentResponse(request, data)).toEqual({ 231 | ValueSpace: { type: "Integer" }, 232 | access: 'public-api', 233 | description: 'Shows the volume level (dB) of the loudspeaker output.', 234 | read: 'Admin;Integrator;User', 235 | }); 236 | }); 237 | }); 238 | 239 | describe('createGetResponse', () => { 240 | it('extracts value from nested object', () => { 241 | const request = { 242 | jsonrpc: '2.0', 243 | method: 'xGet', 244 | id: '2', 245 | params: { Path: ['Status', 'Audio', 'Volume'] }, 246 | }; 247 | 248 | const response = { 249 | ResultId: '2', 250 | Status: { 251 | Audio: { 252 | Volume: { Value: '30' }, 253 | }, 254 | }, 255 | }; 256 | 257 | expect(createGetResponse(request, response)).toEqual('30'); 258 | }); 259 | 260 | it('returns undefined for empty responses', () => { 261 | const request = { 262 | jsonrpc: '2.0', 263 | method: 'xGet', 264 | id: '1', 265 | params: { Path: ['Status', 'Call'] }, 266 | }; 267 | 268 | const response = {}; 269 | 270 | const result = createGetResponse(request, response); 271 | expect(result).toBeUndefined(); 272 | }); 273 | 274 | it('extracts error responses', () => { 275 | const request = { 276 | jsonrpc: '2.0', 277 | method: 'xGet', 278 | id: '2', 279 | params: { Path: ['Status', 'Audio', 'Volume'] }, 280 | }; 281 | 282 | const body = ` 283 | "Status":{ 284 | "status":"Error", 285 | "Reason":{ 286 | "Value":"No match on address expression" 287 | }, 288 | "XPath":{ 289 | "Value":"Status/Audio/Volumee" 290 | } 291 | } 292 | `; 293 | 294 | // Old JSON serialization format 295 | const responseWithCommandResponse = JSON.parse(` 296 | { 297 | "CommandResponse":{ ${body} }, 298 | "ResultId": "2" 299 | } 300 | `); 301 | 302 | // New JSON serialization format 303 | const responseWithoutCommandResponse = JSON.parse(` 304 | { 305 | ${body}, 306 | "ResultId": "2" 307 | } 308 | `); 309 | 310 | expect(() => createGetResponse(request, responseWithCommandResponse)) 311 | .toThrow('No match on address expression'); 312 | expect(() => createGetResponse(request, responseWithoutCommandResponse)) 313 | .toThrow('No match on address expression'); 314 | }); 315 | }); 316 | 317 | describe('createSetResponse', () => { 318 | it('handles Reason error', () => { 319 | const request = { 320 | jsonrpc: '2.0', 321 | method: 'xSet', 322 | id: '1', 323 | params: { 324 | Path: [ 325 | 'Configuration', 326 | 'Video', 327 | 'Output', 328 | 'Connector', 329 | 99, 330 | 'MonitorRole', 331 | ], 332 | Value: 'foo', 333 | }, 334 | }; 335 | const response = JSON.parse(` 336 | { 337 | "CommandResponse":{ 338 | "Configuration":{ 339 | "status":"Error", 340 | "Reason":{ 341 | "Value":"No match on address expression." 342 | }, 343 | "XPath":{ 344 | "Value":"Configuration/Video/Output/Connector[99]/MonitorRole" 345 | } 346 | } 347 | }, 348 | "ResultId":"1" 349 | } 350 | `); 351 | 352 | expect(() => createSetResponse(request, response)).toThrow( 353 | 'No match on address expression', 354 | ); 355 | }); 356 | 357 | it('handles Error error', () => { 358 | const request = { 359 | jsonrpc: '2.0', 360 | method: 'xSet', 361 | id: '1', 362 | params: { 363 | Path: [ 364 | 'Configuration', 365 | 'Video', 366 | 'Output', 367 | 'Connector', 368 | 99, 369 | 'MonitorRole', 370 | ], 371 | Value: 'foo', 372 | }, 373 | }; 374 | 375 | const body = ` 376 | "Configuration":{ 377 | "status":"Error", 378 | "Error":{ 379 | "Value":"No match on address expression." 380 | }, 381 | "XPath":{ 382 | "Value":"Configuration/Video/Output/Connector[99]/MonitorRole" 383 | } 384 | } 385 | `; 386 | 387 | // Old JSON serialization format 388 | const responseWithCommandResponse = JSON.parse(` 389 | { 390 | "CommandResponse":{ ${body} }, 391 | "ResultId":"1" 392 | } 393 | `); 394 | 395 | // New JSON serialization format 396 | const responseWithoutCommandResponse = JSON.parse(` 397 | { 398 | ${body}, 399 | "ResultId":"1" 400 | } 401 | `); 402 | 403 | expect(() => createSetResponse(request, responseWithCommandResponse)) 404 | .toThrow('No match on address expression'); 405 | expect(() => createSetResponse(request, responseWithoutCommandResponse)) 406 | .toThrow('No match on address expression'); 407 | }); 408 | 409 | it('handles unknown error', () => { 410 | const request = { 411 | jsonrpc: '2.0', 412 | method: 'xSet', 413 | id: '1', 414 | params: { 415 | Path: [ 416 | 'Configuration', 417 | 'Video', 418 | 'Output', 419 | 'Connector', 420 | 99, 421 | 'MonitorRole', 422 | ], 423 | Value: 'foo', 424 | }, 425 | }; 426 | const response = JSON.parse(` 427 | { 428 | "CommandResponse":{ 429 | "Configuration":{ 430 | "status":"Error", 431 | "ThisIsNotKnown":{ 432 | "Value":"No match on address expression." 433 | }, 434 | "XPath":{ 435 | "Value":"Configuration/Video/Output/Connector[99]/MonitorRole" 436 | } 437 | } 438 | }, 439 | "ResultId":"1" 440 | } 441 | `); 442 | 443 | expect(() => createSetResponse(request, response)).toThrow( 444 | 'Configuration', 445 | ); 446 | }); 447 | }); 448 | }); 449 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "esModuleInterop": true, 5 | "lib": ["es2015"], 6 | "module": "commonjs", 7 | "strict": true, 8 | "noImplicitThis": false, 9 | "removeComments": true, 10 | "typeRoots": ["typings", "node_modules/@types"] 11 | }, 12 | "include": [ 13 | "src/**/*", 14 | "test/**/*" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended" 5 | ], 6 | "jsRules": {}, 7 | "rules": { 8 | "interface-name": [true, "never-prefix"], 9 | "quotemark": [true, "single"], 10 | "max-classes-per-file": [false], 11 | "no-consecutive-blank-lines": [true, 2] 12 | }, 13 | "rulesDirectory": [] 14 | } 15 | -------------------------------------------------------------------------------- /typings/duplex-passthrough/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'duplex-passthrough' { 2 | class DuplexPassThrough { 3 | public on: any; 4 | public addListener: any; 5 | } 6 | export = DuplexPassThrough; 7 | } 8 | -------------------------------------------------------------------------------- /typings/jsonparse/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'jsonparse' { 2 | class Parser { 3 | public onError: any; 4 | public onValue: any; 5 | public stack: { length: number }; 6 | public write(line: string): void; 7 | } 8 | export default Parser; 9 | } 10 | --------------------------------------------------------------------------------