├── .github └── workflows │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── babel.config.js ├── dist ├── index.js └── lib │ ├── api.js │ ├── client.js │ ├── connection.js │ ├── discovery.js │ └── request.js ├── index.js ├── package.json ├── releasing.md ├── src ├── index.js └── lib │ ├── api.js │ ├── client.js │ ├── connection.js │ ├── discovery.js │ └── request.js └── test └── index.js /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: CI test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | node-version: [10.x, 12.x, 14.x, 16.x] 13 | 14 | steps: 15 | - name: Setup Linux dependencies 16 | run: | 17 | sudo apt-get update -y -qq 18 | sudo apt-get install -y -qq build-essential libavahi-compat-libdnssd-dev 19 | - uses: actions/checkout@v2 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v1 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | - run: npm install 25 | - run: npm test 26 | env: 27 | CI: true -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | *.db 4 | *.log 5 | logs/* 6 | bower_components 7 | package-lock.json 8 | yarn.lock -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Signal K JS Client 2 | 3 | [![Build Status](https://travis-ci.org/SignalK/signalk-js-client.svg)](https://travis-ci.org/SignalK/signalk-js-client) 4 | 5 | > A Javascript SDK for Signal K clients. Provides various abstract interfaces for discovering the Signal K server and communication via WebSocket & REST. Aims to implement all major APIs in the most recent Signal K version(s). 6 | 7 | ### INSTALLATION 8 | 9 | ```bash 10 | [sudo] npm install --save @signalk/client 11 | ``` 12 | 13 | ### BASIC USAGE 14 | 15 | ```javascript 16 | import Client, { Discovery } from '@signalk/client' 17 | import Bonjour from 'bonjour' 18 | 19 | let client = null 20 | 21 | // Default options for instantiating a client: 22 | const defaults = { 23 | hostname: 'localhost', 24 | port: 3000, 25 | useTLS: true, 26 | useAuthentication: false, 27 | notifications: true, 28 | autoConnect: false, 29 | reconnect: true, 30 | maxRetries: Infinity, 31 | maxTimeBetweenRetries: 2500, 32 | username: null, 33 | password: null, 34 | deltaStreamBehaviour: 'none', 35 | wsKeepaliveInterval: 0 36 | } 37 | 38 | // Instantiate client 39 | client = new Client({ 40 | hostname: 'demo.signalk.org', 41 | port: 80, 42 | useTLS: false, 43 | reconnect: true, 44 | autoConnect: false, 45 | }) 46 | 47 | // Instantiate client with authentication 48 | client = new Client({ 49 | hostname: 'demo.signalk.org', 50 | port: 80, 51 | useTLS: false, 52 | rejectUnauthorized: false, // Optional, set to false only if the server has a self-signed certificate 53 | useAuthentication: true, 54 | reconnect: true, 55 | autoConnect: false, 56 | username: 'demo@signalk.org', 57 | password: 'signalk', 58 | }) 59 | 60 | // Discover client using mDNS 61 | // Params: bonjour lib, search time 62 | const bonjour = Bonjour() 63 | const discovery = new Discovery(bonjour, 60000) 64 | 65 | // Timeout fires when search time is up and no servers were found 66 | discovery.on('timeout', () => console.log('No SK servers found')) 67 | 68 | // Found fires when a SK server was found 69 | discovery.on('found', (server) => { 70 | if (server.isMain() && server.isMaster()) { 71 | client = server.createClient({ 72 | useTLS: false, 73 | useAuthentication: true, 74 | reconnect: true, 75 | autoConnect: true, 76 | notifications: false, 77 | username: 'sdk@decipher.industries', 78 | password: 'signalk', 79 | }) 80 | } 81 | }) 82 | 83 | // Delta Stream over WS usage 84 | // 1. Stream behaviour selection 85 | client = new Client({ 86 | hostname: 'demo.signalk.org', 87 | port: 80, 88 | useTLS: false, 89 | reconnect: true, 90 | autoConnect: false, 91 | notifications: false, 92 | // Either "self", "all", "none", or null (see below) 93 | // - null: no behaviour is set for the delta stream, default behaviour is used. Use this option when connecting to older devices that don't support the subscription modifiers per query request. See https://signalk.org/specification/1.4.0/doc/subscription_protocol.html. 94 | // - "self" provides a stream of all local data of own vessel 95 | // - "all" provides a stream of all data for all vessels 96 | // - "none" provides no data over the stream 97 | deltaStreamBehaviour: 'self', 98 | // Either "all" or null. 99 | // - null: provides no Meta data over the stream 100 | // - "all" include Meta data of all data for all vessels 101 | sendMeta: 'all', 102 | // Sends an empty message to the websocket every 10 seconds when the client does not receive any more update from the server to detect if the socket is dead. 103 | wsKeepaliveInterval: 10 104 | 105 | }) 106 | 107 | // 2. Subscribe to specific Signal K paths 108 | client = new Client({ 109 | hostname: 'demo.signalk.org', 110 | port: 80, 111 | useTLS: false, 112 | reconnect: true, 113 | autoConnect: false, 114 | notifications: false, 115 | subscriptions: [ 116 | { 117 | context: 'vessels.*', 118 | subscribe: [ 119 | { 120 | path: 'navigation.position', 121 | policy: 'instant', 122 | }, 123 | ], 124 | }, 125 | ], 126 | }) 127 | 128 | // 3. Listen to the "delta" event to get the stream data 129 | client.on('delta', (delta) => { 130 | // do something with delta 131 | }) 132 | 133 | // 4. Modify your subscription parameters. Can be a single object or an array. 134 | client.subscribe([ 135 | { 136 | context: 'vessels.*', 137 | subscribe: [ 138 | { 139 | path: 'navigation.position', 140 | policy: 'instant', 141 | }, 142 | ], 143 | }, 144 | ]) 145 | 146 | // 5. Unsubscribe from all data paths. 147 | client.unsubscribe() 148 | 149 | // REST API usage 150 | // 1. Fetch an entire group 151 | client 152 | .API() // create REST API client 153 | .then((api) => api.navigation()) 154 | .then((navigationGroupResult) => { 155 | // Do something with navigation group data 156 | }) 157 | 158 | // 2. Fetch a specific path 159 | client 160 | .API() // create REST API client 161 | .then((api) => api.get('/vessels/self/navigation/position')) // Path can be specified using dotnotation and slashes 162 | .then((positionResult) => { 163 | // Do something with position data 164 | }) 165 | 166 | // 3. Fetch meta for a specific path 167 | client 168 | .API() // create REST API client 169 | .then((api) => api.getMeta('vessels.self.navigation.position')) 170 | .then((positionMetaResult) => { 171 | // Do something with position meta data 172 | }) 173 | 174 | // 4. Fetch the entire tree for the local vessel 175 | client 176 | .API() // create REST API client 177 | .then((api) => api.self()) 178 | .then((selfResult) => { 179 | // Do something with boat data 180 | }) 181 | 182 | // ... check out the tests for more REST API examples 183 | ``` 184 | 185 | ### Other Signal K Clients: 186 | 187 | **Angular:** 188 | Signal K client for the Angular framework 189 | [signalk-client-angular](https://github.com/panaaj/signalk-client-angular) 190 | 191 | ### NOTES 192 | 193 | - Node SK server responds with "Request updated" for access request responses. This is incorrect per spec 194 | - Node SK server paths for access requests repsponses are not correct to spec (i.e. no /signalk/v1 prefix) 195 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | const presets = [ 2 | [ 3 | "@babel/env", 4 | { 5 | targets: { 6 | edge: "17", 7 | firefox: "60", 8 | chrome: "67", 9 | safari: "11.1", 10 | }, 11 | corejs: "3", 12 | useBuiltIns: "usage", 13 | }, 14 | ], 15 | ]; 16 | 17 | module.exports = { presets }; -------------------------------------------------------------------------------- /dist/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.default = exports.Discovery = exports.Client = void 0; 7 | 8 | var _client = _interopRequireDefault(require("./lib/client")); 9 | 10 | var _discovery = _interopRequireDefault(require("./lib/discovery")); 11 | 12 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 13 | 14 | /** 15 | * @author Fabian Tollenaar 16 | * @copyright 2018-2019, Fabian Tollenaar. All rights reserved. 17 | * @license Apache-2.0 18 | * @module @signalk/signalk-js-sdk 19 | */ 20 | const Client = _client.default; 21 | exports.Client = Client; 22 | const Discovery = _discovery.default; 23 | exports.Discovery = Discovery; 24 | var _default = _client.default; 25 | exports.default = _default; -------------------------------------------------------------------------------- /dist/lib/api.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | require("core-js/modules/es.string.includes"); 4 | 5 | require("core-js/modules/es.string.replace"); 6 | 7 | require("core-js/modules/es.string.trim"); 8 | 9 | Object.defineProperty(exports, "__esModule", { 10 | value: true 11 | }); 12 | exports.default = void 0; 13 | 14 | /** 15 | * @description An API wraps the REST API for a Signal K server 16 | * @author Fabian Tollenaar 17 | * @copyright 2018-2019, Fabian Tollenaar. All rights reserved. 18 | * @license Apache-2.0 19 | * @module @signalk/signalk-js-sdk 20 | */ 21 | class API { 22 | constructor(connection) { 23 | this.connection = connection; 24 | this.selfMRN = this.connection.self; 25 | } 26 | 27 | get(path) { 28 | if (path.includes('.')) { 29 | path = path.replace(/\./g, '/'); 30 | } 31 | 32 | if (typeof path !== 'string' || path.trim() === '') { 33 | path = '/'; 34 | } 35 | 36 | if (path.charAt(0) !== '/') { 37 | path = "/".concat(path); 38 | } 39 | 40 | return this.connection.fetch(path); 41 | } 42 | 43 | put(path, body) { 44 | if (path.includes('.')) { 45 | path = path.replace(/\./g, '/'); 46 | } 47 | 48 | if (typeof path !== 'string' || path.trim() === '') { 49 | path = '/'; 50 | } 51 | 52 | if (path.charAt(0) !== '/') { 53 | path = "/".concat(path); 54 | } 55 | 56 | return this.connection.fetch(path, { 57 | method: 'PUT', 58 | mode: 'cors', 59 | body: body && typeof body === 'object' ? JSON.stringify(body) : body 60 | }); 61 | } 62 | /** 63 | * Shortcut methods. 64 | * @TODO: investigate if we can generate these using a Proxy and signalk-schema, using this.options.version. 65 | */ 66 | 67 | 68 | getMeta(path) { 69 | return this.get(path).then(result => { 70 | if (!result || typeof result !== 'object') { 71 | return null; 72 | } 73 | 74 | if (!result.hasOwnProperty('meta')) { 75 | return null; 76 | } 77 | 78 | return result.meta; 79 | }); 80 | } 81 | 82 | sources() { 83 | return this.get('/sources'); 84 | } 85 | 86 | resources() { 87 | return this.get('/resources'); 88 | } 89 | 90 | mrn() { 91 | return this.get('/self'); 92 | } 93 | 94 | vessels() { 95 | return this.get('/vessels'); 96 | } 97 | 98 | aircraft() { 99 | return this.get('/aircraft'); 100 | } 101 | 102 | aton() { 103 | return this.get('/aton'); 104 | } 105 | 106 | sar() { 107 | return this.get('/sar'); 108 | } 109 | 110 | version() { 111 | return this.get('/version'); 112 | } 113 | 114 | self(path) { 115 | if (typeof path !== 'string' || path.charAt(0) !== '/') { 116 | path = ''; 117 | } 118 | 119 | return this.connection.fetch("/vessels/self".concat(path)); 120 | } 121 | 122 | vessel(mrn, path) { 123 | if (typeof path !== 'string' || path.charAt(0) !== '/') { 124 | path = ''; 125 | } 126 | 127 | return this.connection.fetch("/vessels/".concat(mrn).concat(path)); 128 | } 129 | 130 | name() { 131 | return this.self('/name'); 132 | } 133 | 134 | getGroup(group, path) { 135 | let vessel = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 'self'; 136 | 137 | if (typeof path !== 'string' || path.charAt(0) !== '/') { 138 | path = ''; 139 | } 140 | 141 | if (vessel === 'self') { 142 | return this.self("/".concat(group).concat(path)); 143 | } 144 | 145 | return this.vessel(vessel, "/".concat(group).concat(path)); 146 | } 147 | 148 | communication() { 149 | let path = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ''; 150 | let vessel = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'self'; 151 | return this.getGroup('communication', path, vessel); 152 | } 153 | 154 | design() { 155 | let path = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ''; 156 | let vessel = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'self'; 157 | return this.getGroup('design', path, vessel); 158 | } 159 | 160 | electrical() { 161 | let path = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ''; 162 | let vessel = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'self'; 163 | return this.getGroup('electrical', path, vessel); 164 | } 165 | 166 | environment() { 167 | let path = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ''; 168 | let vessel = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'self'; 169 | return this.getGroup('environment', path, vessel); 170 | } 171 | 172 | navigation() { 173 | let path = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ''; 174 | let vessel = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'self'; 175 | return this.getGroup('navigation', path, vessel); 176 | } 177 | 178 | notifications() { 179 | let path = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ''; 180 | let vessel = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'self'; 181 | return this.getGroup('notifications', path, vessel); 182 | } 183 | 184 | performance() { 185 | let path = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ''; 186 | let vessel = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'self'; 187 | return this.getGroup('performance', path, vessel); 188 | } 189 | 190 | propulsion() { 191 | let path = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ''; 192 | let vessel = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'self'; 193 | return this.getGroup('propulsion', path, vessel); 194 | } 195 | 196 | sails() { 197 | let path = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ''; 198 | let vessel = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'self'; 199 | return this.getGroup('sails', path, vessel); 200 | } 201 | 202 | sensors() { 203 | let path = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ''; 204 | let vessel = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'self'; 205 | return this.getGroup('sensors', path, vessel); 206 | } 207 | 208 | steering() { 209 | let path = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ''; 210 | let vessel = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'self'; 211 | return this.getGroup('steering', path, vessel); 212 | } 213 | 214 | tanks() { 215 | let path = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ''; 216 | let vessel = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'self'; 217 | return this.getGroup('tanks', path, vessel); 218 | } 219 | 220 | } 221 | 222 | exports.default = API; -------------------------------------------------------------------------------- /dist/lib/client.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | require("core-js/modules/es.object.assign"); 4 | 5 | require("core-js/modules/es.promise"); 6 | 7 | require("core-js/modules/es.string.includes"); 8 | 9 | require("core-js/modules/es.string.replace"); 10 | 11 | require("core-js/modules/web.dom-collections.iterator"); 12 | 13 | Object.defineProperty(exports, "__esModule", { 14 | value: true 15 | }); 16 | exports.default = exports.PERMISSIONS_DENY = exports.PERMISSIONS_READONLY = exports.PERMISSIONS_READWRITE = exports.AUTHENTICATION_REQUEST = void 0; 17 | 18 | var _eventemitter = _interopRequireDefault(require("eventemitter3")); 19 | 20 | var _connection = _interopRequireDefault(require("./connection")); 21 | 22 | var _request = _interopRequireDefault(require("./request")); 23 | 24 | var _api = _interopRequireDefault(require("./api")); 25 | 26 | var _debug = _interopRequireDefault(require("debug")); 27 | 28 | var _uuid = require("uuid"); 29 | 30 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 31 | 32 | function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); keys.push.apply(keys, symbols); } return keys; } 33 | 34 | function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys(Object(source), true).forEach(function (key) { _defineProperty(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; } 35 | 36 | function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } 37 | 38 | const debug = (0, _debug.default)('signalk-js-sdk/Client'); // Constants 39 | 40 | const AUTHENTICATION_REQUEST = '__AUTHENTICATION_REQUEST__'; // Permissions for access requests 41 | 42 | exports.AUTHENTICATION_REQUEST = AUTHENTICATION_REQUEST; 43 | const PERMISSIONS_READWRITE = 'readwrite'; 44 | exports.PERMISSIONS_READWRITE = PERMISSIONS_READWRITE; 45 | const PERMISSIONS_READONLY = 'readonly'; 46 | exports.PERMISSIONS_READONLY = PERMISSIONS_READONLY; 47 | const PERMISSIONS_DENY = 'denied'; 48 | exports.PERMISSIONS_DENY = PERMISSIONS_DENY; 49 | 50 | class Client extends _eventemitter.default { 51 | constructor() { 52 | let options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; 53 | super(); 54 | this.options = _objectSpread({ 55 | hostname: 'localhost', 56 | port: 3000, 57 | useTLS: true, 58 | useAuthentication: false, 59 | notifications: true, 60 | version: 'v1', 61 | autoConnect: false, 62 | reconnect: true, 63 | maxRetries: Infinity, 64 | maxTimeBetweenRetries: 2500, 65 | mdns: null, 66 | username: null, 67 | password: null, 68 | deltaStreamBehaviour: 'none', 69 | subscriptions: [] 70 | }, options); 71 | this.api = null; 72 | this.connection = null; 73 | this.services = []; 74 | this.notifications = {}; 75 | this.requests = {}; 76 | this.fetchReady = null; 77 | 78 | if (Array.isArray(this.options.subscriptions)) { 79 | this.subscribeCommands = this.options.subscriptions.filter(command => isValidSubscribeCommand(command)); 80 | } 81 | 82 | if (this.options.notifications === true) { 83 | this.subscribeCommands.push({ 84 | context: 'vessels.self', 85 | subscribe: [{ 86 | path: 'notifications.*', 87 | policy: 'instant' 88 | }] 89 | }); 90 | } 91 | 92 | if (this.options.autoConnect === true) { 93 | this.connect().catch(err => this.emit('error', err)); 94 | } 95 | } 96 | 97 | get self() { 98 | if (this.connection === null) { 99 | return null; 100 | } 101 | 102 | return this.connection.self; 103 | } 104 | 105 | set(key, value) { 106 | this.options[key] = value; 107 | return this; 108 | } 109 | 110 | get(key) { 111 | return this.options[key] || null; 112 | } 113 | 114 | get retries() { 115 | if (this.connection === null) { 116 | return 0; 117 | } 118 | 119 | return this.connection.retries; 120 | } // @TODO requesting access should be expanded into a small class to manage the entire flow (including polling) 121 | 122 | 123 | requestDeviceAccess(description, _clientId) { 124 | const clientId = typeof _clientId === 'string' ? _clientId : (0, _uuid.v4)(); 125 | return this.connection.fetch('/access/requests', { 126 | method: 'POST', 127 | mode: 'cors', 128 | credentials: 'same-origin', 129 | headers: { 130 | 'Content-Type': 'application/json' 131 | }, 132 | body: JSON.stringify({ 133 | clientId, 134 | description 135 | }) 136 | }).then(response => { 137 | return { 138 | clientId, 139 | response 140 | }; 141 | }); 142 | } 143 | 144 | respondToAccessRequest(uuid, permissions) { 145 | let expiration = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : '1y'; 146 | return this.connection.fetch("/security/access/requests/".concat(uuid, "/").concat(permissions === 'denied' ? 'denied' : 'approved'), { 147 | method: 'PUT', 148 | mode: 'cors', 149 | credentials: 'same-origin', 150 | headers: { 151 | 'Content-Type': 'application/json' 152 | }, 153 | body: JSON.stringify({ 154 | expiration, 155 | permissions 156 | }) 157 | }); 158 | } 159 | 160 | authenticate(username, password) { 161 | const request = this.request(AUTHENTICATION_REQUEST, { 162 | login: { 163 | username, 164 | password 165 | } 166 | }); 167 | request.on('response', response => { 168 | if (response.statusCode === 200 && response.hasOwnProperty('login') && typeof response.login === 'object' && response.login.hasOwnProperty('token')) { 169 | this.connection.setAuthenticated(response.login.token); // We are now authenticated 170 | 171 | return this.emit('authenticated', { 172 | token: response.login.token 173 | }); 174 | } 175 | 176 | this.emit('error', new Error("Error authenticating: status ".concat(response.statusCode))); 177 | }); 178 | request.send(); 179 | } 180 | 181 | request(name) { 182 | let body = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; 183 | 184 | if (!this.requests.hasOwnProperty(name)) { 185 | this.requests[name] = new _request.default(this.connection, name, body); 186 | debug("Registered request \"".concat(name, "\" with ID ").concat(this.requests[name].getRequestId())); 187 | } 188 | 189 | return this.requests[name]; 190 | } 191 | 192 | subscribe() { 193 | let subscriptions = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; 194 | 195 | if (this.connection === null) { 196 | throw new Error('Not connected'); 197 | } 198 | 199 | if (subscriptions && !Array.isArray(subscriptions) && typeof subscriptions === 'object' && subscriptions.hasOwnProperty('subscribe')) { 200 | subscriptions = [subscriptions]; 201 | } 202 | 203 | subscriptions = subscriptions.filter(command => isValidSubscribeCommand(command)); 204 | subscriptions.forEach(command => { 205 | this.subscribeCommands.push(command); 206 | }); 207 | this.connection.subscribe(subscriptions); 208 | } 209 | 210 | unsubscribe() { 211 | if (this.connection === null) { 212 | throw new Error('Not connected'); 213 | } 214 | 215 | const { 216 | notifications 217 | } = this.options; // Reset subscribeCommands 218 | 219 | this.subscribeCommands = notifications === true ? [{ 220 | context: 'vessels.self', 221 | subscribe: [{ 222 | path: 'notifications.*', 223 | policy: 'instant' 224 | }] 225 | }] : []; // Unsubscribe 226 | 227 | this.connection.unsubscribe(); 228 | 229 | if (this.subscribeCommands.length > 0) { 230 | this.connection.subscribe(this.subscribeCommands); 231 | } 232 | } 233 | 234 | connect() { 235 | if (this.connection !== null) { 236 | this.connection.reconnect(true); 237 | return Promise.resolve(this.connection); 238 | } 239 | 240 | return new Promise((resolve, reject) => { 241 | this.connection = new _connection.default(this.options, this.subscribeCommands); 242 | this.connection.on('disconnect', data => this.emit('disconnect', data)); 243 | this.connection.on('message', data => this.processWSMessage(data)); 244 | this.connection.on('connectionInfo', data => this.emit('connectionInfo', data)); 245 | this.connection.on('self', data => this.emit('self', data)); 246 | this.connection.on('hitMaxRetries', () => this.emit('hitMaxRetries')); 247 | this.connection.on('backOffBeforeReconnect', data => this.emit('backOffBeforeReconnect', data)); 248 | this.connection.on('connect', () => { 249 | this.getInitialNotifications(); 250 | this.emit('connect'); 251 | resolve(this.connection); 252 | }); 253 | this.connection.on('fetchReady', () => { 254 | this.fetchReady = true; 255 | }); 256 | this.connection.on('error', err => { 257 | this.emit('error', err); 258 | reject(err); 259 | }); 260 | }); 261 | } 262 | 263 | disconnect() { 264 | let returnPromise = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false; 265 | 266 | if (this.connection !== null) { 267 | this.connection.on('disconnect', () => { 268 | this.cleanupListeners(); 269 | this.connection = null; 270 | }); 271 | this.connection.unsubscribe(); 272 | this.connection.disconnect(); 273 | } else { 274 | this.cleanupListeners(); 275 | } 276 | 277 | if (this.api !== null) { 278 | this.api = null; 279 | } 280 | 281 | if (returnPromise === true) { 282 | return Promise.resolve(this); 283 | } 284 | 285 | return this; 286 | } 287 | 288 | cleanupListeners() { 289 | this.removeAllListeners('self'); 290 | this.removeAllListeners('connectionInfo'); 291 | this.removeAllListeners('message'); 292 | this.removeAllListeners('delta'); 293 | this.removeAllListeners('connect'); 294 | this.removeAllListeners('error'); 295 | this.removeAllListeners('hitMaxRetries'); 296 | this.removeAllListeners('backOffBeforeReconnect'); 297 | this.removeAllListeners('disconnect'); 298 | this.removeAllListeners('unsubscribe'); 299 | this.removeAllListeners('subscribe'); 300 | } 301 | 302 | API() { 303 | // Returning a Promise, so this method can be used as the start of a promise chain. 304 | // I.e., all API methods return Promises, so it makes sense to start the Promise 305 | // chain at the top. 306 | if (this.connection === null) { 307 | return Promise.reject(new Error('There are no available connections. Please connect before you use the REST API.')); 308 | } 309 | 310 | if (this.api !== null) { 311 | return Promise.resolve(this.api); 312 | } 313 | 314 | return new Promise(resolve => { 315 | this.api = new _api.default(this.connection); 316 | 317 | if (this.fetchReady === true || this.options.useAuthentication === false) { 318 | return resolve(this.api); 319 | } 320 | 321 | this.connection.on('fetchReady', () => { 322 | resolve(this.api); 323 | }); 324 | }); 325 | } 326 | 327 | processWSMessage(data) { 328 | this.emit('message', data); // Check if message is SK delta, then emit. 329 | 330 | if (data && typeof data === 'object' && data.hasOwnProperty('updates')) { 331 | this.checkAndEmitNotificationsInDelta(data); 332 | this.emit('delta', data); 333 | } 334 | } 335 | 336 | checkAndEmitNotificationsInDelta(delta) { 337 | if (this.options.notifications === false || !delta || typeof delta !== 'object' || !Array.isArray(delta.updates)) { 338 | return; 339 | } 340 | 341 | const notifications = {}; 342 | delta.updates.forEach(update => { 343 | (update.values || []).forEach(mut => { 344 | if (typeof mut.path === 'string' && mut.path.includes('notifications.')) { 345 | notifications[mut.path.replace('notifications.', '')] = _objectSpread({}, mut.value); 346 | } 347 | }); 348 | }); 349 | Object.keys(notifications).forEach(path => { 350 | if (!this.notifications.hasOwnProperty(path) || this.notifications[path].timestamp !== notifications[path].timestamp) { 351 | this.notifications[path] = _objectSpread({}, notifications[path]); 352 | 353 | const notification = _objectSpread({ 354 | path 355 | }, this.notifications[path]); 356 | 357 | debug("[checkAndEmitNotificationsInDelta] emitting notification: ".concat(JSON.stringify(notification, null, 2))); 358 | this.emit('notification', notification); 359 | } 360 | }); 361 | } 362 | 363 | getInitialNotifications() { 364 | if (this.options.notifications === false) { 365 | return; 366 | } 367 | 368 | if (this.connection === null) { 369 | return; 370 | } 371 | 372 | if (this.api === null) { 373 | this.api = new _api.default(this.connection); 374 | } 375 | 376 | this.api.notifications().then(result => { 377 | this.notifications = _objectSpread(_objectSpread({}, this.notifications), flattenTree(result)); 378 | Object.keys(this.notifications).forEach(path => { 379 | const notification = _objectSpread({ 380 | path 381 | }, this.notifications[path]); 382 | 383 | debug("[getInitialNotifications] emitting notification: ".concat(JSON.stringify(notification, null, 2))); 384 | this.emit('notification', notification); 385 | }); 386 | return this.notifications; 387 | }).catch(err => { 388 | console.error("[getInitialNotifications] error getting notifications: ".concat(err.message)); 389 | }); 390 | } 391 | 392 | } 393 | 394 | exports.default = Client; 395 | 396 | const flattenTree = tree => { 397 | const flattened = {}; 398 | let cursor = tree; 399 | let currentPath = ''; 400 | 401 | const evaluateLeaf = key => { 402 | currentPath += "".concat(currentPath === '' ? '' : '.').concat(key); 403 | cursor = cursor[key]; 404 | 405 | if (!cursor || typeof cursor !== 'object') { 406 | return; 407 | } 408 | 409 | if (cursor && typeof cursor === 'object' && cursor.hasOwnProperty('value')) { 410 | flattened[currentPath] = Object.assign({}, cursor.value); 411 | } else { 412 | Object.keys(cursor).forEach(evaluateLeaf); 413 | } 414 | }; 415 | 416 | Object.keys(cursor).forEach(key => evaluateLeaf(key)); 417 | return flattened; 418 | }; 419 | 420 | const isValidSubscribeCommand = command => { 421 | if (!command || typeof command !== 'object') { 422 | return false; 423 | } 424 | 425 | if (!command.hasOwnProperty('context') || !Array.isArray(command.subscribe)) { 426 | return false; 427 | } 428 | 429 | return true; 430 | }; -------------------------------------------------------------------------------- /dist/lib/connection.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | require("core-js/modules/es.promise"); 4 | 5 | require("core-js/modules/es.string.includes"); 6 | 7 | require("core-js/modules/es.string.replace"); 8 | 9 | require("core-js/modules/es.string.trim"); 10 | 11 | Object.defineProperty(exports, "__esModule", { 12 | value: true 13 | }); 14 | exports.default = exports.SUPPORTED_STREAM_BEHAVIOUR = void 0; 15 | 16 | var _eventemitter = _interopRequireDefault(require("eventemitter3")); 17 | 18 | var _isomorphicWs = _interopRequireDefault(require("isomorphic-ws")); 19 | 20 | var _crossFetch = _interopRequireDefault(require("cross-fetch")); 21 | 22 | var _debug = _interopRequireDefault(require("debug")); 23 | 24 | var _https = _interopRequireDefault(require("https")); 25 | 26 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 27 | 28 | function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); keys.push.apply(keys, symbols); } return keys; } 29 | 30 | function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys(Object(source), true).forEach(function (key) { _defineProperty(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; } 31 | 32 | function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } 33 | 34 | const debug = (0, _debug.default)('signalk-js-sdk/Connection'); 35 | const isNode = typeof process !== 'undefined' && process.versions != null && process.versions.node != null; 36 | const SUPPORTED_STREAM_BEHAVIOUR = { 37 | self: 'self', 38 | all: 'all', 39 | none: 'none' 40 | }; 41 | exports.SUPPORTED_STREAM_BEHAVIOUR = SUPPORTED_STREAM_BEHAVIOUR; 42 | 43 | class Connection extends _eventemitter.default { 44 | constructor(options) { 45 | let subscriptions = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : []; 46 | super(); 47 | this.options = options; 48 | this.httpURI = this.buildURI('http'); 49 | this.wsURI = this.buildURI('ws'); 50 | this.shouldDisconnect = false; 51 | this.connected = false; 52 | this.socket = null; 53 | this.lastMessage = -1; 54 | this.isConnecting = false; 55 | this._fetchReady = false; 56 | this._bearerTokenPrefix = this.options.bearerTokenPrefix || 'Bearer'; 57 | this._authenticated = false; 58 | this._retries = 0; 59 | this._connection = null; 60 | this._self = ''; 61 | this._subscriptions = subscriptions; 62 | this.onWSMessage = this._onWSMessage.bind(this); 63 | this.onWSOpen = this._onWSOpen.bind(this); 64 | this.onWSClose = this._onWSClose.bind(this); 65 | this.onWSError = this._onWSError.bind(this); 66 | this._token = { 67 | kind: '', 68 | token: '' 69 | }; 70 | this.reconnect(true); 71 | } 72 | 73 | get retries() { 74 | return this._retries; 75 | } 76 | 77 | set self(data) { 78 | if (data !== null) { 79 | this.emit('self', data); 80 | } 81 | 82 | this._self = data; 83 | } 84 | 85 | get self() { 86 | return this._self; 87 | } 88 | 89 | set connectionInfo(data) { 90 | if (data !== null) { 91 | this.emit('connectionInfo', data); 92 | } 93 | 94 | this._connection = data; 95 | this.self = data.self; 96 | } 97 | 98 | get connectionInfo() { 99 | return this._connection; 100 | } 101 | 102 | buildURI(protocol) { 103 | const { 104 | useTLS, 105 | hostname, 106 | port, 107 | version, 108 | deltaStreamBehaviour 109 | } = this.options; 110 | let uri = useTLS === true ? "".concat(protocol, "s://") : "".concat(protocol, "://"); 111 | uri += hostname; 112 | uri += port === 80 ? '' : ":".concat(port); 113 | uri += '/signalk/'; 114 | uri += version; 115 | 116 | if (protocol === 'ws') { 117 | uri += '/stream'; 118 | 119 | if (deltaStreamBehaviour && SUPPORTED_STREAM_BEHAVIOUR.hasOwnProperty(deltaStreamBehaviour) && SUPPORTED_STREAM_BEHAVIOUR[deltaStreamBehaviour] !== '') { 120 | uri += "?subscribe=".concat(SUPPORTED_STREAM_BEHAVIOUR[deltaStreamBehaviour]); 121 | } 122 | } 123 | 124 | if (protocol === 'http') { 125 | uri += '/api'; 126 | } 127 | 128 | return uri; 129 | } 130 | 131 | state() { 132 | return { 133 | connecting: this.isConnecting, 134 | connected: this.connected, 135 | ready: this.fetchReady 136 | }; 137 | } 138 | 139 | disconnect() { 140 | debug('[disconnect] called'); 141 | this.shouldDisconnect = true; 142 | this.reconnect(); 143 | } 144 | 145 | backOffAndReconnect() { 146 | if (this.isConnecting === true) { 147 | return; 148 | } 149 | 150 | const { 151 | maxTimeBetweenRetries 152 | } = this.options; 153 | let waitTime = this._retries < Math.round(maxTimeBetweenRetries / 250) ? this._retries * 250 : maxTimeBetweenRetries; 154 | 155 | if (waitTime === 0) { 156 | return this.reconnect(); 157 | } 158 | 159 | this.emit('backOffBeforeReconnect', waitTime); 160 | debug("[backOffAndReconnect] waiting ".concat(waitTime, " ms before reconnecting")); 161 | setTimeout(() => this.reconnect(), waitTime); 162 | } 163 | 164 | reconnect() { 165 | let initial = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false; 166 | 167 | if (this.isConnecting === true) { 168 | return; 169 | } 170 | 171 | if (this.socket !== null) { 172 | debug('[reconnect] closing socket'); 173 | this.socket.close(); 174 | return; 175 | } 176 | 177 | if (initial === false) { 178 | this._retries += 1; 179 | } 180 | 181 | if (initial !== true && this._retries === this.options.maxRetries) { 182 | this.emit('hitMaxRetries'); 183 | this.cleanupListeners(); 184 | return; 185 | } 186 | 187 | if (initial !== true && this.options.reconnect === false) { 188 | debug('[reconnect] Not reconnecting, for reconnect is false'); 189 | this.cleanupListeners(); 190 | return; 191 | } 192 | 193 | if (initial !== true && this.shouldDisconnect === true) { 194 | debug('[reconnect] not reconnecting, shouldDisconnect is true'); 195 | this.cleanupListeners(); 196 | return; 197 | } 198 | 199 | debug("[reconnect] socket is ".concat(this.socket === null ? '' : 'not ', "NULL")); 200 | this._fetchReady = false; 201 | this.shouldDisconnect = false; 202 | this.isConnecting = true; 203 | 204 | if (this.options.useAuthentication === false) { 205 | this._fetchReady = true; 206 | this.emit('fetchReady'); 207 | this.initiateSocket(); 208 | return; 209 | } 210 | 211 | const authRequest = { 212 | method: 'POST', 213 | mode: 'cors', 214 | credentials: 'same-origin', 215 | body: JSON.stringify({ 216 | username: String(this.options.username || ''), 217 | password: String(this.options.password || '') 218 | }) 219 | }; 220 | return this.fetch('/auth/login', authRequest).then(result => { 221 | if (!result || typeof result !== 'object' || !result.hasOwnProperty('token')) { 222 | throw new Error("Unexpected response from auth endpoint: ".concat(JSON.stringify(result))); 223 | } 224 | 225 | debug("[reconnect] successful auth request: ".concat(JSON.stringify(result, null, 2))); 226 | this._authenticated = true; 227 | this._token = { 228 | kind: typeof result.type === 'string' && result.type.trim() !== '' ? result.type : this._bearerTokenPrefix, 229 | token: result.token 230 | }; 231 | this._fetchReady = true; 232 | this.emit('fetchReady'); 233 | this.initiateSocket(); 234 | }).catch(err => { 235 | debug("[reconnect] error logging in: ".concat(err.message, ", reconnecting")); 236 | this.emit('error', err); 237 | this._retries += 1; 238 | this.isConnecting = false; 239 | return this.backOffAndReconnect(); 240 | }); 241 | } 242 | 243 | setAuthenticated(token) { 244 | let kind = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'JWT'; 245 | // @FIXME default type should be Bearer 246 | this.emit('fetchReady'); 247 | this._authenticated = true; 248 | this._token = { 249 | kind, 250 | token 251 | }; 252 | } 253 | 254 | initiateSocket() { 255 | if (isNode && this.options.useTLS && this.options.rejectUnauthorized === false) { 256 | this.socket = new _isomorphicWs.default(this.wsURI, { 257 | rejectUnauthorized: false 258 | }); 259 | } else { 260 | this.socket = new _isomorphicWs.default(this.wsURI); 261 | } 262 | 263 | this.socket.addEventListener('message', this.onWSMessage); 264 | this.socket.addEventListener('open', this.onWSOpen); 265 | this.socket.addEventListener('error', this.onWSError); 266 | this.socket.addEventListener('close', this.onWSClose); 267 | } 268 | 269 | cleanupListeners() { 270 | debug("[cleanupListeners] resetting auth and removing listeners"); // Reset authentication 271 | 272 | this._authenticated = false; 273 | this._token = { 274 | kind: '', 275 | token: '' 276 | }; 277 | this.removeAllListeners(); 278 | } 279 | 280 | _onWSMessage(evt) { 281 | this.lastMessage = Date.now(); 282 | let data = evt.data; 283 | 284 | try { 285 | if (typeof data === 'string') { 286 | data = JSON.parse(data); 287 | } 288 | } catch (e) { 289 | console.error("[Connection: ".concat(this.options.hostname, "] Error parsing data: ").concat(e.message)); 290 | } 291 | 292 | if (data && typeof data === 'object' && data.hasOwnProperty('name') && data.hasOwnProperty('version') && data.hasOwnProperty('roles')) { 293 | this.connectionInfo = data; 294 | } 295 | 296 | this.emit('message', data); 297 | } 298 | 299 | _onWSOpen() { 300 | this.connected = true; 301 | this.isConnecting = false; 302 | 303 | if (this._subscriptions.length > 0) { 304 | const subscriptions = flattenSubscriptions(this._subscriptions); 305 | this.subscribe(subscriptions); 306 | } 307 | 308 | this._retries = 0; 309 | this.emit('connect'); 310 | } 311 | 312 | _onWSError(err) { 313 | debug('[_onWSError] WS error', err.message || ''); 314 | this.emit('error', err); 315 | this.backOffAndReconnect(); 316 | } 317 | 318 | _onWSClose(evt) { 319 | debug('[_onWSClose] called with wsURI:', this.wsURI); 320 | this.socket.removeEventListener('message', this.onWSMessage); 321 | this.socket.removeEventListener('open', this.onWSOpen); 322 | this.socket.removeEventListener('error', this.onWSError); 323 | this.socket.removeEventListener('close', this.onWSClose); 324 | this.connected = false; 325 | this.isConnecting = false; 326 | this.socket = null; 327 | this.emit('disconnect', evt); 328 | this.backOffAndReconnect(); 329 | } 330 | 331 | unsubscribe() { 332 | if (this.connected !== true || this.socket === null) { 333 | debug('Not connected to socket'); 334 | return; 335 | } 336 | 337 | this.send(JSON.stringify({ 338 | context: '*', 339 | unsubscribe: [{ 340 | path: '*' 341 | }] 342 | })); 343 | } 344 | 345 | subscribe() { 346 | let subscriptions = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; 347 | 348 | if (!Array.isArray(subscriptions) && subscriptions && typeof subscriptions === 'object' && subscriptions.hasOwnProperty('subscribe')) { 349 | subscriptions = [subscriptions]; 350 | } 351 | 352 | subscriptions.forEach(sub => { 353 | this.send(JSON.stringify(sub)); 354 | }); 355 | } 356 | 357 | send(data) { 358 | if (this.connected !== true || this.socket === null) { 359 | return Promise.reject(new Error('Not connected to WebSocket')); 360 | } // Basic check if data is stringified JSON 361 | 362 | 363 | if (typeof data === 'string') { 364 | try { 365 | data = JSON.parse(data); 366 | } catch (e) { 367 | debug("[send] data is string but not valid JSON: ".concat(e.message)); 368 | } 369 | } 370 | 371 | const isObj = data && typeof data === 'object'; // FIXME: this shouldn't be required as per discussion about security. 372 | // Add token to data IF authenticated 373 | // https://signalk.org/specification/1.3.0/doc/security.html#other-clients 374 | // if (isObj && this.useAuthentication === true && this._authenticated === true) { 375 | // data.token = String(this._token.token) 376 | // } 377 | 378 | try { 379 | if (isObj) { 380 | data = JSON.stringify(data); 381 | } 382 | } catch (e) { 383 | return Promise.reject(e); 384 | } 385 | 386 | debug("Sending data to socket: ".concat(data)); 387 | const result = this.socket.send(data); 388 | return Promise.resolve(result); 389 | } 390 | 391 | fetch(path, opts) { 392 | if (path.charAt(0) !== '/') { 393 | path = "/".concat(path); 394 | } 395 | 396 | if (!opts || typeof opts !== 'object') { 397 | opts = { 398 | method: 'GET' 399 | }; 400 | } 401 | 402 | if (!opts.headers || typeof opts.headers !== 'object') { 403 | opts.headers = { 404 | Accept: 'application/json', 405 | 'Content-Type': 'application/json' 406 | }; 407 | } 408 | 409 | if (this._authenticated === true && !path.includes('auth/login')) { 410 | opts.headers = _objectSpread(_objectSpread({}, opts.headers), {}, { 411 | Authorization: "".concat(this._token.kind, " ").concat(this._token.token) 412 | }); 413 | opts.credentials = 'same-origin'; 414 | opts.mode = 'cors'; 415 | debug("[fetch] enriching fetch options with in-memory token"); 416 | } 417 | 418 | if (isNode && this.options.useTLS && this.options.rejectUnauthorized === false) { 419 | opts.agent = new _https.default.Agent({ 420 | rejectUnauthorized: false 421 | }); 422 | } 423 | 424 | let URI = "".concat(this.httpURI).concat(path); // @TODO httpURI includes /api, which is not desirable. Need to refactor 425 | 426 | if (URI.includes('/api/auth/login')) { 427 | URI = URI.replace('/api/auth/login', '/auth/login'); 428 | } // @TODO httpURI includes /api, which is not desirable. Need to refactor 429 | 430 | 431 | if (URI.includes('/api/access/requests')) { 432 | URI = URI.replace('/api/access/requests', '/access/requests'); 433 | } // @FIXME weird hack because node server paths for access requests are not standardised 434 | 435 | 436 | if (URI.includes('/signalk/v1/api/security')) { 437 | URI = URI.replace('/signalk/v1/api/security', '/security'); 438 | } 439 | 440 | debug("[fetch] ".concat(opts.method || 'GET', " ").concat(URI, " ").concat(JSON.stringify(opts, null, 2))); 441 | return (0, _crossFetch.default)(URI, opts).then(response => { 442 | if (!response.ok) { 443 | throw new Error("Error fetching ".concat(URI, ": ").concat(response.status, " ").concat(response.statusText)); 444 | } 445 | 446 | const type = response.headers.get('content-type'); 447 | 448 | if (type.includes('application/json')) { 449 | return response.json(); 450 | } 451 | 452 | return response.text(); 453 | }); 454 | } 455 | 456 | } 457 | 458 | exports.default = Connection; 459 | 460 | const flattenSubscriptions = subscriptionCommands => { 461 | const commandPerContext = {}; 462 | subscriptionCommands.forEach(command => { 463 | if (!Array.isArray(commandPerContext[command.context])) { 464 | commandPerContext[command.context] = []; 465 | } 466 | 467 | commandPerContext[command.context] = commandPerContext[command.context].concat(command.subscribe); 468 | }); 469 | return Object.keys(commandPerContext).map(context => { 470 | const subscription = { 471 | context, 472 | subscribe: commandPerContext[context] 473 | }; 474 | 475 | if (subscription.subscribe.length > 0) { 476 | const paths = []; 477 | subscription.subscribe = subscription.subscribe.reduce((list, command) => { 478 | if (!paths.includes(command.path)) { 479 | paths.push(command.path); 480 | } else { 481 | const index = list.findIndex(candidate => candidate.path === command.path); 482 | 483 | if (index !== -1) { 484 | list.splice(index, 1); 485 | } 486 | } 487 | 488 | list.push(command); 489 | return list; 490 | }, []); 491 | } 492 | 493 | return subscription; 494 | }); 495 | }; -------------------------------------------------------------------------------- /dist/lib/discovery.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | require("core-js/modules/es.string.includes"); 4 | 5 | require("core-js/modules/es.string.split"); 6 | 7 | require("core-js/modules/es.string.starts-with"); 8 | 9 | require("core-js/modules/es.string.trim"); 10 | 11 | Object.defineProperty(exports, "__esModule", { 12 | value: true 13 | }); 14 | exports.default = exports.SKServer = void 0; 15 | 16 | var _eventemitter = _interopRequireDefault(require("eventemitter3")); 17 | 18 | var _client = _interopRequireDefault(require("./client")); 19 | 20 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 21 | 22 | function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); keys.push.apply(keys, symbols); } return keys; } 23 | 24 | function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys(Object(source), true).forEach(function (key) { _defineProperty(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; } 25 | 26 | function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } 27 | 28 | class SKServer { 29 | constructor(service) { 30 | this._roles = service.roles || ['master', 'main']; 31 | this._self = service.self || ''; 32 | this._version = service.version || '0.0.0'; 33 | this._hostname = service.hostname; 34 | this._port = service.port; 35 | } 36 | 37 | get roles() { 38 | return this._roles; 39 | } 40 | 41 | get self() { 42 | return this._self; 43 | } 44 | 45 | get version() { 46 | return this._version; 47 | } 48 | 49 | get hostname() { 50 | return this._hostname; 51 | } 52 | 53 | get port() { 54 | return this._port; 55 | } 56 | 57 | isMain() { 58 | return this._roles.includes('main'); 59 | } 60 | 61 | isMaster() { 62 | return this._roles.includes('master'); 63 | } 64 | 65 | createClient() { 66 | let opts = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; 67 | return new _client.default(_objectSpread(_objectSpread({}, opts), {}, { 68 | hostname: this._hostname, 69 | port: this._port 70 | })); 71 | } 72 | 73 | } 74 | 75 | exports.SKServer = SKServer; 76 | 77 | class Discovery extends _eventemitter.default { 78 | constructor(bonjourOrMdns) { 79 | let timeout = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 60000; 80 | super(); 81 | this.found = []; 82 | 83 | if (!bonjourOrMdns || typeof bonjourOrMdns !== 'object') { 84 | throw new Error('No mDNS provider given'); 85 | } 86 | 87 | const bonjourProps = ['_server', '_registry'].join(','); 88 | const mdnsProps = ['dns_sd', 'Advertisement', 'createAdvertisement', 'Browser'].join(','); 89 | 90 | if (Object.keys(bonjourOrMdns).join(',').startsWith(bonjourProps)) { 91 | return this.discoverWithBonjour(bonjourOrMdns, timeout); 92 | } 93 | 94 | if (Object.keys(bonjourOrMdns).join(',').startsWith(mdnsProps)) { 95 | return this.discoverWithMdns(bonjourOrMdns, timeout); 96 | } 97 | 98 | throw new Error('Unrecognized mDNS provider given'); 99 | } 100 | 101 | discoverWithBonjour(bonjour, timeout) { 102 | const browser = bonjour.find({ 103 | type: 'signalk-http' 104 | }); 105 | browser.on('up', ad => this.handleDiscoveredService(ad, _objectSpread(_objectSpread({}, ad.txt), {}, { 106 | name: ad.name || '', 107 | hostname: ad.host || '', 108 | port: parseInt(ad.port, 10), 109 | provider: 'bonjour' 110 | }))); 111 | setTimeout(() => { 112 | if (this.found.length === 0) { 113 | this.emit('timeout'); 114 | } 115 | 116 | browser.stop(); 117 | }, timeout); 118 | browser.start(); 119 | } 120 | 121 | discoverWithMdns(mDNS, timeout) { 122 | const browser = mDNS.createBrowser(mDNS.tcp('_signalk-http')); 123 | browser.on('serviceUp', ad => this.handleDiscoveredService(ad, _objectSpread(_objectSpread({}, ad.txtRecord), {}, { 124 | hostname: ad.host || '', 125 | port: parseInt(ad.port, 10), 126 | provider: 'mdns' 127 | }))); 128 | browser.on('error', err => this.handleDiscoveryError(err)); 129 | setTimeout(() => { 130 | if (this.found.length === 0) { 131 | this.emit('timeout'); 132 | } 133 | 134 | browser.stop(); 135 | }, timeout); 136 | browser.start(); 137 | } 138 | 139 | handleDiscoveryError(err) { 140 | console.error("Error during discovery: ".concat(err.message)); 141 | } 142 | 143 | handleDiscoveredService(ad, service) { 144 | if (typeof service.roles === 'string') { 145 | service.roles = service.roles.split(',').map(role => role.trim().toLowerCase()); 146 | } 147 | 148 | service.roles = Array.isArray(service.roles) ? service.roles : []; 149 | let ipv4 = service.hostname; 150 | 151 | if (Array.isArray(ad.addresses)) { 152 | ipv4 = ad.addresses.reduce((found, address) => { 153 | if (address && typeof address === 'string' && address.includes('.')) { 154 | found = address; 155 | } 156 | 157 | return found; 158 | }, service.hostname); 159 | } 160 | 161 | if (ipv4.trim() !== '') { 162 | service.hostname = ipv4; 163 | } 164 | 165 | const server = new SKServer(service); 166 | this.found.push(server); 167 | this.emit('found', server); 168 | } 169 | 170 | } 171 | 172 | exports.default = Discovery; -------------------------------------------------------------------------------- /dist/lib/request.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | Object.defineProperty(exports, "__esModule", { 4 | value: true 5 | }); 6 | exports.default = void 0; 7 | 8 | var _eventemitter = _interopRequireDefault(require("eventemitter3")); 9 | 10 | var _debug = _interopRequireDefault(require("debug")); 11 | 12 | var _uuid = require("uuid"); 13 | 14 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } 15 | 16 | function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); keys.push.apply(keys, symbols); } return keys; } 17 | 18 | function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys(Object(source), true).forEach(function (key) { _defineProperty(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; } 19 | 20 | function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } 21 | 22 | const debug = (0, _debug.default)('signalk-js-sdk/Request'); 23 | 24 | class Request extends _eventemitter.default { 25 | constructor(connection, name, body) { 26 | super(); 27 | this.connection = connection; 28 | this.requestId = (0, _uuid.v4)(); 29 | this.name = name; 30 | this.body = body; 31 | this.responses = []; 32 | this.sent = false; 33 | this.connection.on('message', message => { 34 | if (message && typeof message === 'object' && message.hasOwnProperty('requestId') && message.requestId === this.requestId) { 35 | this.addResponse(message); 36 | } 37 | }); 38 | } 39 | 40 | query() { 41 | const request = { 42 | requestId: this.requestId, 43 | query: true 44 | }; 45 | debug("Sending query: ".concat(JSON.stringify(request, null, 2))); 46 | this.connection.send(request); 47 | } 48 | 49 | send() { 50 | if (this.sent === true) { 51 | return; 52 | } 53 | 54 | const request = _objectSpread({ 55 | requestId: this.requestId 56 | }, this.body); 57 | 58 | debug("Sending request: ".concat(JSON.stringify(request, null, 2))); 59 | this.connection.send(request); 60 | } 61 | 62 | addResponse(response) { 63 | debug("Got response for request \"".concat(this.name, "\": ").concat(JSON.stringify(response, null, 2))); 64 | const receivedAt = new Date().toISOString(); 65 | this.responses.push({ 66 | response, 67 | receivedAt 68 | }); 69 | this.emit('response', _objectSpread(_objectSpread({}, response), {}, { 70 | request: { 71 | receivedAt, 72 | name: this.name, 73 | requestId: this.requestId 74 | } 75 | })); 76 | } 77 | 78 | getRequestId() { 79 | return this.requestId; 80 | } 81 | 82 | } 83 | 84 | exports.default = Request; -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./dist') 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@signalk/client", 3 | "version": "2.3.0", 4 | "description": "A Javascript SDK for Signal K clients. Provides various abstract interfaces for discovering (via optional mDNS) the Signal K server and communication via WebSocket & REST. Aims to implement all major APIs in the most recent Signal K version(s)", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "mocha --timeout 10000 --require @babel/register --exit", 8 | "start": "nodemon --exec babel-node src/index.js", 9 | "dist": "./node_modules/.bin/babel src -d dist", 10 | "prepublishOnly": "npm run dist", 11 | "preversion": "npm test", 12 | "push": "npm publish --access public --scope @signalk .", 13 | "create-release": "github-create-release --owner signalk --repository signalk-js-client", 14 | "release": "git tag -d v$npm_package_version && git tag v$npm_package_version && git push --tags && git push && npm run create-release" 15 | }, 16 | "keywords": [ 17 | "signal k", 18 | "js", 19 | "javascript", 20 | "ecmascript", 21 | "client", 22 | "sdk" 23 | ], 24 | "author": "Fabian Tollenaar (http://signalk.org)", 25 | "license": "Apache-2.0", 26 | "devDependencies": { 27 | "@babel/cli": "^7.10.1", 28 | "@babel/core": "^7.10.2", 29 | "@babel/preset-env": "^7.10.2", 30 | "@babel/register": "^7.10.1", 31 | "@signalk/github-create-release": "^1.2.0", 32 | "bonjour": "^3.5.0", 33 | "casper-chai": "^0.3.0", 34 | "chai": "^4.1.0", 35 | "freeport-promise": "^1.1.0", 36 | "mdns": "^2.5.1", 37 | "mocha": "^7.2.0", 38 | "signalk-server": "^1.32.0" 39 | }, 40 | "repository": { 41 | "type": "git", 42 | "url": "https://github.com/SignalK/signalk-js-client" 43 | }, 44 | "homepage": "https://github.com/SignalK/signalk-js-client", 45 | "bugs": { 46 | "url": "https://github.com/SignalK/signalk-js-client/issues" 47 | }, 48 | "standard": { 49 | "globals": [ 50 | "describe", 51 | "before", 52 | "after", 53 | "it", 54 | "expect", 55 | "Promise", 56 | "WebSocket" 57 | ] 58 | }, 59 | "dependencies": { 60 | "core-js": "^3.6.5", 61 | "cross-fetch": "^3.0.3", 62 | "debug": "^4.3.2", 63 | "eventemitter3": "^4.0.0", 64 | "isomorphic-ws": "^4.0.0", 65 | "uuid": "^8.1.0" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /releasing.md: -------------------------------------------------------------------------------- 1 | ``` 2 | npm version [ | major | minor | patch | premajor | preminor | prepatch | prerelease | from-git] 3 | npm run release 4 | npm publish 5 | ``` 6 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Fabian Tollenaar 3 | * @copyright 2018-2019, Fabian Tollenaar. All rights reserved. 4 | * @license Apache-2.0 5 | * @module @signalk/signalk-js-sdk 6 | */ 7 | 8 | import SKClient from './lib/client' 9 | import SKDiscovery from './lib/discovery' 10 | 11 | export const Client = SKClient 12 | export const Discovery = SKDiscovery 13 | 14 | export default SKClient 15 | -------------------------------------------------------------------------------- /src/lib/api.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @description An API wraps the REST API for a Signal K server 3 | * @author Fabian Tollenaar 4 | * @copyright 2018-2019, Fabian Tollenaar. All rights reserved. 5 | * @license Apache-2.0 6 | * @module @signalk/signalk-js-sdk 7 | */ 8 | 9 | export default class API { 10 | constructor (connection) { 11 | this.connection = connection 12 | this.selfMRN = this.connection.self 13 | } 14 | 15 | get (path) { 16 | if (path.includes('.')) { 17 | path = path.replace(/\./g, '/') 18 | } 19 | 20 | if (typeof path !== 'string' || path.trim() === '') { 21 | path = '/' 22 | } 23 | 24 | if (path.charAt(0) !== '/') { 25 | path = `/${path}` 26 | } 27 | 28 | return this.connection.fetch(path) 29 | } 30 | 31 | put (path, body) { 32 | if (path.includes('.')) { 33 | path = path.replace(/\./g, '/') 34 | } 35 | 36 | if (typeof path !== 'string' || path.trim() === '') { 37 | path = '/' 38 | } 39 | 40 | if (path.charAt(0) !== '/') { 41 | path = `/${path}` 42 | } 43 | 44 | return this.connection.fetch(path, { 45 | method: 'PUT', 46 | mode: 'cors', 47 | body: (body && typeof body === 'object') ? JSON.stringify(body) : body 48 | }) 49 | } 50 | 51 | /** 52 | * Shortcut methods. 53 | * @TODO: investigate if we can generate these using a Proxy and signalk-schema, using this.options.version. 54 | */ 55 | 56 | getMeta (path) { 57 | return this 58 | .get(path) 59 | .then(result => { 60 | if (!result || typeof result !== 'object') { 61 | return null 62 | } 63 | 64 | if (!result.hasOwnProperty('meta')) { 65 | return null 66 | } 67 | 68 | return result.meta 69 | }) 70 | } 71 | 72 | sources () { 73 | return this.get('/sources') 74 | } 75 | 76 | resources () { 77 | return this.get('/resources') 78 | } 79 | 80 | mrn () { 81 | return this.get('/self') 82 | } 83 | 84 | vessels () { 85 | return this.get('/vessels') 86 | } 87 | 88 | aircraft () { 89 | return this.get('/aircraft') 90 | } 91 | 92 | aton () { 93 | return this.get('/aton') 94 | } 95 | 96 | sar () { 97 | return this.get('/sar') 98 | } 99 | 100 | version () { 101 | return this.get('/version') 102 | } 103 | 104 | self (path) { 105 | if (typeof path !== 'string' || path.charAt(0) !== '/') { 106 | path = '' 107 | } 108 | 109 | return this.connection.fetch(`/vessels/self${path}`) 110 | } 111 | 112 | vessel (mrn, path) { 113 | if (typeof path !== 'string' || path.charAt(0) !== '/') { 114 | path = '' 115 | } 116 | 117 | return this.connection.fetch(`/vessels/${mrn}${path}`) 118 | } 119 | 120 | name () { 121 | return this.self('/name') 122 | } 123 | 124 | getGroup (group, path, vessel = 'self') { 125 | if (typeof path !== 'string' || path.charAt(0) !== '/') { 126 | path = '' 127 | } 128 | 129 | if (vessel === 'self') { 130 | return this.self(`/${group}${path}`) 131 | } 132 | 133 | return this.vessel(vessel, `/${group}${path}`) 134 | } 135 | 136 | communication (path = '', vessel = 'self') { 137 | return this.getGroup('communication', path, vessel) 138 | } 139 | 140 | design (path = '', vessel = 'self') { 141 | return this.getGroup('design', path, vessel) 142 | } 143 | 144 | electrical (path = '', vessel = 'self') { 145 | return this.getGroup('electrical', path, vessel) 146 | } 147 | 148 | environment (path = '', vessel = 'self') { 149 | return this.getGroup('environment', path, vessel) 150 | } 151 | 152 | navigation (path = '', vessel = 'self') { 153 | return this.getGroup('navigation', path, vessel) 154 | } 155 | 156 | notifications (path = '', vessel = 'self') { 157 | return this.getGroup('notifications', path, vessel) 158 | } 159 | 160 | performance (path = '', vessel = 'self') { 161 | return this.getGroup('performance', path, vessel) 162 | } 163 | 164 | propulsion (path = '', vessel = 'self') { 165 | return this.getGroup('propulsion', path, vessel) 166 | } 167 | 168 | sails (path = '', vessel = 'self') { 169 | return this.getGroup('sails', path, vessel) 170 | } 171 | 172 | sensors (path = '', vessel = 'self') { 173 | return this.getGroup('sensors', path, vessel) 174 | } 175 | 176 | steering (path = '', vessel = 'self') { 177 | return this.getGroup('steering', path, vessel) 178 | } 179 | 180 | tanks (path = '', vessel = 'self') { 181 | return this.getGroup('tanks', path, vessel) 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /src/lib/client.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @description Client implements functionality to discover, connect to, 3 | * retrieve data and receive data from a Signal K server. 4 | * @author Fabian Tollenaar 5 | * @copyright 2018-2019, Fabian Tollenaar. All rights reserved. 6 | * @license Apache-2.0 7 | * @module @signalk/signalk-js-sdk 8 | */ 9 | 10 | import EventEmitter from 'eventemitter3' 11 | import Connection from './connection' 12 | import Request from './request' 13 | import API from './api' 14 | import Debug from 'debug' 15 | import { v4 as uuid } from 'uuid' 16 | 17 | const debug = Debug('signalk-js-sdk/Client') 18 | 19 | // Constants 20 | export const AUTHENTICATION_REQUEST = '__AUTHENTICATION_REQUEST__' 21 | // Permissions for access requests 22 | export const PERMISSIONS_READWRITE = 'readwrite' 23 | export const PERMISSIONS_READONLY = 'readonly' 24 | export const PERMISSIONS_DENY = 'denied' 25 | 26 | export default class Client extends EventEmitter { 27 | constructor(options = {}) { 28 | super() 29 | this.options = { 30 | hostname: 'localhost', 31 | port: 3000, 32 | useTLS: true, 33 | useAuthentication: false, 34 | notifications: true, 35 | version: 'v1', 36 | autoConnect: false, 37 | reconnect: true, 38 | maxRetries: Infinity, 39 | maxTimeBetweenRetries: 2500, 40 | mdns: null, 41 | username: null, 42 | password: null, 43 | deltaStreamBehaviour: 'none', 44 | subscriptions: [], 45 | wsKeepaliveInterval: 0, 46 | ...options, 47 | } 48 | 49 | this.api = null 50 | this.connection = null 51 | this.services = [] 52 | this.notifications = {} 53 | this.requests = {} 54 | this.fetchReady = null 55 | 56 | if (Array.isArray(this.options.subscriptions)) { 57 | this.subscribeCommands = this.options.subscriptions.filter((command) => 58 | isValidSubscribeCommand(command) 59 | ) 60 | } 61 | 62 | if (this.options.notifications === true) { 63 | this.subscribeCommands.push({ 64 | context: 'vessels.self', 65 | subscribe: [ 66 | { 67 | path: 'notifications.*', 68 | policy: 'instant', 69 | }, 70 | ], 71 | }) 72 | } 73 | 74 | if (this.options.autoConnect === true) { 75 | this.connect().catch((err) => this.emit('error', err)) 76 | } 77 | } 78 | 79 | get self() { 80 | if (this.connection === null) { 81 | return null 82 | } 83 | 84 | return this.connection.self 85 | } 86 | 87 | set(key, value) { 88 | this.options[key] = value 89 | return this 90 | } 91 | 92 | get(key) { 93 | return this.options[key] || null 94 | } 95 | 96 | get retries() { 97 | if (this.connection === null) { 98 | return 0 99 | } 100 | 101 | return this.connection.retries 102 | } 103 | 104 | // @TODO requesting access should be expanded into a small class to manage the entire flow (including polling) 105 | requestDeviceAccess(description, _clientId) { 106 | const clientId = typeof _clientId === 'string' ? _clientId : uuid() 107 | return this.connection 108 | .fetch('/access/requests', { 109 | method: 'POST', 110 | mode: 'cors', 111 | credentials: 'same-origin', 112 | headers: { 'Content-Type': 'application/json' }, 113 | body: JSON.stringify({ 114 | clientId, 115 | description, 116 | }), 117 | }) 118 | .then((response) => { 119 | return { 120 | clientId, 121 | response, 122 | } 123 | }) 124 | } 125 | 126 | respondToAccessRequest(uuid, permissions, expiration = '1y') { 127 | return this.connection.fetch( 128 | `/security/access/requests/${uuid}/${permissions === 'denied' ? 'denied' : 'approved'}`, 129 | { 130 | method: 'PUT', 131 | mode: 'cors', 132 | credentials: 'same-origin', 133 | headers: { 'Content-Type': 'application/json' }, 134 | body: JSON.stringify({ 135 | expiration, 136 | permissions, 137 | }), 138 | } 139 | ) 140 | } 141 | 142 | authenticate(username, password) { 143 | const request = this.request(AUTHENTICATION_REQUEST, { 144 | login: { 145 | username, 146 | password, 147 | }, 148 | }) 149 | 150 | request.on('response', (response) => { 151 | if ( 152 | response.statusCode === 200 && 153 | response.hasOwnProperty('login') && 154 | typeof response.login === 'object' && 155 | response.login.hasOwnProperty('token') 156 | ) { 157 | this.connection.setAuthenticated(response.login.token) 158 | 159 | // We are now authenticated 160 | return this.emit('authenticated', { 161 | token: response.login.token, 162 | }) 163 | } 164 | 165 | this.emit('error', new Error(`Error authenticating: status ${response.statusCode}`)) 166 | }) 167 | 168 | request.send() 169 | } 170 | 171 | request(name, body = {}) { 172 | if (!this.requests.hasOwnProperty(name)) { 173 | this.requests[name] = new Request(this.connection, name, body) 174 | debug(`Registered request "${name}" with ID ${this.requests[name].getRequestId()}`) 175 | } 176 | 177 | return this.requests[name] 178 | } 179 | 180 | subscribe(subscriptions = []) { 181 | if (this.connection === null) { 182 | throw new Error('Not connected') 183 | } 184 | 185 | if ( 186 | subscriptions && 187 | !Array.isArray(subscriptions) && 188 | typeof subscriptions === 'object' && 189 | subscriptions.hasOwnProperty('subscribe') 190 | ) { 191 | subscriptions = [subscriptions] 192 | } 193 | 194 | subscriptions = subscriptions.filter((command) => isValidSubscribeCommand(command)) 195 | subscriptions.forEach((command) => { 196 | this.subscribeCommands.push(command) 197 | }) 198 | 199 | this.connection.subscribe(subscriptions) 200 | } 201 | 202 | unsubscribe() { 203 | if (this.connection === null) { 204 | throw new Error('Not connected') 205 | } 206 | 207 | const { notifications } = this.options 208 | 209 | // Reset subscribeCommands 210 | this.subscribeCommands = 211 | notifications === true 212 | ? [ 213 | { 214 | context: 'vessels.self', 215 | subscribe: [ 216 | { 217 | path: 'notifications.*', 218 | policy: 'instant', 219 | }, 220 | ], 221 | }, 222 | ] 223 | : [] 224 | 225 | // Unsubscribe 226 | this.connection.unsubscribe() 227 | 228 | if (this.subscribeCommands.length > 0) { 229 | this.connection.subscribe(this.subscribeCommands) 230 | } 231 | } 232 | 233 | connect() { 234 | if (this.connection !== null) { 235 | this.connection.reconnect(true) 236 | return Promise.resolve(this.connection) 237 | } 238 | 239 | return new Promise((resolve, reject) => { 240 | this.connection = new Connection(this.options, this.subscribeCommands) 241 | 242 | this.connection.on('disconnect', (data) => this.emit('disconnect', data)) 243 | this.connection.on('message', (data) => this.processWSMessage(data)) 244 | this.connection.on('connectionInfo', (data) => this.emit('connectionInfo', data)) 245 | this.connection.on('self', (data) => this.emit('self', data)) 246 | this.connection.on('hitMaxRetries', () => this.emit('hitMaxRetries')) 247 | this.connection.on('backOffBeforeReconnect', (data) => 248 | this.emit('backOffBeforeReconnect', data) 249 | ) 250 | 251 | this.connection.on('connect', () => { 252 | this.getInitialNotifications() 253 | this.emit('connect') 254 | resolve(this.connection) 255 | }) 256 | 257 | this.connection.on('fetchReady', () => { 258 | this.fetchReady = true 259 | }) 260 | 261 | this.connection.on('error', (err) => { 262 | this.emit('error', err) 263 | reject(err) 264 | }) 265 | }) 266 | } 267 | 268 | disconnect(returnPromise = false) { 269 | if (this.connection !== null) { 270 | this.connection.on('disconnect', () => { 271 | this.cleanupListeners() 272 | this.connection = null 273 | }) 274 | 275 | this.connection.unsubscribe() 276 | this.connection.disconnect() 277 | } else { 278 | this.cleanupListeners() 279 | } 280 | 281 | if (this.api !== null) { 282 | this.api = null 283 | } 284 | 285 | if (returnPromise === true) { 286 | return Promise.resolve(this) 287 | } 288 | 289 | return this 290 | } 291 | 292 | cleanupListeners() { 293 | this.removeAllListeners('self') 294 | this.removeAllListeners('connectionInfo') 295 | this.removeAllListeners('message') 296 | this.removeAllListeners('delta') 297 | this.removeAllListeners('connect') 298 | this.removeAllListeners('error') 299 | this.removeAllListeners('hitMaxRetries') 300 | this.removeAllListeners('backOffBeforeReconnect') 301 | this.removeAllListeners('disconnect') 302 | this.removeAllListeners('unsubscribe') 303 | this.removeAllListeners('subscribe') 304 | } 305 | 306 | API() { 307 | // Returning a Promise, so this method can be used as the start of a promise chain. 308 | // I.e., all API methods return Promises, so it makes sense to start the Promise 309 | // chain at the top. 310 | if (this.connection === null) { 311 | return Promise.reject( 312 | new Error('There are no available connections. Please connect before you use the REST API.') 313 | ) 314 | } 315 | 316 | if (this.api !== null) { 317 | return Promise.resolve(this.api) 318 | } 319 | 320 | return new Promise((resolve) => { 321 | this.api = new API(this.connection) 322 | 323 | if (this.fetchReady === true || this.options.useAuthentication === false) { 324 | return resolve(this.api) 325 | } 326 | 327 | this.connection.on('fetchReady', () => { 328 | resolve(this.api) 329 | }) 330 | }) 331 | } 332 | 333 | processWSMessage(data) { 334 | this.emit('message', data) 335 | 336 | // Check if message is SK delta, then emit. 337 | if (data && typeof data === 'object' && data.hasOwnProperty('updates')) { 338 | this.checkAndEmitNotificationsInDelta(data) 339 | this.emit('delta', data) 340 | } 341 | } 342 | 343 | checkAndEmitNotificationsInDelta(delta) { 344 | if ( 345 | this.options.notifications === false || 346 | !delta || 347 | typeof delta !== 'object' || 348 | !Array.isArray(delta.updates) 349 | ) { 350 | return 351 | } 352 | 353 | const notifications = {} 354 | 355 | delta.updates.forEach((update) => { 356 | (update.values || []).forEach((mut) => { 357 | if (typeof mut.path === 'string' && mut.path.includes('notifications.')) { 358 | notifications[mut.path.replace('notifications.', '')] = { 359 | ...mut.value, 360 | } 361 | } 362 | }) 363 | }) 364 | 365 | Object.keys(notifications).forEach((path) => { 366 | if ( 367 | !this.notifications.hasOwnProperty(path) || 368 | this.notifications[path].timestamp !== notifications[path].timestamp 369 | ) { 370 | this.notifications[path] = { 371 | ...notifications[path], 372 | } 373 | 374 | const notification = { 375 | path, 376 | ...this.notifications[path], 377 | } 378 | 379 | debug( 380 | `[checkAndEmitNotificationsInDelta] emitting notification: ${JSON.stringify( 381 | notification, 382 | null, 383 | 2 384 | )}` 385 | ) 386 | this.emit('notification', notification) 387 | } 388 | }) 389 | } 390 | 391 | getInitialNotifications() { 392 | if (this.options.notifications === false) { 393 | return 394 | } 395 | 396 | if (this.connection === null) { 397 | return 398 | } 399 | 400 | if (this.api === null) { 401 | this.api = new API(this.connection) 402 | } 403 | 404 | this.api 405 | .notifications() 406 | .then((result) => { 407 | this.notifications = { 408 | ...this.notifications, 409 | ...flattenTree(result), 410 | } 411 | 412 | Object.keys(this.notifications).forEach((path) => { 413 | const notification = { 414 | path, 415 | ...this.notifications[path], 416 | } 417 | debug( 418 | `[getInitialNotifications] emitting notification: ${JSON.stringify( 419 | notification, 420 | null, 421 | 2 422 | )}` 423 | ) 424 | this.emit('notification', notification) 425 | }) 426 | 427 | return this.notifications 428 | }) 429 | .catch((err) => { 430 | console.error(`[getInitialNotifications] error getting notifications: ${err.message}`) 431 | }) 432 | } 433 | } 434 | 435 | const flattenTree = (tree) => { 436 | const flattened = {} 437 | let cursor = tree 438 | let currentPath = '' 439 | 440 | const evaluateLeaf = (key) => { 441 | currentPath += `${currentPath === '' ? '' : '.'}${key}` 442 | cursor = cursor[key] 443 | 444 | if (!cursor || typeof cursor !== 'object') { 445 | return 446 | } 447 | 448 | if (cursor && typeof cursor === 'object' && cursor.hasOwnProperty('value')) { 449 | flattened[currentPath] = Object.assign({}, cursor.value) 450 | } else { 451 | Object.keys(cursor).forEach(evaluateLeaf) 452 | } 453 | } 454 | 455 | Object.keys(cursor).forEach((key) => evaluateLeaf(key)) 456 | return flattened 457 | } 458 | 459 | const isValidSubscribeCommand = (command) => { 460 | if (!command || typeof command !== 'object') { 461 | return false 462 | } 463 | 464 | if (!command.hasOwnProperty('context') || !Array.isArray(command.subscribe)) { 465 | return false 466 | } 467 | 468 | return true 469 | } 470 | -------------------------------------------------------------------------------- /src/lib/connection.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @description A Connection represents a single connection to a Signal K server. 3 | * It manages both the HTTP connection (REST API) and the WS connection. 4 | * @author Fabian Tollenaar 5 | * @copyright 2018-2019, Fabian Tollenaar. All rights reserved. 6 | * @license Apache-2.0 7 | * @module @signalk/signalk-js-sdk 8 | */ 9 | 10 | import EventEmitter from 'eventemitter3' 11 | import WebSocket from 'isomorphic-ws' 12 | import fetch from 'cross-fetch' 13 | import Debug from 'debug' 14 | import https from 'https' 15 | 16 | const debug = Debug('signalk-js-sdk/Connection') 17 | 18 | const isNode = typeof process !== 'undefined' && process.versions != null && process.versions.node != null 19 | 20 | export const SUPPORTED_STREAM_BEHAVIOUR = { 21 | self: 'self', 22 | all: 'all', 23 | none: 'none', 24 | } 25 | 26 | export const SUPPORTED_SEND_META = { 27 | all: 'all', 28 | } 29 | 30 | export default class Connection extends EventEmitter { 31 | constructor(options, subscriptions = []) { 32 | super() 33 | this.options = options 34 | this.httpURI = this.buildURI('http') 35 | this.wsURI = this.buildURI('ws') 36 | this.shouldDisconnect = false 37 | this.connected = false 38 | this.socket = null 39 | this.lastMessage = -1 40 | this.isConnecting = false 41 | this.wsKeepaliveIntervalMs = this.options.wsKeepaliveInterval * 1000 42 | 43 | this._fetchReady = false 44 | this._bearerTokenPrefix = this.options.bearerTokenPrefix || 'Bearer' 45 | this._authenticated = false 46 | this._retries = 0 47 | this._connection = null 48 | this._self = '' 49 | this._subscriptions = subscriptions 50 | 51 | this.sendKeepaliveWithReschedule = this.sendKeepaliveWithReschedule.bind(this) 52 | this.onWSMessage = this._onWSMessage.bind(this) 53 | this.onWSOpen = this._onWSOpen.bind(this) 54 | this.onWSClose = this._onWSClose.bind(this) 55 | this.onWSError = this._onWSError.bind(this) 56 | 57 | this._token = { 58 | kind: '', 59 | token: '', 60 | } 61 | 62 | this.reconnect(true) 63 | } 64 | 65 | get retries() { 66 | return this._retries 67 | } 68 | 69 | set self(data) { 70 | if (data !== null) { 71 | this.emit('self', data) 72 | } 73 | 74 | this._self = data 75 | } 76 | 77 | get self() { 78 | return this._self 79 | } 80 | 81 | set connectionInfo(data) { 82 | if (data !== null) { 83 | this.emit('connectionInfo', data) 84 | } 85 | 86 | this._connection = data 87 | this.self = data.self 88 | } 89 | 90 | get connectionInfo() { 91 | return this._connection 92 | } 93 | 94 | buildURI(protocol) { 95 | const { useTLS, hostname, port, version, deltaStreamBehaviour, sendMeta } = this.options 96 | 97 | let uri = useTLS === true ? `${protocol}s://` : `${protocol}://` 98 | uri += hostname 99 | uri += port === 80 ? '' : `:${port}` 100 | 101 | uri += '/signalk/' 102 | uri += version 103 | 104 | if (protocol === 'ws') { 105 | uri += '/stream' 106 | 107 | const params = [] 108 | if (deltaStreamBehaviour && SUPPORTED_STREAM_BEHAVIOUR.hasOwnProperty(deltaStreamBehaviour) && SUPPORTED_STREAM_BEHAVIOUR[deltaStreamBehaviour] !== '') { 109 | params.push(`subscribe=${SUPPORTED_STREAM_BEHAVIOUR[deltaStreamBehaviour]}`) 110 | } 111 | if (sendMeta && SUPPORTED_SEND_META.hasOwnProperty(sendMeta) && SUPPORTED_SEND_META[sendMeta] !== '') { 112 | params.push(`sendMeta=${SUPPORTED_SEND_META[sendMeta]}`) 113 | } 114 | if (params) { 115 | uri += '?' + params.join('&') 116 | } 117 | } 118 | 119 | if (protocol === 'http') { 120 | uri += '/api' 121 | } 122 | 123 | return uri 124 | } 125 | 126 | state() { 127 | return { 128 | connecting: this.isConnecting, 129 | connected: this.connected, 130 | ready: this.fetchReady, 131 | } 132 | } 133 | 134 | disconnect() { 135 | debug('[disconnect] called') 136 | this.shouldDisconnect = true 137 | this.reconnect() 138 | } 139 | 140 | backOffAndReconnect() { 141 | if (this.isConnecting === true) { 142 | return 143 | } 144 | 145 | const { maxTimeBetweenRetries } = this.options 146 | 147 | let waitTime = this._retries < Math.round(maxTimeBetweenRetries / 250) ? this._retries * 250 : maxTimeBetweenRetries 148 | 149 | if (waitTime === 0) { 150 | return this.reconnect() 151 | } 152 | 153 | this.emit('backOffBeforeReconnect', waitTime) 154 | 155 | debug(`[backOffAndReconnect] waiting ${waitTime} ms before reconnecting`) 156 | setTimeout(() => this.reconnect(), waitTime) 157 | } 158 | 159 | reconnect(initial = false) { 160 | if (this.isConnecting === true) { 161 | return 162 | } 163 | 164 | if (this.socket !== null) { 165 | debug('[reconnect] closing socket') 166 | this.socket.close() 167 | return 168 | } 169 | 170 | if (initial === false) { 171 | this._retries += 1 172 | } 173 | 174 | if (initial !== true && this._retries === this.options.maxRetries) { 175 | this.emit('hitMaxRetries') 176 | this.cleanupListeners() 177 | return 178 | } 179 | 180 | if (initial !== true && this.options.reconnect === false) { 181 | debug('[reconnect] Not reconnecting, for reconnect is false') 182 | this.cleanupListeners() 183 | return 184 | } 185 | 186 | if (initial !== true && this.shouldDisconnect === true) { 187 | debug('[reconnect] not reconnecting, shouldDisconnect is true') 188 | this.cleanupListeners() 189 | return 190 | } 191 | 192 | debug(`[reconnect] socket is ${this.socket === null ? '' : 'not '}NULL`) 193 | 194 | this._fetchReady = false 195 | this.shouldDisconnect = false 196 | this.isConnecting = true 197 | 198 | if (this.options.useAuthentication === false) { 199 | this._fetchReady = true 200 | this.emit('fetchReady') 201 | this.initiateSocket() 202 | return 203 | } 204 | 205 | const authRequest = { 206 | method: 'POST', 207 | mode: 'cors', 208 | credentials: 'same-origin', 209 | body: JSON.stringify({ 210 | username: String(this.options.username || ''), 211 | password: String(this.options.password || ''), 212 | }), 213 | } 214 | 215 | return this.fetch('/auth/login', authRequest) 216 | .then((result) => { 217 | if (!result || typeof result !== 'object' || !result.hasOwnProperty('token')) { 218 | throw new Error(`Unexpected response from auth endpoint: ${JSON.stringify(result)}`) 219 | } 220 | 221 | debug(`[reconnect] successful auth request: ${JSON.stringify(result, null, 2)}`) 222 | 223 | this._authenticated = true 224 | this._token = { 225 | kind: typeof result.type === 'string' && result.type.trim() !== '' ? result.type : this._bearerTokenPrefix, 226 | token: result.token, 227 | } 228 | 229 | this._fetchReady = true 230 | this.emit('fetchReady') 231 | this.initiateSocket() 232 | }) 233 | .catch((err) => { 234 | debug(`[reconnect] error logging in: ${err.message}, reconnecting`) 235 | this.emit('error', err) 236 | this._retries += 1 237 | this.isConnecting = false 238 | return this.backOffAndReconnect() 239 | }) 240 | } 241 | 242 | setAuthenticated(token, kind = 'JWT') { 243 | // @FIXME default type should be Bearer 244 | this.emit('fetchReady') 245 | this._authenticated = true 246 | this._token = { 247 | kind, 248 | token, 249 | } 250 | } 251 | 252 | initiateSocket() { 253 | if (isNode && this.options.useTLS && this.options.rejectUnauthorized === false) { 254 | this.socket = new WebSocket(this.wsURI, { rejectUnauthorized: false }) 255 | } else { 256 | this.socket = new WebSocket(this.wsURI) 257 | } 258 | this.socket.addEventListener('message', this.onWSMessage) 259 | this.socket.addEventListener('open', this.onWSOpen) 260 | this.socket.addEventListener('error', this.onWSError) 261 | this.socket.addEventListener('close', this.onWSClose) 262 | } 263 | 264 | cleanupListeners() { 265 | debug(`[cleanupListeners] resetting auth and removing listeners`) 266 | // Reset authentication 267 | this._authenticated = false 268 | this._token = { 269 | kind: '', 270 | token: '', 271 | } 272 | this.removeAllListeners() 273 | } 274 | 275 | sendKeepaliveWithReschedule() { 276 | if (this.connected === true) { 277 | if (this.lastMessage < Date.now() - this.wsKeepaliveIntervalMs) { 278 | this.socket.send("{}"); 279 | } 280 | setTimeout(this.sendKeepaliveWithReschedule, this.wsKeepaliveIntervalMs); 281 | } 282 | } 283 | 284 | _onWSMessage(evt) { 285 | this.lastMessage = Date.now() 286 | let data = evt.data 287 | 288 | try { 289 | if (typeof data === 'string') { 290 | data = JSON.parse(data) 291 | } 292 | } catch (e) { 293 | console.error(`[Connection: ${this.options.hostname}] Error parsing data: ${e.message}`) 294 | } 295 | 296 | if (data && typeof data === 'object' && data.hasOwnProperty('name') && data.hasOwnProperty('version') && data.hasOwnProperty('roles')) { 297 | this.connectionInfo = data 298 | } 299 | 300 | this.emit('message', data) 301 | } 302 | 303 | _onWSOpen() { 304 | this.connected = true 305 | this.isConnecting = false 306 | 307 | if (this._subscriptions.length > 0) { 308 | const subscriptions = flattenSubscriptions(this._subscriptions) 309 | this.subscribe(subscriptions) 310 | } 311 | 312 | this._retries = 0 313 | if(this.options.wsKeepaliveInterval > 0) this.sendKeepaliveWithReschedule(); 314 | this.emit('connect') 315 | } 316 | 317 | _onWSError(err) { 318 | debug('[_onWSError] WS error', err.message || '') 319 | this.emit('error', err) 320 | this.backOffAndReconnect() 321 | } 322 | 323 | _onWSClose(evt) { 324 | debug('[_onWSClose] called with wsURI:', this.wsURI) 325 | this.socket.removeEventListener('message', this.onWSMessage) 326 | this.socket.removeEventListener('open', this.onWSOpen) 327 | this.socket.removeEventListener('error', this.onWSError) 328 | this.socket.removeEventListener('close', this.onWSClose) 329 | 330 | this.connected = false 331 | this.isConnecting = false 332 | this.socket = null 333 | 334 | this.emit('disconnect', evt) 335 | this.backOffAndReconnect() 336 | } 337 | 338 | unsubscribe() { 339 | if (this.connected !== true || this.socket === null) { 340 | debug('Not connected to socket') 341 | return 342 | } 343 | 344 | this.send( 345 | JSON.stringify({ 346 | context: '*', 347 | unsubscribe: [ 348 | { 349 | path: '*', 350 | }, 351 | ], 352 | }) 353 | ) 354 | } 355 | 356 | subscribe(subscriptions = []) { 357 | if (!Array.isArray(subscriptions) && subscriptions && typeof subscriptions === 'object' && subscriptions.hasOwnProperty('subscribe')) { 358 | subscriptions = [subscriptions] 359 | } 360 | 361 | subscriptions.forEach((sub) => { 362 | this.send(JSON.stringify(sub)) 363 | }) 364 | } 365 | 366 | send(data) { 367 | if (this.connected !== true || this.socket === null) { 368 | return Promise.reject(new Error('Not connected to WebSocket')) 369 | } 370 | 371 | // Basic check if data is stringified JSON 372 | if (typeof data === 'string') { 373 | try { 374 | data = JSON.parse(data) 375 | } catch (e) { 376 | debug(`[send] data is string but not valid JSON: ${e.message}`) 377 | } 378 | } 379 | 380 | const isObj = data && typeof data === 'object' 381 | 382 | // FIXME: this shouldn't be required as per discussion about security. 383 | // Add token to data IF authenticated 384 | // https://signalk.org/specification/1.3.0/doc/security.html#other-clients 385 | // if (isObj && this.useAuthentication === true && this._authenticated === true) { 386 | // data.token = String(this._token.token) 387 | // } 388 | 389 | try { 390 | if (isObj) { 391 | data = JSON.stringify(data) 392 | } 393 | } catch (e) { 394 | return Promise.reject(e) 395 | } 396 | 397 | debug(`Sending data to socket: ${data}`) 398 | const result = this.socket.send(data) 399 | return Promise.resolve(result) 400 | } 401 | 402 | fetch(path, opts) { 403 | if (path.charAt(0) !== '/') { 404 | path = `/${path}` 405 | } 406 | 407 | if (!opts || typeof opts !== 'object') { 408 | opts = { 409 | method: 'GET', 410 | } 411 | } 412 | 413 | if (!opts.headers || typeof opts.headers !== 'object') { 414 | opts.headers = { 415 | Accept: 'application/json', 416 | 'Content-Type': 'application/json', 417 | } 418 | } 419 | 420 | if (this._authenticated === true && !path.includes('auth/login')) { 421 | opts.headers = { 422 | ...opts.headers, 423 | Authorization: `${this._token.kind} ${this._token.token}`, 424 | } 425 | 426 | opts.credentials = 'same-origin' 427 | opts.mode = 'cors' 428 | 429 | debug(`[fetch] enriching fetch options with in-memory token`) 430 | } 431 | 432 | if (isNode && this.options.useTLS && this.options.rejectUnauthorized === false) { 433 | opts.agent = new https.Agent({ rejectUnauthorized: false }) 434 | } 435 | 436 | let URI = `${this.httpURI}${path}` 437 | 438 | // @TODO httpURI includes /api, which is not desirable. Need to refactor 439 | if (URI.includes('/api/auth/login')) { 440 | URI = URI.replace('/api/auth/login', '/auth/login') 441 | } 442 | 443 | // @TODO httpURI includes /api, which is not desirable. Need to refactor 444 | if (URI.includes('/api/access/requests')) { 445 | URI = URI.replace('/api/access/requests', '/access/requests') 446 | } 447 | 448 | // @FIXME weird hack because node server paths for access requests are not standardised 449 | if (URI.includes('/signalk/v1/api/security')) { 450 | URI = URI.replace('/signalk/v1/api/security', '/security') 451 | } 452 | 453 | debug(`[fetch] ${opts.method || 'GET'} ${URI} ${JSON.stringify(opts, null, 2)}`) 454 | return fetch(URI, opts).then((response) => { 455 | if (!response.ok) { 456 | throw new Error(`Error fetching ${URI}: ${response.status} ${response.statusText}`) 457 | } 458 | 459 | const type = response.headers.get('content-type') 460 | 461 | if (type.includes('application/json')) { 462 | return response.json() 463 | } 464 | 465 | return response.text() 466 | }) 467 | } 468 | } 469 | 470 | const flattenSubscriptions = (subscriptionCommands) => { 471 | const commandPerContext = {} 472 | 473 | subscriptionCommands.forEach((command) => { 474 | if (!Array.isArray(commandPerContext[command.context])) { 475 | commandPerContext[command.context] = [] 476 | } 477 | 478 | commandPerContext[command.context] = commandPerContext[command.context].concat(command.subscribe) 479 | }) 480 | 481 | return Object.keys(commandPerContext).map((context) => { 482 | const subscription = { 483 | context, 484 | subscribe: commandPerContext[context], 485 | } 486 | 487 | if (subscription.subscribe.length > 0) { 488 | const paths = [] 489 | subscription.subscribe = subscription.subscribe.reduce((list, command) => { 490 | if (!paths.includes(command.path)) { 491 | paths.push(command.path) 492 | } else { 493 | const index = list.findIndex((candidate) => candidate.path === command.path) 494 | if (index !== -1) { 495 | list.splice(index, 1) 496 | } 497 | } 498 | 499 | list.push(command) 500 | return list 501 | }, []) 502 | } 503 | 504 | return subscription 505 | }) 506 | } 507 | -------------------------------------------------------------------------------- /src/lib/discovery.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @description A Discovery takes an mDNS instance and discovers Signal K 3 | * servers on the local network. 4 | * @author Fabian Tollenaar 5 | * @copyright 2018-2019, Fabian Tollenaar. All rights reserved. 6 | * @license Apache-2.0 7 | * @module @signalk/signalk-js-sdk 8 | */ 9 | 10 | import EventEmitter from 'eventemitter3' 11 | import Client from './client' 12 | 13 | export class SKServer { 14 | constructor(service) { 15 | this._roles = service.roles || ['master', 'main'] 16 | this._self = service.self || '' 17 | this._version = service.version || '0.0.0' 18 | this._hostname = service.hostname 19 | this._port = service.port 20 | } 21 | 22 | get roles() { 23 | return this._roles 24 | } 25 | 26 | get self() { 27 | return this._self 28 | } 29 | 30 | get version() { 31 | return this._version 32 | } 33 | 34 | get hostname() { 35 | return this._hostname 36 | } 37 | 38 | get port() { 39 | return this._port 40 | } 41 | 42 | isMain() { 43 | return this._roles.includes('main') 44 | } 45 | 46 | isMaster() { 47 | return this._roles.includes('master') 48 | } 49 | 50 | createClient(opts = {}) { 51 | return new Client({ 52 | ...opts, 53 | hostname: this._hostname, 54 | port: this._port, 55 | }) 56 | } 57 | } 58 | 59 | export default class Discovery extends EventEmitter { 60 | constructor(bonjourOrMdns, timeout = 60000) { 61 | super() 62 | 63 | this.found = [] 64 | 65 | if (!bonjourOrMdns || typeof bonjourOrMdns !== 'object') { 66 | throw new Error('No mDNS provider given') 67 | } 68 | 69 | const bonjourProps = ['_server', '_registry'].join(',') 70 | const mdnsProps = ['dns_sd', 'Advertisement', 'createAdvertisement', 'Browser'].join(',') 71 | 72 | if (Object.keys(bonjourOrMdns).join(',').startsWith(bonjourProps)) { 73 | return this.discoverWithBonjour(bonjourOrMdns, timeout) 74 | } 75 | 76 | if (Object.keys(bonjourOrMdns).join(',').startsWith(mdnsProps)) { 77 | return this.discoverWithMdns(bonjourOrMdns, timeout) 78 | } 79 | 80 | throw new Error('Unrecognized mDNS provider given') 81 | } 82 | 83 | discoverWithBonjour(bonjour, timeout) { 84 | const browser = bonjour.find({ type: 'signalk-http' }) 85 | 86 | browser.on('up', (ad) => 87 | this.handleDiscoveredService(ad, { 88 | ...ad.txt, 89 | name: ad.name || '', 90 | hostname: ad.host || '', 91 | port: parseInt(ad.port, 10), 92 | provider: 'bonjour', 93 | }) 94 | ) 95 | 96 | setTimeout(() => { 97 | if (this.found.length === 0) { 98 | this.emit('timeout') 99 | } 100 | 101 | browser.stop() 102 | }, timeout) 103 | 104 | browser.start() 105 | } 106 | 107 | discoverWithMdns(mDNS, timeout) { 108 | const browser = mDNS.createBrowser(mDNS.tcp('_signalk-http')) 109 | 110 | browser.on('serviceUp', (ad) => 111 | this.handleDiscoveredService(ad, { 112 | ...ad.txtRecord, 113 | hostname: ad.host || '', 114 | port: parseInt(ad.port, 10), 115 | provider: 'mdns', 116 | }) 117 | ) 118 | 119 | browser.on('error', (err) => this.handleDiscoveryError(err)) 120 | 121 | setTimeout(() => { 122 | if (this.found.length === 0) { 123 | this.emit('timeout') 124 | } 125 | 126 | browser.stop() 127 | }, timeout) 128 | 129 | browser.start() 130 | } 131 | 132 | handleDiscoveryError(err) { 133 | console.error(`Error during discovery: ${err.message}`) 134 | } 135 | 136 | handleDiscoveredService(ad, service) { 137 | if (typeof service.roles === 'string') { 138 | service.roles = service.roles.split(',').map((role) => role.trim().toLowerCase()) 139 | } 140 | 141 | service.roles = Array.isArray(service.roles) ? service.roles : [] 142 | 143 | let ipv4 = service.hostname 144 | 145 | if (Array.isArray(ad.addresses)) { 146 | ipv4 = ad.addresses.reduce((found, address) => { 147 | if (address && typeof address === 'string' && address.includes('.')) { 148 | found = address 149 | } 150 | return found 151 | }, service.hostname) 152 | } 153 | 154 | if (ipv4.trim() !== '') { 155 | service.hostname = ipv4 156 | } 157 | 158 | const server = new SKServer(service) 159 | this.found.push(server) 160 | this.emit('found', server) 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/lib/request.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @description A Request represents an asynchronous request to a Signal K server. 3 | * This class manages a single request and it's responses. 4 | * @author Fabian Tollenaar 5 | * @copyright 2018-2019, Fabian Tollenaar. All rights reserved. 6 | * @license Apache-2.0 7 | * @module @signalk/signalk-js-sdk 8 | */ 9 | 10 | import EventEmitter from 'eventemitter3' 11 | import Debug from 'debug' 12 | import { v4 as uuid } from 'uuid' 13 | const debug = Debug('signalk-js-sdk/Request') 14 | 15 | export default class Request extends EventEmitter { 16 | constructor (connection, name, body) { 17 | super() 18 | 19 | this.connection = connection 20 | this.requestId = uuid() 21 | this.name = name 22 | this.body = body 23 | this.responses = [] 24 | this.sent = false 25 | 26 | this.connection.on('message', message => { 27 | if (message && typeof message === 'object' && message.hasOwnProperty('requestId') && message.requestId === this.requestId) { 28 | this.addResponse(message) 29 | } 30 | }) 31 | } 32 | 33 | query () { 34 | const request = { 35 | requestId: this.requestId, 36 | query: true 37 | } 38 | 39 | debug(`Sending query: ${JSON.stringify(request, null, 2)}`) 40 | this.connection.send(request) 41 | } 42 | 43 | send () { 44 | if (this.sent === true) { 45 | return 46 | } 47 | 48 | const request = { 49 | requestId: this.requestId, 50 | ...this.body 51 | } 52 | 53 | debug(`Sending request: ${JSON.stringify(request, null, 2)}`) 54 | this.connection.send(request) 55 | } 56 | 57 | addResponse (response) { 58 | debug(`Got response for request "${this.name}": ${JSON.stringify(response, null, 2)}`) 59 | const receivedAt = new Date().toISOString() 60 | 61 | this.responses.push({ 62 | response, 63 | receivedAt 64 | }) 65 | 66 | this.emit('response', { 67 | ...response, 68 | request: { 69 | receivedAt, 70 | name: this.name, 71 | requestId: this.requestId 72 | } 73 | }) 74 | } 75 | 76 | getRequestId () { 77 | return this.requestId 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @description Tests for signalk-js-sdk. Also useful for spec-testing a SK server. 3 | * @author Fabian Tollenaar 4 | * @copyright 2018-2019, Fabian Tollenaar. All rights reserved. 5 | * @license Apache-2.0 6 | * @module @signalk/signalk-js-sdk 7 | */ 8 | 9 | import Client, { Discovery, Client as NamedClient, PERMISSIONS_READONLY } from '../src' 10 | 11 | import Bonjour from 'bonjour' 12 | import mdns from 'mdns' 13 | import { assert } from 'chai' 14 | import { v4 as uuid } from 'uuid' 15 | import Server from 'signalk-server' 16 | import freeport from 'freeport-promise' 17 | 18 | const isObject = (mixed, prop, propIsObject) => { 19 | const _isObj = mixed && typeof mixed === 'object' 20 | 21 | if (!_isObj) { 22 | return false 23 | } 24 | 25 | if (typeof prop === 'string' && typeof propIsObject === 'boolean') { 26 | const _propIsObj = mixed[prop] && typeof mixed[prop] === 'object' 27 | return _isObj && mixed.hasOwnProperty(prop) && _propIsObj === propIsObject 28 | } 29 | 30 | if (typeof prop === 'string') { 31 | return _isObj && mixed.hasOwnProperty(prop) 32 | } 33 | 34 | return _isObj 35 | } 36 | 37 | const getPathsFromDelta = (delta, paths = []) => { 38 | if (!delta || typeof delta !== 'object' || !Array.isArray(delta.updates)) { 39 | return paths 40 | } 41 | 42 | delta.updates.forEach((update) => { 43 | if (update && typeof update === 'object' && Array.isArray(update.values)) { 44 | update.values.forEach((mut) => { 45 | if (!paths.includes(mut.path)) { 46 | paths.push(mut.path) 47 | } 48 | }) 49 | } 50 | }) 51 | 52 | return paths 53 | } 54 | 55 | const USER = 'sdk' 56 | const PASSWORD = 'signalk' 57 | const BEARER_TOKEN_PREFIX = 'JWT' 58 | 59 | let TEST_SERVER_HOSTNAME = process.env.TEST_SERVER_HOSTNAME 60 | let TEST_SERVER_PORT = process.env.TEST_SERVER_PORT 61 | let serverApp 62 | 63 | const securityConfig = { 64 | allow_readonly: false, 65 | expiration: '1d', 66 | secretKey: 67 | '3c2eddf95ece9080518eb777b26c0fa6285f107cccb5ff9d5bdd7776eeb82c8afaf0dffa7d9312936882351ec6b1d5535203b4a2b806ab130cdbcd917f46f2a69e7ff4548ca3644c97a98185284041de46518cdb026f85430532fa4482882e4cfd08cc0256dca88d0ca2577b91d6a435a832e6c600b2db13f794d087e5e3a181d9566c1e61a14f984dbc643a7f6ab6a60cafafff34c93475d442475136cf7f0bfb62c59b050a9be572bc26993c46ef05fa748dc8395277eaa07519d79a7bc12502a2429b2f89b78796f6dcf3f474a5c5e276ecbb59dcdceaa8df8f1b1f98ec23a4c36cc1334e07e06a8c8cd6671fee599e578d24aabd187d1a2903ae6facb090', 68 | users: [ 69 | { 70 | username: 'sdk', 71 | type: 'admin', 72 | password: '$2a$10$JyzSM5PMD3PCyivdtSN61OfwmjfgdISVtJ1l5KIC8/R1sUHPseMU2', 73 | }, 74 | ], 75 | devices: [], 76 | immutableConfig: false, 77 | acls: [], 78 | allowDeviceAccessRequests: true, 79 | allowNewUserRegistration: true, 80 | } 81 | 82 | function startServer(done = () => {}) { 83 | TEST_SERVER_HOSTNAME = 'localhost' 84 | 85 | let promise 86 | 87 | if (!TEST_SERVER_PORT) { 88 | promise = freeport() 89 | } else { 90 | promise = Promise.resolve(TEST_SERVER_PORT) 91 | } 92 | 93 | promise.then((port) => { 94 | TEST_SERVER_PORT = port 95 | serverApp = new Server({ 96 | config: { 97 | settings: { 98 | port, 99 | interfaces: { 100 | plugins: false, 101 | }, 102 | security: { 103 | strategy: './tokensecurity', 104 | }, 105 | }, 106 | }, 107 | securityConfig: securityConfig, 108 | }) 109 | serverApp.start().then(() => done()) 110 | }) 111 | } 112 | 113 | function killServer(done = () => {}) { 114 | if (!serverApp) { 115 | return done() 116 | } 117 | 118 | serverApp.stop().then(() => done()) 119 | } 120 | 121 | describe('Signal K SDK', () => { 122 | before((done) => { 123 | if (TEST_SERVER_HOSTNAME) { 124 | done() 125 | } else { 126 | startServer(() => { 127 | console.log('STARTED SERVER') 128 | done() 129 | }) 130 | } 131 | }) 132 | 133 | // @TODO requesting access should be expanded into a small class to manage the entire flow (including polling) 134 | describe('Device access requests', () => { 135 | it('... successfully requests device access', (done) => { 136 | const clientId = uuid() 137 | const client = new Client({ 138 | hostname: TEST_SERVER_HOSTNAME, 139 | port: TEST_SERVER_PORT, 140 | useTLS: false, 141 | useAuthentication: true, 142 | username: USER, 143 | password: PASSWORD, 144 | reconnect: false, 145 | notifications: true, 146 | bearerTokenPrefix: BEARER_TOKEN_PREFIX, 147 | }) 148 | 149 | client.on('connect', () => { 150 | client 151 | .requestDeviceAccess('Top Secret Client', clientId) 152 | .then((result) => { 153 | client.disconnect() 154 | assert(isObject(result, 'response', true) && result.response.hasOwnProperty('state') && result.response.hasOwnProperty('requestId') && result.response.hasOwnProperty('href')) 155 | done() 156 | }) 157 | .catch((err) => done(err)) 158 | }) 159 | 160 | client.connect() 161 | }).timeout(30000) 162 | 163 | /* 164 | it('... receives an access request sent by some device', done => { 165 | let isDone = false 166 | const clientId = uuid() 167 | const client = new Client({ 168 | hostname: TEST_SERVER_HOSTNAME, 169 | port: TEST_SERVER_PORT, 170 | useTLS: false, 171 | useAuthentication: true, 172 | username: USER, 173 | password: PASSWORD, 174 | reconnect: false, 175 | notifications: true, 176 | bearerTokenPrefix: BEARER_TOKEN_PREFIX 177 | }) 178 | 179 | client.on('notification', notification => { 180 | if (isDone === false) { 181 | isDone = true 182 | assert( 183 | notification.path.includes('security.accessRequest') && 184 | notification.path.includes(clientId) 185 | ) 186 | client.disconnect() 187 | done() 188 | } 189 | }) 190 | 191 | client.on('connect', () => { 192 | client 193 | .requestDeviceAccess('Top Secret Client', clientId) 194 | .catch(err => done(err)) 195 | }) 196 | 197 | client.connect() 198 | }).timeout(30000) 199 | // */ 200 | 201 | // I don't understand why this suddenly doesn't work anymore. Is this buggy in server? 202 | it.skip('... FIXME: can respond to the access request notification sent by server', (done) => { 203 | let sent = false 204 | let connected = false 205 | 206 | const clientId = uuid() 207 | const client = new Client({ 208 | hostname: TEST_SERVER_HOSTNAME, 209 | port: TEST_SERVER_PORT, 210 | useTLS: false, 211 | useAuthentication: true, 212 | username: USER, 213 | password: PASSWORD, 214 | reconnect: false, 215 | notifications: true, 216 | bearerTokenPrefix: BEARER_TOKEN_PREFIX, 217 | }) 218 | 219 | client.on('connect', () => { 220 | connected = true 221 | }) 222 | 223 | client.on('notification', (notification) => { 224 | if (sent === false && notification.path.includes('security.accessRequest') && notification.path.includes(clientId)) { 225 | sent = true 226 | client 227 | .respondToAccessRequest(clientId, PERMISSIONS_READONLY) 228 | .then((result) => { 229 | assert(String(result).toLowerCase().includes('request updated') === true) // TODO: node server returns incorrect response here 230 | done() 231 | }) 232 | .catch((err) => done(err)) 233 | } 234 | }) 235 | 236 | client.on('connect', () => { 237 | setTimeout(() => { 238 | client.requestDeviceAccess('Top Secret Client', clientId).catch((err) => done(err)) 239 | }, 1500) 240 | }) 241 | 242 | client.connect() 243 | }).timeout(30000) 244 | }) 245 | 246 | describe('Authentication over WebSockets, using seperate mechanism', () => { 247 | it('... sends an authentication request with incorrect password, and receives the proper error code', (done) => { 248 | const client = new Client({ 249 | hostname: TEST_SERVER_HOSTNAME, 250 | port: TEST_SERVER_PORT, 251 | useTLS: false, 252 | reconnect: false, 253 | notifications: false, 254 | bearerTokenPrefix: BEARER_TOKEN_PREFIX, 255 | }) 256 | 257 | client.on('connect', () => { 258 | client.authenticate(USER, 'wrong!') 259 | client.once('error', (err) => { 260 | assert(err.message.includes('401')) 261 | done() 262 | }) 263 | }) 264 | 265 | client.connect() 266 | }).timeout(15000) 267 | 268 | it('... sends an authentication request and receives a well-formed response including token', (done) => { 269 | const client = new Client({ 270 | hostname: TEST_SERVER_HOSTNAME, 271 | port: TEST_SERVER_PORT, 272 | useTLS: false, 273 | reconnect: false, 274 | notifications: false, 275 | bearerTokenPrefix: BEARER_TOKEN_PREFIX, 276 | }) 277 | 278 | client.on('connect', () => { 279 | client.authenticate(USER, PASSWORD) 280 | client.once('authenticated', (data) => { 281 | assert(data && typeof data === 'object' && data.hasOwnProperty('token')) 282 | done() 283 | }) 284 | }) 285 | 286 | client.connect() 287 | }).timeout(15000) 288 | 289 | it('... successfully authenticates, and then can access resources via the REST API', (done) => { 290 | const client = new Client({ 291 | hostname: TEST_SERVER_HOSTNAME, 292 | port: TEST_SERVER_PORT, 293 | useTLS: false, 294 | reconnect: false, 295 | notifications: false, 296 | bearerTokenPrefix: BEARER_TOKEN_PREFIX, 297 | }) 298 | 299 | client.on('connect', () => { 300 | client.authenticate(USER, PASSWORD) 301 | client.once('authenticated', (data) => { 302 | client 303 | .API() 304 | .then((api) => api.self()) 305 | .then((result) => { 306 | assert(result && typeof result === 'object') 307 | done() 308 | }) 309 | .catch((err) => done(err)) 310 | }) 311 | }) 312 | 313 | client.connect() 314 | }).timeout(15000) 315 | }) 316 | 317 | describe('Request/response mechanics', () => { 318 | it('... sends a request and receives a well-formed response', (done) => { 319 | const client = new Client({ 320 | hostname: TEST_SERVER_HOSTNAME, 321 | port: TEST_SERVER_PORT, 322 | useTLS: false, 323 | useAuthentication: true, 324 | username: USER, 325 | password: PASSWORD, 326 | reconnect: false, 327 | notifications: false, 328 | bearerTokenPrefix: BEARER_TOKEN_PREFIX, 329 | }) 330 | 331 | client.on('connect', () => { 332 | const request = client.request('PUT', { 333 | put: { 334 | path: 'electrical.switches.anchorLight.state', 335 | value: 1, 336 | }, 337 | }) 338 | 339 | request.once('response', (response) => { 340 | assert(response && typeof response === 'object' && response.hasOwnProperty('requestId') && response.hasOwnProperty('state') && response.hasOwnProperty('statusCode') && (response.state === 'PENDING' || response.state === 'COMPLETED') && response.requestId === request.getRequestId()) 341 | done() 342 | }) 343 | 344 | request.send() 345 | }) 346 | 347 | client.connect() 348 | }).timeout(15000) 349 | 350 | // TODO: not yet implemented by Signal K node.js server, so this this would always fail 351 | it.skip('... TODO: sends a query for a request and receives a well-formed response (not yet implemented in Node server)', (done) => { 352 | const client = new Client({ 353 | hostname: TEST_SERVER_HOSTNAME, 354 | port: TEST_SERVER_PORT, 355 | useTLS: false, 356 | reconnect: false, 357 | notifications: false, 358 | bearerTokenPrefix: BEARER_TOKEN_PREFIX, 359 | }) 360 | 361 | let received = 0 362 | 363 | client.on('connect', () => { 364 | const request = client.request('LOGIN', { 365 | login: { 366 | username: USER, 367 | password: PASSWORD, 368 | }, 369 | }) 370 | 371 | request.on('response', (response) => { 372 | received += 1 373 | 374 | if (received === 1) { 375 | // Send query after initial response... 376 | request.query() 377 | } 378 | 379 | if (received > 1) { 380 | assert(response && typeof response === 'object' && response.hasOwnProperty('requestId') && response.hasOwnProperty('state') && response.hasOwnProperty('statusCode') && (response.state === 'PENDING' || response.state === 'COMPLETED') && response.requestId === request.getRequestId()) 381 | done() 382 | } 383 | }) 384 | 385 | request.send() 386 | }) 387 | 388 | client.connect() 389 | }).timeout(15000) 390 | }) 391 | 392 | describe('mDNS server discovery', () => { 393 | !process.env.TRAVIS && 394 | it('... Emits an event when a Signal K host is found', (done) => { 395 | let found = 0 396 | const bonjour = Bonjour() 397 | const discovery = new Discovery(bonjour, 10000) 398 | 399 | discovery.once('found', (server) => { 400 | found += 1 401 | assert(typeof server.hostname === 'string' && server.hostname !== '' && typeof server.port === 'number' && typeof server.createClient === 'function' && Array.isArray(server.roles) && server.createClient() instanceof Client) 402 | done() 403 | }) 404 | 405 | discovery.on('timeout', () => { 406 | if (found === 0) { 407 | done() 408 | } 409 | }) 410 | }).timeout(15000) 411 | }) 412 | 413 | describe('Delta stream behaviour & subscriptions', () => { 414 | it('... Streams own vessel (self) data when the behaviour is set to "null"', (done) => { 415 | const client = new Client({ 416 | hostname: 'demo.signalk.org', 417 | port: 80, 418 | useTLS: false, 419 | reconnect: false, 420 | notifications: false, 421 | bearerTokenPrefix: BEARER_TOKEN_PREFIX, 422 | deltaStreamBehaviour: null, 423 | }) 424 | 425 | let count = 0 426 | 427 | client.on('delta', (data) => { 428 | count += 1 429 | if (count < 5) { 430 | assert(data && typeof data === 'object' && data.hasOwnProperty('updates') && data.hasOwnProperty('context') && data.context === client.self) 431 | } else if (count === 5) { 432 | done() 433 | } 434 | }) 435 | 436 | client.connect().catch((err) => done(err)) 437 | }).timeout(20000) 438 | 439 | it('... Streams own vessel (self) data when the behaviour is set to "self"', (done) => { 440 | const client = new Client({ 441 | hostname: 'demo.signalk.org', 442 | port: 80, 443 | useTLS: false, 444 | reconnect: false, 445 | notifications: false, 446 | bearerTokenPrefix: BEARER_TOKEN_PREFIX, 447 | deltaStreamBehaviour: 'self', 448 | }) 449 | 450 | let count = 0 451 | 452 | client.on('delta', (data) => { 453 | count += 1 454 | if (count < 5) { 455 | assert(data && typeof data === 'object' && data.hasOwnProperty('updates') && data.hasOwnProperty('context') && data.context === client.self) 456 | } else if (count === 5) { 457 | done() 458 | } 459 | }) 460 | 461 | client.connect().catch((err) => done(err)) 462 | }).timeout(20000) 463 | 464 | it('... Streams data from multiple vessels when the behaviour is set to "all"', (done) => { 465 | const client = new Client({ 466 | hostname: 'demo.signalk.org', 467 | port: 80, 468 | useTLS: false, 469 | reconnect: false, 470 | notifications: false, 471 | bearerTokenPrefix: BEARER_TOKEN_PREFIX, 472 | deltaStreamBehaviour: 'all', 473 | }) 474 | 475 | let contexes = [] 476 | 477 | setTimeout(() => { 478 | assert(contexes.length > 2) 479 | done() 480 | }, 5000) 481 | 482 | client.on('delta', (data) => { 483 | if (!contexes.includes(data.context)) { 484 | contexes.push(data.context) 485 | } 486 | }) 487 | 488 | client.connect().catch((err) => done(err)) 489 | }).timeout(30000) 490 | 491 | it('... Creates a subscription for navigation data from own vessel', (done) => { 492 | const client = new Client({ 493 | hostname: 'demo.signalk.org', 494 | port: 80, 495 | useTLS: false, 496 | reconnect: false, 497 | notifications: false, 498 | bearerTokenPrefix: BEARER_TOKEN_PREFIX, 499 | subscriptions: [ 500 | { 501 | context: 'vessels.self', 502 | subscribe: [ 503 | { 504 | path: 'navigation.position', 505 | policy: 'instant', 506 | }, 507 | ], 508 | }, 509 | ], 510 | }) 511 | 512 | let count = 0 513 | 514 | client.on('delta', (data) => { 515 | count += 1 516 | 517 | if (count < 5) { 518 | const findPathInUpdate = (update) => { 519 | if (!Array.isArray(update.values)) { 520 | return false 521 | } 522 | 523 | const found = update.values.find((mut) => mut.path === 'navigation.position') 524 | return found && typeof found === 'object' 525 | } 526 | 527 | let hasPath = false 528 | 529 | try { 530 | const search = data.updates.find(findPathInUpdate) 531 | hasPath = search && typeof search === 'object' 532 | } catch (e) { 533 | hasPath = false 534 | } 535 | 536 | assert(data && typeof data === 'object' && data.hasOwnProperty('updates') && data.hasOwnProperty('context') && hasPath && data.context === client.self) 537 | } else if (count === 5) { 538 | done() 539 | } 540 | }) 541 | 542 | client.connect().catch((err) => done(err)) 543 | }).timeout(30000) 544 | 545 | it('... Creates multiple subscriptions when notifications = true and the subscription pertains multiple vessels', (done) => { 546 | const client = new Client({ 547 | hostname: 'demo.signalk.org', 548 | port: 80, 549 | useTLS: false, 550 | reconnect: false, 551 | notifications: true, 552 | bearerTokenPrefix: BEARER_TOKEN_PREFIX, 553 | subscriptions: [ 554 | { 555 | context: 'vessels.*', 556 | subscribe: [ 557 | { 558 | path: 'navigation.position', 559 | policy: 'instant', 560 | }, 561 | ], 562 | }, 563 | ], 564 | }) 565 | 566 | const pathsFound = [] 567 | let isDone = false 568 | 569 | client.on('delta', (data) => { 570 | if (isDone === true) { 571 | return 572 | } 573 | 574 | if (pathsFound.includes('notifications.*') && pathsFound.includes('navigation.position')) { 575 | assert(pathsFound.includes('notifications.*') === true && pathsFound.includes('navigation.position') === true) 576 | 577 | isDone = true 578 | return done() 579 | } 580 | 581 | if (data && typeof data === 'object' && Array.isArray(data.updates)) { 582 | data.updates.forEach((update) => { 583 | update.values.forEach((mut) => { 584 | if (pathsFound.includes(mut.path)) { 585 | return 586 | } 587 | 588 | if (!mut.path.includes('notifications.')) { 589 | pathsFound.push(mut.path) 590 | } 591 | 592 | if (mut.path.includes('notifications.') && data.context === client.self && !pathsFound.includes('notifications.*')) { 593 | pathsFound.push('notifications.*') 594 | } 595 | }) 596 | }) 597 | } 598 | }) 599 | 600 | client.on('connect', () => { 601 | client.requestDeviceAccess('Top Secret Client', uuid()).catch((err) => done(err)) 602 | }) 603 | 604 | client.connect().catch((err) => done(err)) 605 | }).timeout(30000) 606 | 607 | it('... Modifies the delta stream subscription after initialisation', (done) => { 608 | const client = new Client({ 609 | hostname: 'demo.signalk.org', 610 | port: 80, 611 | useTLS: false, 612 | reconnect: false, 613 | notifications: false, 614 | bearerTokenPrefix: BEARER_TOKEN_PREFIX, 615 | }) 616 | 617 | let count = 0 618 | 619 | client.on('delta', (data) => { 620 | count += 1 621 | 622 | if (count < 5) { 623 | const findPathInUpdate = (update) => { 624 | if (!Array.isArray(update.values)) { 625 | return false 626 | } 627 | 628 | const found = update.values.find((mut) => mut.path === 'navigation.position') 629 | return found && typeof found === 'object' 630 | } 631 | 632 | let hasPath = false 633 | 634 | try { 635 | const search = data.updates.find(findPathInUpdate) 636 | hasPath = search && typeof search === 'object' 637 | } catch (e) { 638 | hasPath = false 639 | } 640 | 641 | assert(data && typeof data === 'object' && data.hasOwnProperty('updates') && data.hasOwnProperty('context') && hasPath && data.context === client.self) 642 | } else if (count === 5) { 643 | done() 644 | } 645 | }) 646 | 647 | client.on('connect', () => { 648 | client.subscribe({ 649 | context: 'vessels.self', 650 | subscribe: [ 651 | { 652 | path: 'navigation.position', 653 | policy: 'instant', 654 | }, 655 | ], 656 | }) 657 | }) 658 | 659 | client.connect().catch((err) => done(err)) 660 | }).timeout(30000) 661 | 662 | it('... Handles multiple subscribe calls correctly', (done) => { 663 | const client = new Client({ 664 | hostname: 'demo.signalk.org', 665 | port: 80, 666 | useTLS: false, 667 | reconnect: false, 668 | notifications: false, 669 | bearerTokenPrefix: BEARER_TOKEN_PREFIX, 670 | }) 671 | 672 | const paths = [] 673 | let isDone = false 674 | 675 | client.on('delta', (delta) => { 676 | if (isDone === true) { 677 | return 678 | } 679 | 680 | isDone = paths.includes('navigation.position') && paths.includes('environment.wind.angleApparent') 681 | 682 | if (isDone === true) { 683 | assert(isDone === true) 684 | return done() 685 | } 686 | 687 | getPathsFromDelta(delta, paths) 688 | }) 689 | 690 | client.on('connect', () => { 691 | client.subscribe({ 692 | context: 'vessels.self', 693 | subscribe: [ 694 | { 695 | path: 'navigation.position', 696 | policy: 'instant', 697 | }, 698 | ], 699 | }) 700 | 701 | client.subscribe({ 702 | context: 'vessels.self', 703 | subscribe: [ 704 | { 705 | path: 'environment.wind.angleApparent', 706 | policy: 'instant', 707 | }, 708 | ], 709 | }) 710 | }) 711 | 712 | client.connect().catch((err) => done(err)) 713 | }).timeout(30000) 714 | 715 | it('... Handles subscribes, then unsubscribes', (done) => { 716 | const client = new Client({ 717 | hostname: 'demo.signalk.org', 718 | port: 80, 719 | useTLS: false, 720 | reconnect: false, 721 | notifications: false, 722 | bearerTokenPrefix: BEARER_TOKEN_PREFIX, 723 | }) 724 | 725 | const paths = [] 726 | let isDone = false 727 | let countAtUnsubscribe = -1 728 | 729 | client.on('delta', (delta) => { 730 | if (isDone === true) { 731 | return 732 | } 733 | 734 | getPathsFromDelta(delta, paths) 735 | 736 | if (countAtUnsubscribe === -1 && paths.length > 0) { 737 | client.unsubscribe() 738 | countAtUnsubscribe = paths.length 739 | 740 | setTimeout(() => { 741 | isDone = true 742 | assert(countAtUnsubscribe === paths.length) 743 | done() 744 | }, 1500) 745 | } 746 | }) 747 | 748 | client.on('connect', () => { 749 | client.subscribe({ 750 | context: 'vessels.self', 751 | subscribe: [ 752 | { 753 | path: 'navigation.position', 754 | policy: 'instant', 755 | }, 756 | ], 757 | }) 758 | }) 759 | 760 | client.connect().catch((err) => done(err)) 761 | }).timeout(30000) 762 | 763 | it.skip('... @TODO: Unsubscribes, after subscribing using a behaviour modifier (not supported by server)', (done) => { 764 | const client = new Client({ 765 | hostname: 'demo.signalk.org', 766 | port: 80, 767 | useTLS: false, 768 | reconnect: false, 769 | notifications: false, 770 | bearerTokenPrefix: BEARER_TOKEN_PREFIX, 771 | deltaStreamBehaviour: null, 772 | }) 773 | 774 | const paths = [] 775 | let isDone = false 776 | let countAtUnsubscribe = -1 777 | 778 | client.on('delta', (delta) => { 779 | if (isDone === true) { 780 | return 781 | } 782 | 783 | getPathsFromDelta(delta, paths) 784 | 785 | if (countAtUnsubscribe === -1 && paths.length > 0) { 786 | client.unsubscribe() 787 | countAtUnsubscribe = paths.length 788 | 789 | setTimeout(() => { 790 | isDone = true 791 | assert(countAtUnsubscribe === paths.length, `No. of recorded paths (${paths.length}) doesn't match count when unsubscribe() was called (${countAtUnsubscribe})`) 792 | done() 793 | }, 1500) 794 | } 795 | }) 796 | 797 | client.connect().catch((err) => done(err)) 798 | }).timeout(30000) 799 | 800 | it('... Streams no data when the behaviour is set to "none"', (done) => { 801 | const client = new Client({ 802 | hostname: 'demo.signalk.org', 803 | port: 80, 804 | useTLS: false, 805 | reconnect: false, 806 | notifications: false, 807 | bearerTokenPrefix: BEARER_TOKEN_PREFIX, 808 | deltaStreamBehaviour: 'none', 809 | }) 810 | 811 | let count = 0 812 | let isDone = false 813 | let timeout = setTimeout(() => { 814 | if (isDone === true) { 815 | return 816 | } 817 | 818 | assert(count === 0) 819 | isDone = true 820 | done() 821 | }, 10000) 822 | 823 | client.on('delta', (data) => { 824 | if (isDone === false) { 825 | count += 1 826 | } 827 | 828 | if (timeout) { 829 | clearTimeout(timeout) 830 | timeout = null 831 | isDone = true 832 | done(new Error('A delta was received, despite deltaStreamBehaviour being set to "none"')) 833 | } 834 | }) 835 | 836 | client.connect().catch((err) => done(err)) 837 | }).timeout(20000) 838 | 839 | it('... Send keepalived in WS every 0.5s and wait for 10 delta update "navigation.datetime"', (done) => { 840 | const client = new Client({ 841 | hostname: 'demo.signalk.org', 842 | port: 80, 843 | useTLS: false, 844 | reconnect: false, 845 | notifications: false, 846 | bearerTokenPrefix: BEARER_TOKEN_PREFIX, 847 | deltaStreamBehaviour: 'none', 848 | wsKeepaliveInterval: 0.5 849 | }) 850 | 851 | let count = 0 852 | 853 | client.on('connect', () => { 854 | client.subscribe({ 855 | context: 'vessels.self', 856 | subscribe: [ 857 | { 858 | path: 'navigation.datetime', 859 | policy: 'instant', 860 | }, 861 | ], 862 | }) 863 | }) 864 | 865 | client.on('delta', (data) => { 866 | count += 1 867 | 868 | if (count < 10) { 869 | const findPathInUpdate = (update) => { 870 | if (!Array.isArray(update.values)) { 871 | return false 872 | } 873 | 874 | const found = update.values.find((mut) => mut.path === 'navigation.datetime') 875 | return found && typeof found === 'object' 876 | } 877 | 878 | let hasPath = false 879 | 880 | try { 881 | const search = data.updates.find(findPathInUpdate) 882 | hasPath = search && typeof search === 'object' 883 | } catch (e) { 884 | hasPath = false 885 | } 886 | 887 | assert(data && typeof data === 'object' && data.hasOwnProperty('updates') && data.hasOwnProperty('context') && hasPath && data.context === client.self) 888 | } else if (count === 10) { 889 | done() 890 | } 891 | }) 892 | 893 | client.connect().catch((err) => done(err)) 894 | }).timeout(20000) 895 | }) 896 | 897 | 898 | describe('Notifications', () => { 899 | it('... Connects and receives notifications', (done) => { 900 | const clientId = uuid() 901 | const client = new Client({ 902 | hostname: TEST_SERVER_HOSTNAME, 903 | port: TEST_SERVER_PORT, 904 | useTLS: false, 905 | autoConnect: false, 906 | notifications: true, 907 | useAuthentication: true, 908 | username: USER, 909 | password: PASSWORD, 910 | bearerTokenPrefix: BEARER_TOKEN_PREFIX, 911 | }) 912 | 913 | client.once('notification', (notification) => { 914 | assert(notification && typeof notification === 'object' && notification.hasOwnProperty('path') && notification.path.includes('security.')) 915 | done() 916 | }) 917 | 918 | client.on('connect', () => { 919 | setTimeout(() => { 920 | // Force the sending of a notification 921 | client.requestDeviceAccess('Top Secret Client', clientId).catch((err) => { 922 | console.log('ERROR', err.message) 923 | console.log(err.stack) 924 | 925 | done(err) 926 | }) 927 | }, 500) 928 | }) 929 | 930 | client.connect() 931 | }).timeout(60000) 932 | }) 933 | 934 | describe('Metadata', () => { 935 | it('... Connects and receives metadata', (done) => { 936 | const clientId = uuid() 937 | const client = new Client({ 938 | hostname: 'demo.signalk.org', 939 | port: 80, 940 | useTLS: false, 941 | reconnect: false, 942 | notifications: false, 943 | bearerTokenPrefix: BEARER_TOKEN_PREFIX, 944 | deltaStreamBehaviour: 'self', 945 | sendMeta: 'all', 946 | }) 947 | 948 | let count = 0 949 | let isDone = false; 950 | let timeout = setTimeout(() => { 951 | assert(count > 1, 'No Metadata received') 952 | }, 3000) 953 | 954 | client.on('delta', (data) => { 955 | assert(data && typeof data === 'object' && data.hasOwnProperty('updates')) 956 | if (data && typeof data === 'object' && Array.isArray(data.updates)) { 957 | data.updates.forEach((update) => { 958 | if(update.hasOwnProperty('meta') && Array.isArray(update.meta)) { 959 | count += 1; 960 | } 961 | }) 962 | if(count > 0 && !isDone) { 963 | isDone = true 964 | done() 965 | } 966 | } 967 | }) 968 | 969 | client.connect().catch((err) => done(err)) 970 | }).timeout(60000) 971 | }) 972 | 973 | describe('REST API', () => { 974 | let client 975 | 976 | before(() => { 977 | client = new Client({ 978 | hostname: TEST_SERVER_HOSTNAME, 979 | port: TEST_SERVER_PORT, 980 | useAuthentication: true, 981 | username: USER, 982 | password: PASSWORD, 983 | useTLS: false, 984 | reconnect: false, 985 | autoConnect: true, 986 | notifications: false, 987 | bearerTokenPrefix: BEARER_TOKEN_PREFIX, 988 | }) 989 | }) 990 | 991 | const groups = ['communication', 'design', 'electrical', 'environment', 'navigation', 'notifications', 'performance', 'propulsion', 'sails', 'sensors', 'steering', 'tanks'] 992 | 993 | groups.forEach((group) => { 994 | it(`... Fetches "self/${group}" successfully`, (done) => { 995 | client 996 | .API() 997 | .then((api) => api[group]()) 998 | .then((result) => { 999 | assert(result && typeof result === 'object') 1000 | done() 1001 | }) 1002 | .catch((err) => { 1003 | // 404 means successful request, but the data isn't present on the vessel 1004 | if (err.message.includes('404')) { 1005 | return done() 1006 | } 1007 | 1008 | done(err) 1009 | }) 1010 | }) 1011 | }) 1012 | 1013 | it.skip('... @FIXME Successfully completes a PUT request for a given path', (done) => { 1014 | client 1015 | .API() 1016 | .then((api) => api.put('/vessels/self/environment/depth/belowTransducer', { value: 100 })) 1017 | .then((result) => { 1018 | console.log(result) 1019 | done() 1020 | }) 1021 | .catch((err) => done(err)) 1022 | }) 1023 | 1024 | it.skip('... @FIXME Fails to complete a PUT request for an unknown path', (done) => { 1025 | client 1026 | .API() 1027 | .then((api) => api.put('/vessels/self/environment/depth/belowTransducer', { value: 100 })) 1028 | .then((result) => { 1029 | console.log(result) 1030 | done() 1031 | }) 1032 | .catch((err) => done(err)) 1033 | }) 1034 | 1035 | it('... Fetches meta data by path successfully', (done) => { 1036 | client 1037 | .API() 1038 | .then((api) => api.getMeta('vessels.self.navigation.position')) 1039 | .then((result) => { 1040 | assert(result && typeof result === 'object') 1041 | done() 1042 | }) 1043 | .catch((err) => { 1044 | // 404 means successful request, but the data isn't present on the vessel 1045 | if (err.message.includes('404')) { 1046 | return done() 1047 | } 1048 | 1049 | done(err) 1050 | }) 1051 | }) 1052 | 1053 | it('... Fetches position data by path successfully, using dot notation', (done) => { 1054 | client 1055 | .API() 1056 | .then((api) => api.get('vessels.self.navigation.position')) 1057 | .then((result) => { 1058 | assert(result && typeof result === 'object') 1059 | done() 1060 | }) 1061 | .catch((err) => { 1062 | // 404 means successful request, but the data isn't present on the vessel 1063 | if (err.message.includes('404')) { 1064 | return done() 1065 | } 1066 | 1067 | done(err) 1068 | }) 1069 | }) 1070 | 1071 | it('... Fetches position data by path successfully, using forward slashes', (done) => { 1072 | client 1073 | .API() 1074 | .then((api) => api.get('/vessels/self/navigation/position')) 1075 | .then((result) => { 1076 | assert(result && typeof result === 'object') 1077 | done() 1078 | }) 1079 | .catch((err) => { 1080 | // 404 means successful request, but the data isn't present on the vessel 1081 | if (err.message.includes('404')) { 1082 | return done() 1083 | } 1084 | 1085 | done(err) 1086 | }) 1087 | }) 1088 | 1089 | it('... Fetches server version successfully', (done) => { 1090 | client 1091 | .API() 1092 | .then((api) => api.version()) 1093 | .then((result) => { 1094 | assert(typeof result === 'string') 1095 | done() 1096 | }) 1097 | .catch((err) => { 1098 | // 404 means successful request, but the data isn't present on the vessel 1099 | if (err.message.includes('404')) { 1100 | return done() 1101 | } 1102 | 1103 | done(err) 1104 | }) 1105 | }) 1106 | 1107 | it('... Fetches the vessels\' "name" successfully', (done) => { 1108 | client 1109 | .API() 1110 | .then((api) => api.name()) 1111 | .then((result) => { 1112 | assert(typeof result === 'string') 1113 | done() 1114 | }) 1115 | .catch((err) => { 1116 | // 404 means successful request, but the data isn't present on the vessel 1117 | if (err.message.includes('404')) { 1118 | return done() 1119 | } 1120 | 1121 | done(err) 1122 | }) 1123 | }) 1124 | 1125 | it('... Fetches "self" successfully', (done) => { 1126 | client 1127 | .API() 1128 | .then((api) => api.self()) 1129 | .then((result) => { 1130 | assert(result && typeof result === 'object') 1131 | done() 1132 | }) 1133 | .catch((err) => { 1134 | // 404 means successful request, but the data isn't present on the vessel 1135 | if (err.message.includes('404')) { 1136 | return done() 1137 | } 1138 | 1139 | done(err) 1140 | }) 1141 | }) 1142 | 1143 | it('... Fetches "vessels" successfully', (done) => { 1144 | client 1145 | .API() 1146 | .then((api) => api.vessels()) 1147 | .then((result) => { 1148 | assert(result && typeof result === 'object') 1149 | done() 1150 | }) 1151 | .catch((err) => { 1152 | // 404 means successful request, but the data isn't present on the vessel 1153 | if (err.message.includes('404')) { 1154 | return done() 1155 | } 1156 | 1157 | done(err) 1158 | }) 1159 | }) 1160 | 1161 | it('... Fetches "aircraft" successfully', (done) => { 1162 | client 1163 | .API() 1164 | .then((api) => api.aircraft()) 1165 | .then((result) => { 1166 | assert(result && typeof result === 'object') 1167 | done() 1168 | }) 1169 | .catch((err) => { 1170 | // 404 means successful request, but the data isn't present on the vessel 1171 | if (err.message.includes('404')) { 1172 | return done() 1173 | } 1174 | 1175 | done(err) 1176 | }) 1177 | }) 1178 | 1179 | it('... Fetches "aton" successfully', (done) => { 1180 | client 1181 | .API() 1182 | .then((api) => api.aton()) 1183 | .then((result) => { 1184 | assert(result && typeof result === 'object') 1185 | done() 1186 | }) 1187 | .catch((err) => { 1188 | // 404 means successful request, but the data isn't present on the vessel 1189 | if (err.message.includes('404')) { 1190 | return done() 1191 | } 1192 | 1193 | done(err) 1194 | }) 1195 | }) 1196 | 1197 | it('... Fetches "sar" successfully', (done) => { 1198 | client 1199 | .API() 1200 | .then((api) => api.sar()) 1201 | .then((result) => { 1202 | assert(result && typeof result === 'object') 1203 | done() 1204 | }) 1205 | .catch((err) => { 1206 | // 404 means successful request, but the data isn't present on the vessel 1207 | if (err.message.includes('404')) { 1208 | return done() 1209 | } 1210 | 1211 | done(err) 1212 | }) 1213 | }) 1214 | 1215 | it('... Fetches the vessel MRN successfully', (done) => { 1216 | client 1217 | .API() 1218 | .then((api) => api.mrn()) 1219 | .then((result) => { 1220 | assert(typeof result === 'string') 1221 | done() 1222 | }) 1223 | .catch((err) => { 1224 | // 404 means successful request, but the data isn't present on the vessel 1225 | if (err.message.includes('404')) { 1226 | return done() 1227 | } 1228 | 1229 | done(err) 1230 | }) 1231 | }) 1232 | 1233 | it('... Fetches "sources" successfully', (done) => { 1234 | client 1235 | .API() 1236 | .then((api) => api.sources()) 1237 | .then((result) => { 1238 | assert(result && typeof result === 'object') 1239 | done() 1240 | }) 1241 | .catch((err) => { 1242 | // 404 means successful request, but the data isn't present on the vessel 1243 | if (err.message.includes('404')) { 1244 | return done() 1245 | } 1246 | 1247 | done(err) 1248 | }) 1249 | }) 1250 | 1251 | it('... Fetches "resources" successfully', (done) => { 1252 | client 1253 | .API() 1254 | .then((api) => api.resources()) 1255 | .then((result) => { 1256 | assert(result && typeof result === 'object') 1257 | done() 1258 | }) 1259 | .catch((err) => { 1260 | // 404 means successful request, but the data isn't present on the vessel 1261 | if (err.message.includes('404')) { 1262 | return done() 1263 | } 1264 | 1265 | done(err) 1266 | }) 1267 | }) 1268 | }) 1269 | 1270 | describe('Module API', () => { 1271 | it('... exports a Signal K Client as a named constant and the default export', (done) => { 1272 | assert(Client === NamedClient) 1273 | done() 1274 | }) 1275 | 1276 | it('... successfully instantiates a Client with default options', (done) => { 1277 | const client = new Client() 1278 | assert(client.options.hostname === 'localhost') 1279 | assert(client.options.port === 3000) 1280 | assert(client.options.useTLS === true) 1281 | assert(client.options.version === 'v1') 1282 | assert(client.options.autoConnect === false) 1283 | done() 1284 | }) 1285 | 1286 | it('... instantiates a Client with custom options', (done) => { 1287 | const client = new Client({ hostname: 'signalk.org' }) 1288 | assert(client.options.hostname === 'signalk.org') 1289 | done() 1290 | }) 1291 | 1292 | it('... Client is an EventEmitter', (done) => { 1293 | const client = new Client() 1294 | assert(typeof client.on === 'function') 1295 | done() 1296 | }) 1297 | }) 1298 | 1299 | describe('Connection', () => { 1300 | it('... Successfully closes the connection and any connection attempts when "disconnect" is called', (done) => { 1301 | let client = new Client({ 1302 | hostname: TEST_SERVER_HOSTNAME, 1303 | port: TEST_SERVER_PORT, 1304 | useTLS: false, 1305 | autoConnect: true, 1306 | notifications: false, 1307 | bearerTokenPrefix: BEARER_TOKEN_PREFIX, 1308 | }) 1309 | 1310 | client.on('connect', () => { 1311 | client.disconnect() 1312 | }) 1313 | 1314 | client.on('disconnect', () => { 1315 | client = null 1316 | done() 1317 | }) 1318 | }) 1319 | 1320 | it('... Reconnects after a connection failure until (odd) maxRetries is reached, at which point an event is emitted', (done) => { 1321 | const client = new Client({ 1322 | hostname: 'poo.signalk.org', 1323 | port: 80, 1324 | useTLS: false, 1325 | maxRetries: 11, 1326 | notifications: false, 1327 | bearerTokenPrefix: BEARER_TOKEN_PREFIX, 1328 | }) 1329 | 1330 | client.on('hitMaxRetries', () => { 1331 | assert(client.retries === 11) 1332 | done() 1333 | }) 1334 | 1335 | client.connect().catch(() => {}) 1336 | }).timeout(60000) 1337 | 1338 | it('... Reconnects after a connection failure until (even) maxRetries is reached, at which point an event is emitted', (done) => { 1339 | const client = new Client({ 1340 | hostname: 'poo.signalk.org', 1341 | port: 80, 1342 | useTLS: false, 1343 | maxRetries: 10, 1344 | notifications: false, 1345 | bearerTokenPrefix: BEARER_TOKEN_PREFIX, 1346 | }) 1347 | 1348 | client.on('hitMaxRetries', () => { 1349 | assert(client.retries === 10) 1350 | done() 1351 | }) 1352 | 1353 | client.connect().catch(() => {}) 1354 | }).timeout(60000) 1355 | 1356 | it("... Calling disconnect on a disconnected client shouldn't throw", (done) => { 1357 | const client = new Client({ 1358 | hostname: 'demo.signalk.org', 1359 | port: 80, 1360 | useTLS: false, 1361 | reconnect: false, 1362 | notifications: false, 1363 | bearerTokenPrefix: BEARER_TOKEN_PREFIX, 1364 | }) 1365 | 1366 | client.on('disconnect', () => { 1367 | client.disconnect(true).then(() => { 1368 | assert(true) 1369 | return done() 1370 | }) 1371 | }) 1372 | 1373 | client.on('connect', () => { 1374 | client.disconnect() 1375 | }) 1376 | 1377 | client.connect() 1378 | }).timeout(30000) 1379 | 1380 | it('... Reconnects after a connection failure, with progressive back-off behaviour', (done) => { 1381 | const client = new Client({ 1382 | hostname: 'poo.signalk.org', 1383 | port: 80, 1384 | useTLS: false, 1385 | maxRetries: 10, 1386 | notifications: false, 1387 | bearerTokenPrefix: BEARER_TOKEN_PREFIX, 1388 | }) 1389 | 1390 | const waitTimes = [] 1391 | client.on('backOffBeforeReconnect', (waitTime) => waitTimes.push(waitTime)) 1392 | 1393 | client.on('hitMaxRetries', () => { 1394 | assert.deepEqual(waitTimes, [250, 500, 750, 1000, 1250, 1500, 1750, 2000, 2250]) 1395 | assert(client.retries === 10) 1396 | done() 1397 | }) 1398 | 1399 | client.connect().catch(() => {}) 1400 | }).timeout(60000) 1401 | 1402 | it('... Emits an "error" event after a failed connection attempt', (done) => { 1403 | const client = new Client({ 1404 | hostname: 'poo.signalk.org', 1405 | port: 80, 1406 | useTLS: false, 1407 | reconnect: false, 1408 | notifications: false, 1409 | bearerTokenPrefix: BEARER_TOKEN_PREFIX, 1410 | }) 1411 | 1412 | client.on('error', () => { 1413 | assert(true) 1414 | done() 1415 | }) 1416 | 1417 | client.connect().catch(() => {}) 1418 | }).timeout(30000) 1419 | 1420 | it('... Emits a "connect" event after successful connection to demo.signalk.org', (done) => { 1421 | const client = new Client({ 1422 | hostname: 'demo.signalk.org', 1423 | port: 80, 1424 | useTLS: false, 1425 | reconnect: false, 1426 | notifications: false, 1427 | bearerTokenPrefix: BEARER_TOKEN_PREFIX, 1428 | }) 1429 | 1430 | client.on('connect', () => { 1431 | assert(true) 1432 | done() 1433 | }) 1434 | 1435 | client.connect() 1436 | }).timeout(30000) 1437 | 1438 | it('... Rejects the connect Promise after a failed connection attempt', (done) => { 1439 | const client = new Client({ 1440 | hostname: 'poo.signalk.org', 1441 | port: 80, 1442 | useTLS: false, 1443 | reconnect: false, 1444 | notifications: false, 1445 | bearerTokenPrefix: BEARER_TOKEN_PREFIX, 1446 | }) 1447 | 1448 | client.connect().catch(() => { 1449 | assert(true) 1450 | done() 1451 | }) 1452 | }).timeout(30000) 1453 | 1454 | it('... Resolves the connect Promise after successful connection to demo.signalk.org', (done) => { 1455 | const client = new Client({ 1456 | hostname: 'demo.signalk.org', 1457 | port: 80, 1458 | useTLS: false, 1459 | reconnect: false, 1460 | notifications: false, 1461 | bearerTokenPrefix: BEARER_TOKEN_PREFIX, 1462 | }) 1463 | 1464 | client.connect().then(() => { 1465 | assert(true) 1466 | done() 1467 | }) 1468 | }).timeout(30000) 1469 | 1470 | it('... Gets server connection info after successful connection to demo.signalk.org', (done) => { 1471 | const client = new Client({ 1472 | hostname: 'demo.signalk.org', 1473 | port: 80, 1474 | useTLS: false, 1475 | reconnect: false, 1476 | notifications: false, 1477 | bearerTokenPrefix: BEARER_TOKEN_PREFIX, 1478 | }) 1479 | 1480 | client.on('connectionInfo', () => { 1481 | assert(true) 1482 | done() 1483 | }) 1484 | 1485 | client.connect() 1486 | }).timeout(15000) 1487 | 1488 | it('... Gets vessel "self" MRN after successful connection to demo.signalk.org', (done) => { 1489 | const client = new Client({ 1490 | hostname: 'demo.signalk.org', 1491 | port: 80, 1492 | useTLS: false, 1493 | reconnect: false, 1494 | notifications: false, 1495 | bearerTokenPrefix: BEARER_TOKEN_PREFIX, 1496 | }) 1497 | 1498 | client.on('self', (self) => { 1499 | assert(self.startsWith('vessels.urn:mrn:signalk:uuid:')) 1500 | assert(self.length === 65) 1501 | done() 1502 | }) 1503 | 1504 | client.connect() 1505 | }).timeout(15000) 1506 | 1507 | it('... Fails to get vessel in case of unauthenticated connection', (done) => { 1508 | const client = new Client({ 1509 | hostname: TEST_SERVER_HOSTNAME, 1510 | port: TEST_SERVER_PORT, 1511 | useTLS: false, 1512 | reconnect: false, 1513 | notifications: false, 1514 | bearerTokenPrefix: BEARER_TOKEN_PREFIX, 1515 | }) 1516 | 1517 | client.on('connect', () => { 1518 | client 1519 | .API() 1520 | .then((api) => api.self()) 1521 | .then((result) => { 1522 | assert(result && typeof result === 'object' && result.hasOwnProperty('uuid')) 1523 | done(new Error("Got data when we shouldn't be authenticated")) 1524 | }) 1525 | .catch((err) => { 1526 | assert(err && typeof err === 'object' && typeof err.message === 'string' && err.message.includes('401')) 1527 | done() 1528 | }) 1529 | }) 1530 | 1531 | client.connect() 1532 | }).timeout(15000) 1533 | 1534 | it('... Successfully authenticates with correct username/password', (done) => { 1535 | const client = new Client({ 1536 | hostname: TEST_SERVER_HOSTNAME, 1537 | port: TEST_SERVER_PORT, 1538 | useTLS: false, 1539 | useAuthentication: true, 1540 | username: USER, 1541 | password: PASSWORD, 1542 | reconnect: false, 1543 | notifications: false, 1544 | bearerTokenPrefix: BEARER_TOKEN_PREFIX, 1545 | }) 1546 | 1547 | client.on('connect', () => { 1548 | client 1549 | .API() 1550 | .then((api) => api.self()) 1551 | .then((result) => { 1552 | assert(result && typeof result === 'object' && result.hasOwnProperty('uuid')) 1553 | done() 1554 | }) 1555 | .catch((err) => done(err)) 1556 | }) 1557 | 1558 | client.connect() 1559 | }).timeout(15000) 1560 | // */ 1561 | 1562 | it('... Successfully re-connects after the remote server is restarted', (done) => { 1563 | const client = new Client({ 1564 | hostname: TEST_SERVER_HOSTNAME, 1565 | port: TEST_SERVER_PORT, 1566 | useTLS: false, 1567 | useAuthentication: true, 1568 | username: USER, 1569 | password: PASSWORD, 1570 | reconnect: true, 1571 | notifications: false, 1572 | bearerTokenPrefix: BEARER_TOKEN_PREFIX, 1573 | maxRetries: Infinity, 1574 | }) 1575 | 1576 | let connectionCount = 0 1577 | 1578 | client.on('connect', () => { 1579 | connectionCount += 1 1580 | if (connectionCount === 1) { 1581 | killServer(() => 1582 | setTimeout(() => { 1583 | startServer() 1584 | }, 100) 1585 | ) 1586 | } 1587 | 1588 | if (connectionCount === 2) { 1589 | done() 1590 | } 1591 | }) 1592 | 1593 | client.connect() 1594 | }).timeout(15000) 1595 | 1596 | /* 1597 | // @NOTE: 1598 | // this test requires a manual restart of the test server, 1599 | // as the included server doesn't emit deltas 1600 | 1601 | it('... Successfully re-subscribes to all data after the remote server is restarted', done => { 1602 | const client = new Client({ 1603 | hostname: 'hq.decipher.digital', 1604 | port: 3000, 1605 | useTLS: false, 1606 | useAuthentication: false, 1607 | reconnect: true, 1608 | notifications: false, 1609 | bearerTokenPrefix: BEARER_TOKEN_PREFIX, 1610 | maxRetries: Infinity 1611 | }) 1612 | 1613 | let connectionCount = 0 1614 | let serverKilled = false 1615 | let deltas = 0 1616 | let doneCalled = false 1617 | 1618 | client.on('delta', data => { 1619 | if (!data || typeof data !== 'object' || !data.hasOwnProperty('updates') || serverKilled === true) { 1620 | return 1621 | } 1622 | 1623 | deltas += 1 1624 | if (connectionCount > 1 && doneCalled === false) { 1625 | doneCalled = true 1626 | assert(deltas >= 1000) 1627 | done(deltas >= 1000 ? null : new Error('Didn\'t get deltas after reconnection')) 1628 | } 1629 | }) 1630 | 1631 | client.on('connect', () => { 1632 | connectionCount += 1 1633 | 1634 | if (connectionCount === 1) { 1635 | client.subscribe() 1636 | } 1637 | 1638 | if (connectionCount > 1) { 1639 | deltas = 1000 1640 | } 1641 | }) 1642 | 1643 | client.connect() 1644 | }).timeout(150000) 1645 | // */ 1646 | }) 1647 | }) 1648 | --------------------------------------------------------------------------------