├── .github └── workflows │ └── test.yaml ├── .gitignore ├── LICENSE.md ├── README.md ├── docs ├── logo.png ├── statelifecycle.drawio └── statelifecycle.png ├── index.d.ts ├── index.js ├── lib ├── client.d.ts ├── client.js ├── errors.d.ts ├── errors.js ├── event-buffer.d.ts ├── event-buffer.js ├── queue.d.ts ├── queue.js ├── schemas │ ├── ocpp1_6.json │ ├── ocpp2_0_1.json │ └── ocpp2_1.json ├── server-client.d.ts ├── server-client.js ├── server.d.ts ├── server.js ├── standard-validators.d.ts ├── standard-validators.js ├── symbols.d.ts ├── symbols.js ├── util.d.ts ├── util.js ├── validator.d.ts ├── validator.js ├── ws-util.d.ts └── ws-util.js ├── package-lock.json ├── package.json └── test ├── client.js ├── server-client.js ├── server.js ├── util.js ├── validator.js └── ws-util.js /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | on: ["push","pull_request"] 2 | 3 | name: Test Coveralls 4 | 5 | jobs: 6 | test: 7 | name: Test 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | nodeVer: 12 | - 17.3.0 13 | - 18 14 | - 19 15 | steps: 16 | 17 | - uses: actions/checkout@master 18 | 19 | - name: Setup Node.js 20 | uses: actions/setup-node@master 21 | with: 22 | node-version: ${{ matrix.nodeVer }} 23 | 24 | - name: Run tests 25 | run: | 26 | npm ci 27 | npm run coverage 28 | 29 | - name: Coveralls 30 | uses: coverallsapp/github-action@1.1.3 31 | with: 32 | github-token: ${{ secrets.GITHUB_TOKEN }} 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .nyc_output/ 2 | node_modules/ 3 | coverage/ 4 | ~$* 5 | docs/logo.psd 6 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Gareth Hughes 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OCPP-RPC 2 | 3 | [![Coverage Status](https://coveralls.io/repos/github/mikuso/ocpp-rpc/badge.svg?branch=master)](https://coveralls.io/github/mikuso/ocpp-rpc?branch=master) 4 | ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/mikuso/ocpp-rpc/test.yaml?branch=master) 5 | [![GitHub issues](https://img.shields.io/github/issues/mikuso/ocpp-rpc)](https://github.com/mikuso/ocpp-rpc/issues) 6 | [![GitHub license](https://img.shields.io/github/license/mikuso/ocpp-rpc)](https://github.com/mikuso/ocpp-rpc/blob/master/LICENSE.md) 7 | [![GitHub stars](https://img.shields.io/github/stars/mikuso/ocpp-rpc)](https://github.com/mikuso/ocpp-rpc/stargazers) 8 | [![GitHub forks](https://img.shields.io/github/forks/mikuso/ocpp-rpc)](https://github.com/mikuso/ocpp-rpc/network) 9 | 10 | ![OCPP-RPC](/docs/logo.png) 11 | 12 | A client & server implementation of the WAMP-like RPC-over-websocket system defined in the [OCPP-J protocols](https://openchargealliance.org/protocols/) (e.g. [OCPP1.6J](https://openchargealliance.org/protocols/open-charge-point-protocol/#OCPP1.6), [OCPP2.0.1J](https://openchargealliance.org/protocols/open-charge-point-protocol/#OCPP2.0.1) and [OCPP2.1](https://openchargealliance.org/protocols/open-charge-point-protocol/#OCPP2.1)). 13 | 14 | Requires Node.js >= 17.3.0 15 | 16 | This module is built for Node.js and does not currently work in browsers. 17 | 18 | ## Who is this for? 19 | 20 | * Anyone building an OCPP-based Charging Station or Charging Station Management System (CSMS) using Node.js. 21 | * Anyone looking for a simple yet robust symmetrical RPC framework that runs over WebSockets. 22 | 23 | ## Features 24 | 25 | * 🛂 **Authentication** - Optional authentication step for initiating session data and filtering incoming clients. 26 | * 🔒 **[OCPP Security](#ocpp-security)** - Compatible with OCPP security profiles 1, 2 & 3. 27 | * 💬 **Serve multiple subprotocols** - Simultaneously serve multiple different subprotocols from the same service endpoint. 28 | * ✅ **[Strict Validation](#strict-validation)** - Optionally enforce subprotocol schemas to prevent invalid calls & responses. 29 | * **Automatic reconnects** - Client supports automatic exponential-backoff reconnects. 30 | * **Automatic keep-alive** - Regularly performs pings, and drops dangling TCP connections. 31 | * **Graceful shutdowns** - Supports waiting for all in-flight messages to be responded to before closing sockets. 32 | * **Clean closing of websockets** - Supports sending & receiving WebSocket close codes & reasons. 33 | * **Embraces abort signals** - `AbortSignal`s can be passed to most async methods. 34 | * **Optional HTTP server** - Bring your own HTTP server if you want to, or let `RPCServer` create one for you. 35 | 36 | ## Table of Contents 37 | 38 | * [Installing](#installing) 39 | * [Usage Examples](#usage-examples) 40 | * [Barebones OCPP1.6J server](#barebones-ocpp16j-server) 41 | * [Barebones OCPP1.6J client](#barebones-ocpp16j-client) 42 | * [Using with Express.js](#using-with-expressjs) 43 | * [API Docs](#api-docs) 44 | * [Strict Validation](#strict-validation) 45 | * [OCPP Security](#ocpp-security) 46 | * [Security Profile 1](#security-profile-1) 47 | * [Security Profile 2](#security-profile-2) 48 | * [Security Profile 3](#security-profile-3) 49 | * [RPCClient state lifecycle](#rpcclient-state-lifecycle) 50 | * [Upgrading from 1.X -> 2.0](#upgrading-from-1x---20) 51 | * [License](#license) 52 | 53 | ## Installing 54 | 55 | ```sh 56 | npm install ocpp-rpc 57 | ``` 58 | 59 | ## Usage Examples 60 | 61 | ### Barebones OCPP1.6J server 62 | 63 | ```js 64 | const { RPCServer, createRPCError } = require('ocpp-rpc'); 65 | 66 | const server = new RPCServer({ 67 | protocols: ['ocpp1.6'], // server accepts ocpp1.6 subprotocol 68 | strictMode: true, // enable strict validation of requests & responses 69 | }); 70 | 71 | server.auth((accept, reject, handshake) => { 72 | // accept the incoming client 73 | accept({ 74 | // anything passed to accept() will be attached as a 'session' property of the client. 75 | sessionId: 'XYZ123' 76 | }); 77 | }); 78 | 79 | server.on('client', async (client) => { 80 | console.log(`${client.session.sessionId} connected!`); // `XYZ123 connected!` 81 | 82 | // create a specific handler for handling BootNotification requests 83 | client.handle('BootNotification', ({params}) => { 84 | console.log(`Server got BootNotification from ${client.identity}:`, params); 85 | 86 | // respond to accept the client 87 | return { 88 | status: "Accepted", 89 | interval: 300, 90 | currentTime: new Date().toISOString() 91 | }; 92 | }); 93 | 94 | // create a specific handler for handling Heartbeat requests 95 | client.handle('Heartbeat', ({params}) => { 96 | console.log(`Server got Heartbeat from ${client.identity}:`, params); 97 | 98 | // respond with the server's current time. 99 | return { 100 | currentTime: new Date().toISOString() 101 | }; 102 | }); 103 | 104 | // create a specific handler for handling StatusNotification requests 105 | client.handle('StatusNotification', ({params}) => { 106 | console.log(`Server got StatusNotification from ${client.identity}:`, params); 107 | return {}; 108 | }); 109 | 110 | // create a wildcard handler to handle any RPC method 111 | client.handle(({method, params}) => { 112 | // This handler will be called if the incoming method cannot be handled elsewhere. 113 | console.log(`Server got ${method} from ${client.identity}:`, params); 114 | 115 | // throw an RPC error to inform the server that we don't understand the request. 116 | throw createRPCError("NotImplemented"); 117 | }); 118 | }); 119 | 120 | await server.listen(3000); 121 | ``` 122 | 123 | ### Barebones OCPP1.6J client 124 | 125 | ```js 126 | const { RPCClient } = require('ocpp-rpc'); 127 | 128 | const cli = new RPCClient({ 129 | endpoint: 'ws://localhost:3000', // the OCPP endpoint URL 130 | identity: 'EXAMPLE', // the OCPP identity 131 | protocols: ['ocpp1.6'], // client understands ocpp1.6 subprotocol 132 | strictMode: true, // enable strict validation of requests & responses 133 | }); 134 | 135 | // connect to the OCPP server 136 | await cli.connect(); 137 | 138 | // send a BootNotification request and await the response 139 | const bootResponse = await cli.call('BootNotification', { 140 | chargePointVendor: "ocpp-rpc", 141 | chargePointModel: "ocpp-rpc", 142 | }); 143 | 144 | // check that the server accepted the client 145 | if (bootResponse.status === 'Accepted') { 146 | 147 | // send a Heartbeat request and await the response 148 | const heartbeatResponse = await cli.call('Heartbeat', {}); 149 | // read the current server time from the response 150 | console.log('Server time is:', heartbeatResponse.currentTime); 151 | 152 | // send a StatusNotification request for the controller 153 | await cli.call('StatusNotification', { 154 | connectorId: 0, 155 | errorCode: "NoError", 156 | status: "Available", 157 | }); 158 | } 159 | ``` 160 | 161 | ### Using with [Express.js](https://expressjs.com/) 162 | 163 | ```js 164 | const {RPCServer, RPCClient} = require('ocpp-rpc'); 165 | const express = require('express'); 166 | 167 | const app = express(); 168 | const httpServer = app.listen(3000, 'localhost'); 169 | 170 | const rpcServer = new RPCServer(); 171 | httpServer.on('upgrade', rpcServer.handleUpgrade); 172 | 173 | rpcServer.on('client', client => { 174 | // RPC client connected 175 | client.call('Say', `Hello, ${client.identity}!`); 176 | }); 177 | 178 | // create a simple client to connect to the server 179 | const cli = new RPCClient({ 180 | endpoint: 'ws://localhost:3000', 181 | identity: 'XYZ123' 182 | }); 183 | 184 | cli.handle('Say', ({params}) => { 185 | console.log('Server said:', params); 186 | }); 187 | 188 | await cli.connect(); 189 | ``` 190 | 191 | ## API Docs 192 | 193 | * [Class: RPCServer](#class-rpcserver) 194 | * [new RPCServer(options)](#new-rpcserveroptions) 195 | * [Event: 'client'](#event-client) 196 | * [Event: 'error'](#event-error) 197 | * [Event: 'close'](#event-close) 198 | * [Event: 'closing'](#event-closing) 199 | * [Event: 'upgradeAborted'](#event-upgradeaborted) 200 | * [server.auth(callback)](#serverauthcallback) 201 | * [server.handleUpgrade(request)](#serverhandleupgraderequest-socket-head) 202 | * [server.reconfigure(options)](#serverreconfigureoptions) 203 | * [server.listen(port[, host[, options]])](#serverlistenport-host-options) 204 | * [server.close([options])](#servercloseoptions) 205 | 206 | * [Class: RPCClient](#class-rpcclient) 207 | * [new RPCClient(options)](#new-rpcclientoptions) 208 | * [Event: 'badMessage'](#event-badmessage) 209 | * [Event: 'strictValidationFailure'](#event-strictvalidationfailure) 210 | * [Event: 'message'](#event-message) 211 | * [Event: 'call'](#event-call) 212 | * [Event: 'callResult'](#event-callresult) 213 | * [Event: 'callError'](#event-callerror) 214 | * [Event: 'close'](#event-close-1) 215 | * [Event: 'closing'](#event-closing-1) 216 | * [Event: 'connecting'](#event-connecting) 217 | * [Event: 'disconnect'](#event-disconnect) 218 | * [Event: 'open'](#event-open) 219 | * [Event: 'ping'](#event-ping) 220 | * [Event: 'protocol'](#event-protocol) 221 | * [Event: 'response'](#event-response) 222 | * [Event: 'socketError'](#event-socketerror) 223 | * [client.identity](#clientidentity) 224 | * [client.state](#clientstate) 225 | * [client.protocol](#clientprotocol) 226 | * [client.reconfigure(options)](#clientreconfigureoptions) 227 | * [client.removeHandler([method])](#clientremovehandlermethod) 228 | * [client.removeAllHandlers()](#clientremoveallhandlers) 229 | * [client.connect()](#clientconnect) 230 | * [client.close([options])](#clientcloseoptions) 231 | * [client.handle([method,] handler)](#clienthandlemethod-handler) 232 | * [client.call(method[, params[, options]])](#clientcallmethod-params-options) 233 | * [client.sendRaw(message)](#clientsendrawmessage) 234 | 235 | * [Class: RPCServerClient](#class-rpcserverclient--rpcclient) 236 | * [client.handshake](#clienthandshake) 237 | * [client.session](#clientsession) 238 | 239 | * [Class: RPCError](#class-rpcerror--error) 240 | * [err.rpcErrorCode](#errrpcerrorcode) 241 | * [err.details](#errdetails) 242 | 243 | * [createValidator(subprotocol, schema)](#createvalidatorsubprotocol-schema) 244 | * [createRPCError(type[, message[, details]])](#createrpcerrortype-message-details) 245 | 246 | ### Class: RPCServer 247 | 248 | #### new RPCServer(options) 249 | 250 | - `options` {Object} 251 | - `protocols` {Array<String>} - Array of subprotocols supported by this server. Can be overridden in an [auth](#serverauthcallback) callback. Defaults to `[]`. 252 | - `callTimeoutMs` {Number} - Milliseconds to wait before unanswered outbound calls are rejected automatically. Defaults to `30000`. 253 | - `pingIntervalMs` {Number} - Milliseconds between WebSocket pings to connected clients. Used for keep-alive timeouts. Defaults to `30000`. 254 | - `deferPingsOnActivity` {Boolean} - Should connected clients skip sending keep-alive pings if activity received? Defaults to `false`. 255 | - `respondWithDetailedErrors` {Boolean} - Specifies whether to send detailed errors (including stack trace) to remote party upon an error being thrown by a handler. Defaults to `false`. 256 | - `callConcurrency` {Number} - The number of concurrent in-flight outbound calls permitted at any one time. Additional calls are queued. (There is no limit on inbound calls.) Defaults to `1`. 257 | - `strictMode` {Boolean} - Enable strict validation of calls & responses. Defaults to `false`. (See [Strict Validation](#strict-validation) to understand how this works.) 258 | - `strictModeValidators` {Array<Validator>} - Optional additional validators to be used in conjunction with `strictMode`. (See [Strict Validation](#adding-additional-validation-schemas) to understand how this works.) 259 | - `maxBadMessages` {Number} - The maximum number of [non-conforming RPC messages](#event-rpcerror) which can be tolerated by the server before the client is automatically closed. Defaults to `Infinity`. 260 | - `wssOptions` {Object} - Additional [WebSocketServer options](https://github.com/websockets/ws/blob/master/doc/ws.md#new-websocketserveroptions-callback). 261 | 262 | #### Event: 'client' 263 | 264 | * `client` {RPCServerClient} 265 | 266 | Emitted when a client has connected and been accepted. By default, a client will be automatically accepted if it connects with a matching subprotocol offered by the server (as per the `protocols` option in the server constructor). This behaviour can be overriden by [setting an auth handler](#serverauthcallback). 267 | 268 | #### Event: 'error' 269 | 270 | * `error` {Error} 271 | 272 | Emitted when the underlying WebSocketServer emits an error. 273 | 274 | #### Event: 'close' 275 | 276 | Emitted when the server has fully closed and all clients have been disconnected. 277 | 278 | #### Event: 'closing' 279 | 280 | Emitted when the server has begun closing. Beyond this point, no more clients will be accepted and the `'client'` event will no longer fire. 281 | 282 | #### Event: 'upgradeAborted' 283 | 284 | * `event` {Object} 285 | * `error` {Error} - The cause of the abort. 286 | * `socket` {net.Socket} - Network socket between the server and client 287 | * `request` {http.IncomingMessage} - The full HTTP request received by the underlying webserver. 288 | * `identity` {String} - The identity portion of the connection URL, decoded. 289 | 290 | Emitted when a websocket upgrade has been aborted. This could be caused by an authentication rejection, socket error or websocket handshake error. 291 | 292 | #### server.auth(callback) 293 | 294 | * `callback` {Function} 295 | 296 | Sets an authentication callback to be called before each client is accepted by the server. Setting an authentication callback is optional. By default, clients are accepted if they simply support a matching subprotocol. 297 | 298 | The callback function is called with the following three arguments: 299 | 300 | * `accept` {Function} - A function with the signature `accept([session[, protocol]])`. Call this function to accept the client, causing the server to emit a `'client'` event. 301 | * `session` {*} - Optional data to save as the client's 'session'. This data can later be retrieved from the [`session`](#clientsession) property of the client. 302 | * `protocol` {String} - Optionally explicitly set the subprotocol to use for this connection. If not set, the subprotocol will be decided automatically as the first mutual subprotocol (in order of the [RPCServer constructor](#new-rpcserveroptions)'s `protocols` value). If a non mutually-agreeable subprotocol value is set, the client will be rejected instead. 303 | 304 | * `reject` {Function} - A function with the signature `reject([code[, message]])` 305 | * `code` {Number} - The HTTP error code to reject the upgrade. Defaults to `400`. 306 | * `message` {String} - An optional message to send as the response body. Defaults to `''`. 307 | 308 | * `handshake` {Object} - A handshake object 309 | * `protocols` {Set} - A set of subprotocols purportedly supported by the client. 310 | * `identity` {String} - The identity portion of the connection URL, decoded. 311 | * `password` {Buffer} - If HTTP Basic auth was used in the connection, and the username correctly matches the identity, this field will contain the password (otherwise `undefined`). Typically this password would be a string, but the OCPP specs allow for this to be binary, so it is provided as a `Buffer` for you to interpret as you wish. Read [Security Profile 1](#security-profile-1) for more details of how this works. 312 | * `endpoint` {String} - The endpoint path portion of the connection URL. This is the part of the path before the identity. 313 | * `query` {URLSearchParams} - The query string parsed as [URLSearchParams](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams). 314 | * `remoteAddress` {String} - The remote IP address of the socket. 315 | * `headers` {Object} - The HTTP headers sent in the upgrade request. 316 | * `request` {http.IncomingMessage} - The full HTTP request received by the underlying webserver. 317 | 318 | * `signal` {AbortSignal} - An `AbortSignal` used to indicate whether the websocket upgrade has been aborted during the authentication process. The `signal.reason` is also available as the `error` property of the ['upgradeAborted'](#event-upgradeaborted) event. 319 | 320 | Example: 321 | 322 | ```js 323 | const rpcServer = new RPCServer(); 324 | rpcServer.auth((accept, reject, handshake, signal) => { 325 | if (handshake.identity === 'TEST') { 326 | accept(); 327 | } else { 328 | reject(401, "I don't recognise you"); 329 | } 330 | }); 331 | ``` 332 | 333 | #### server.handleUpgrade(request, socket, head) 334 | 335 | * `request` {http.IncomingMessage} 336 | * `socket` {net.Socket} - Network socket between the server and client 337 | * `head` {Buffer} - The first packet of the upgraded stream (may be empty) 338 | 339 | Converts an HTTP upgrade request into a WebSocket client to be handled by this RPCServer. This method is bound to the server instance, so it is suitable to pass directly as an `http.Server`'s `'upgrade'` event handler. 340 | 341 | This is typically only needed if you are creating your own HTTP server. HTTP servers created by [`listen()`](#serverlistenport-host-options) have their `'upgrade'` event attached to this method automatically. 342 | 343 | Example: 344 | 345 | ```js 346 | const rpcServer = new RPCServer(); 347 | const httpServer = http.createServer(); 348 | httpServer.on('upgrade', rpcServer.handleUpgrade); 349 | ``` 350 | 351 | #### server.reconfigure(options) 352 | 353 | * `options` {Object} 354 | 355 | Use this method to change any of the `options` that can be passed to the `RPCServer`'s [constructor](#new-rpcserveroptions). 356 | 357 | #### server.listen([port[, host[, options]]]) 358 | 359 | * `port` {Number} - The port number to listen on. If not set, the operating system will assign an unused port. 360 | * `host` {String} - The host address to bind to. If not set, connections will be accepted on all interfaces. 361 | * `options` {Object} 362 | * `signal` {AbortSignal} - An `AbortSignal` used to abort the `listen()` call of the underlying `net.Server`. 363 | 364 | Creates a simple HTTP server which only accepts websocket upgrades and returns a 404 response to any other request. 365 | 366 | Returns a Promise which resolves to an instance of `http.Server` or rejects with an `Error` on failure. 367 | 368 | #### server.close([options]) 369 | 370 | * `options` {Object} 371 | * `code` {Number} - The [WebSocket close code](https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code) to pass to all connected clients. Defaults to `1000`. 372 | * `reason` {String} - The reason for closure to pass to all connected clients. Defaults to `''`. 373 | * `awaitPending` {Boolean} - If `true`, each connected client won't be fully closed until any outstanding in-flight (inbound & outbound) calls are responded to. Additional calls will be rejected in the meantime. Defaults to `false`. 374 | * `force` {Boolean} - If `true`, terminates all client WebSocket connections instantly and uncleanly. Defaults to `false`. 375 | 376 | This blocks new clients from connecting, calls [`client.close()`](#clientcloseoptions) on all connected clients, and then finally closes any listening HTTP servers which were created using [`server.listen()`](#serverlistenport-host-options). 377 | 378 | Returns a `Promise` which resolves when the server has completed closing. 379 | 380 | ### Class: RPCClient 381 | 382 | #### new RPCClient(options) 383 | 384 | - `options` {Object} 385 | - `endpoint` {String} - The RPC server's endpoint (a websocket URL). **Required**. 386 | - `identity` {String} - The RPC client's identity. Will be automatically encoded. **Required**. 387 | - `protocols` {Array<String>} - Array of subprotocols supported by this client. Defaults to `[]`. 388 | - `password` {String|Buffer} - Optional password to use in [HTTP Basic auth](#security-profile-1). This can be a Buffer to allow for binary auth keys as recommended in the OCPP security whitepaper. If provided as a string, it will be encoded as UTF-8. (The corresponding username will always be the identity). 389 | - `headers` {Object} - Additional HTTP headers to send along with the websocket upgrade request. Defaults to `{}`. 390 | - `query` {Object|String} - An optional query string or object to append as the query string of the connection URL. Defaults to `''`. 391 | - `callTimeoutMs` {Number} - Milliseconds to wait before unanswered outbound calls are rejected automatically. Defaults to `60000`. 392 | - `pingIntervalMs` {Number} - Milliseconds between WebSocket pings. Used for keep-alive timeouts. Defaults to `30000`. 393 | - `deferPingsOnActivity` {Boolean} - Should the client skip sending keep-alive pings if activity received? Defaults to `false`. 394 | - `strictMode` {Boolean} - Enable strict validation of calls & responses. Defaults to `false`. (See [Strict Validation](#strict-validation) to understand how this works.) 395 | - `strictModeValidators` {Array<Validator>} - Optional additional validators to be used in conjunction with `strictMode`. (See [Strict Validation](#adding-additional-validation-schemas) to understand how this works.) 396 | - `respondWithDetailedErrors` {Boolean} - Specifies whether to send detailed errors (including stack trace) to remote party upon an error being thrown by a handler. Defaults to `false`. 397 | - `callConcurrency` {Number} - The number of concurrent in-flight outbound calls permitted at any one time. Additional calls are queued. There is no concurrency limit imposed on inbound calls. Defaults to `1`. 398 | - `reconnect` {Boolean} - If `true`, the client will attempt to reconnect after losing connection to the RPCServer. Only works after making one initial successful connection. Defaults to `true`. 399 | - `maxReconnects` {Number} - If `reconnect` is `true`, specifies the number of times to try reconnecting before failing and emitting a `close` event. Defaults to `Infinity` 400 | - `backoff` {Object} - If `reconnect` is `true`, specifies the options for an [ExponentialStrategy](https://github.com/MathieuTurcotte/node-backoff#class-exponentialstrategy) backoff strategy, used for reconnects. 401 | - `maxBadMessages` {Number} - The maximum number of [non-conforming RPC messages](#event-rpcerror) which can be tolerated by the client before the client is automatically closed. Defaults to `Infinity`. 402 | - `wsOpts` {Object} - Additional [WebSocket options](https://github.com/websockets/ws/blob/master/doc/ws.md#new-websocketaddress-protocols-options). 403 | 404 | #### Event: 'badMessage' 405 | 406 | * `event` {Object} 407 | * `buffer` {Buffer} - The raw discarded "bad" message payload. 408 | * `error` {Error} - An error describing what went wrong when handling the payload. 409 | * `response` {Array|null} - A copy of the response sent in reply to the bad message (if applicable). 410 | 411 | This event is emitted when a "bad message" is received. A "bad message" is simply one which does not structurally conform to the RPC protocol or violates some other principle of the framework (such as a response to a call which was not made). If appropriate, the client will respond with a `"RpcFrameworkError"` or similar error code (depending upon the violation) as required by the spec. 412 | 413 | (To be clear, this event will **not** simply be emitted upon receipt of an error response or invalid call. The message itself must actually be non-conforming to the spec to be considered "bad".) 414 | 415 | If too many bad messages are received in succession, the client will be closed with a close code of `1002`. The number of bad messages tolerated before automatic closure is determined by the `maxBadMessages` option. After receiving a valid (non-bad) message, the "bad message" counter will be reset. 416 | 417 | #### Event: 'strictValidationFailure' 418 | 419 | * `event` {Object} 420 | * `error` {Error} - The validation error that triggered the `strictValidationFailure` event. 421 | * `messageId` {String} - The RPC message ID 422 | * `method` {String} - The RPC method being invoked. 423 | * `params` {Object} - The RPC parameters. 424 | * `result` {Object} - If this error relates to a **CALLRESULT** validation failure, then this contains the invalid result, otherwise `null`. 425 | * `outbound` {Boolean} - This will be `true` if the invalid message originated locally. 426 | * `isCall` {Boolean} - This will be `true` if the invalid message is a **CALL** type. `false` indicates a **CALLRESULT** type. 427 | 428 | This event is emitted in [strict mode](#strict-validation) when an inbound call or outbound response does not satisfy the subprotocol schema validator. See [Effects of `strictMode`](#effects-of-strictmode) to understand what happens in response to the invalid message. 429 | 430 | #### Event: 'call' 431 | 432 | * `call` {Object} 433 | * `messageId` {String} - The RPC message ID 434 | * `outbound` {Boolean} - This will be `true` if the call originated locally. 435 | * `payload` {Array} - The RPC call payload array. 436 | 437 | Emitted immediately before a call request is sent, or in the case of an inbound call, immediately before the call is processed. Useful for logging or debugging. 438 | 439 | If you want to handle (and respond) to the call, you should register a handler using [client.handle()](#clienthandlemethod-handler) instead. 440 | 441 | #### Event: 'callResult' 442 | 443 | * `event` {Object} 444 | * `messageId` {String} - The RPC message ID 445 | * `outbound` {Boolean} - This will be `true` if the call originated locally. 446 | * `method` {String} - The RPC method. 447 | * `params` {Object} - The RPC params. 448 | * `result` {Object} - The result of the call. 449 | 450 | Emitted immediately after a call result is successfully sent or received. Useful for logging or debugging. 451 | 452 | #### Event: 'callError' 453 | 454 | * `event` {Object} 455 | * `messageId` {String} - The RPC message ID 456 | * `outbound` {Boolean} - This will be `true` if the call originated locally. 457 | * `method` {String} - The RPC method. 458 | * `params` {Object} - The RPC params. 459 | * `error` {RPCError} - The error from the call. 460 | 461 | Emitted immediately after a call error is sent or received. Useful for logging or debugging. 462 | 463 | Will not be emitted if NOREPLY is sent as a response, or if a call times out. 464 | 465 | #### Event: 'close' 466 | 467 | * `event` {Object} 468 | * `code` {Number} - The close code received. 469 | * `reason` {String} - The reason for the connection closing. 470 | 471 | Emitted after `client.close()` completes. 472 | 473 | #### Event: 'closing' 474 | 475 | Emitted when the client is closing and does not plan to reconnect. 476 | 477 | #### Event: 'connecting' 478 | 479 | Emitted when the client is trying to establish a new WebSocket connection. If sucessful, the this should be followed by an `'open'` event. 480 | 481 | #### Event: 'disconnect' 482 | 483 | * `event` {Object} 484 | * `code` {Number} - The close code received. 485 | * `reason` {String} - The reason for the connection closing. 486 | 487 | Emitted when the underlying WebSocket has disconnected. If the client is configured to reconnect, this should be followed by a `'connecting'` event, otherwise a `'closing'` event. 488 | 489 | #### Event: 'message' 490 | 491 | * `event` {Object} 492 | * `message` {Buffer|String} - The message payload. 493 | * `outbound` {Boolean} - This will be `true` if the message originated locally. 494 | 495 | Emitted whenever a message is sent or received over client's WebSocket. Useful for logging or debugging. 496 | 497 | If you want to handle (and respond) to a call, you should register a handler using [client.handle()](#clienthandlemethod-handler) instead. 498 | 499 | #### Event: 'open' 500 | 501 | * `result` {Object} 502 | * `response` {http.ServerResponse} - The response to the client's upgrade request. 503 | 504 | Emitted when the client is connected to the server and ready to send & receive calls. 505 | 506 | #### Event: 'ping' 507 | 508 | * `event` {Object} 509 | * `rtt` {Number} - The round trip time (in milliseconds) between when the ping was sent and the pong was received. 510 | 511 | Emitted when the client has received a response to a ping. 512 | 513 | #### Event: 'protocol' 514 | 515 | * `protocol` {String} - The mutually agreed websocket subprotocol. 516 | 517 | Emitted when the client protocol has been set. Once set, this cannot change. This event only occurs once per [`connect()`](#clientconnect). 518 | 519 | #### Event: 'response' 520 | 521 | * `response` {Object} 522 | * `outbound` {Boolean} - This will be `true` if the response originated locally. 523 | * `payload` {Array} - The RPC response payload array. 524 | 525 | Emitted immediately before a response request is sent, or in the case of an inbound response, immediately before the response is processed. Useful for logging or debugging. 526 | 527 | #### Event: 'socketError' 528 | 529 | * `error` {Error} 530 | 531 | Emitted when the underlying WebSocket instance fires an `'error'` event. 532 | 533 | #### client.identity 534 | 535 | * {String} 536 | 537 | The decoded client identity. 538 | 539 | #### client.state 540 | 541 | * {Number} 542 | 543 | The client's state. See [state lifecycle](#rpcclient-state-lifecycle) 544 | 545 | | Enum | Value | 546 | | ---------- | ----- | 547 | | CONNECTING | 0 | 548 | | OPEN | 1 | 549 | | CLOSING | 2 | 550 | | CLOSED | 3 | 551 | 552 | #### client.protocol 553 | 554 | * {String} 555 | 556 | The agreed subprotocol. Once connected for the first time, this subprotocol becomes fixed and will be expected upon automatic reconnects (even if the server changes the available subprotocol options). 557 | 558 | #### client.reconfigure(options) 559 | 560 | * `options` {Object} 561 | 562 | Use this method to change any of the `options` that can be passed to the `RPCClient`'s [constructor](#new-rpcclientoptions). 563 | 564 | When changing `identity`, the `RPCClient` must be explicitly `close()`d and then `connect()`ed for the change to take effect. 565 | 566 | #### client.removeHandler([method]) 567 | 568 | * `method` {String} 569 | 570 | Unregisters a call handler. If no method name is provided, it will unregister the wildcard handler instead. 571 | 572 | #### client.removeAllHandlers() 573 | 574 | Unregisters all previously-registered call handlers (including wildcard handler if set). 575 | 576 | #### client.connect() 577 | 578 | The client will attempt to connect to the `RPCServer` specified in `options.url`. 579 | 580 | Returns a `Promise` which will either resolve to a `result` object upon successfully connecting, or reject if the connection fails. 581 | 582 | * `result` {Object} 583 | * `response` {http.ServerResponse} - The response to the client's upgrade request. 584 | 585 | #### client.sendRaw(message) 586 | 587 | * `message` {Array|Number|Object|String|ArrayBuffer|Buffer|DataView|TypedArray} - A raw message to send across the WebSocket. 588 | 589 | Send arbitrary data across the websocket. Not intended for general use. 590 | 591 | #### client.close([options]) 592 | * `options` {Object} 593 | * `code` {Number} - The [WebSocket close code](https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code). Defaults to `1000`. 594 | * `reason` {String} - The reason for closure. Defaults to `''`. 595 | * `awaitPending` {Boolean} - If `true`, the connection won't be fully closed until any outstanding in-flight (inbound & outbound) calls are responded to. Additional calls will be rejected in the meantime. Defaults to `false`. 596 | * `force` {Boolean} - If `true`, terminates the WebSocket connection instantly and uncleanly. Defaults to `false`. 597 | 598 | Close the underlying connection. Unless `awaitPending` is true, all in-flight outbound calls will be instantly rejected and any inbound calls in process will have their `signal` aborted. Unless `force` is true, `close()` will wait until all calls are settled before returning the final `code` and `reason` for closure. 599 | 600 | Returns a `Promise` which resolves to an Object with properties `code` and `reason`. 601 | 602 | In some circumstances, the final `code` and `reason` returned may be different from those which were requested. For instance, if `close()` is called twice, the first `code` provided is canonical. Also, if `close()` is called while in the CONNECTING state during the first connect, the `code` will always be `1001`, with the `reason` of `'Connection aborted'`. 603 | 604 | #### client.handle([method,] handler) 605 | 606 | * `method` {String} - The name of the method to be handled. If not provided, acts as a "wildcard" handler which will handle any call that doesn't have a more specific handler already registered. 607 | * `handler` {Function} - The function to be invoked when attempting to handle a call. 608 | 609 | Registers a call handler. Only one "wildcard" handler can be registered at once. Likewise, attempting to register a handler for a method which is already being handled will override the former handler. 610 | 611 | When the `handler` function is invoked, it will be passed an object with the following properties: 612 | * `method` {String} - The name of the method being invoked (useful for wildcard handlers). 613 | * `params` {*} - The parameters of the call. 614 | * `signal` {AbortSignal} - A signal which will abort if the underlying connection is dropped (therefore, the response will never be received by the caller). You may choose whether to ignore the signal or not, but it could save you some time if you use it to abort the call early. 615 | * `messageId` {String} - The OCPP Message ID used in the call. 616 | * `reply` {Function} - A callback function with which to pass a response to the call. Accepts a response value, an `Error`, or a `Promise`. 617 | 618 | Responses to handled calls are sent according to these rules: 619 | * If a value (or a `Promise` which resolves to a value) is passed to `reply()`, a **CALLRESULT** will be sent with this value as the result. 620 | * If an `Error` (or a `Promise` which rejects with an `Error`) is passed to `reply()`, a **CALLERROR** will be sent instead. (The `Error` may be coerced into an `RPCError`. You can use [createRPCError()](#createrpcerrortype-message-details) to reply with a specific RPC error code.) 621 | * If the `NOREPLY` symbol is passed to `reply()`, then no response will be sent. It will then be your responsibility to send the response by some other means (such as with [`sendRaw()`](#clientsendrawmessage)). 622 | * If the `handler` returns or throws before `reply()` is called, then the `reply()` callback will be called implicitly with the returned value (or thrown `Error`). 623 | * Calling `reply()` more than once, or returning/throwing after calling `reply()` is considered a no-op, will not result in any additional responses being sent, and has no effect. 624 | 625 | ##### Example of handling an OCPP1.6 Heartbeat 626 | 627 | ```js 628 | client.handle('Heartbeat', ({reply}) => { 629 | reply({ currentTime: new Date().toISOString() }); 630 | }); 631 | 632 | // or... 633 | client.handle('Heartbeat', () => { 634 | return { currentTime: new Date().toISOString() }; 635 | }); 636 | ``` 637 | 638 | ##### Example of using NOREPLY 639 | 640 | ```js 641 | const {NOREPLY} = require('ocpp-rpc'); 642 | 643 | client.handle('WontReply', ({reply}) => { 644 | reply(NOREPLY); 645 | }); 646 | 647 | // or... 648 | client.handle('WontReply', () => { 649 | return NOREPLY; 650 | }); 651 | ``` 652 | 653 | #### client.call(method[, params[, options]]) 654 | 655 | * `method` {String} - The name of the method to call. 656 | * `params` {*} - Parameters to send to the call handler. 657 | * `options` {Object} 658 | * `callTimeoutMs` {Number} - Milliseconds before unanswered call is rejected. Defaults to the same value as the option passed to the client/server constructor. 659 | * `signal` {AbortSignal} - `AbortSignal` to abort the call. 660 | * `noReply` {Boolean} - Send call without expecting a response, resolving immediately with `undefined`. If a response is received, a `badMessage` event will be emitted instead. Defaults to `false`. 661 | 662 | Calls a remote method. Returns a `Promise` which either: 663 | * resolves to the value returned by the remote handler. 664 | * rejects with an error. 665 | 666 | If the underlying connection is interrupted while waiting for a response, the `Promise` will reject with an `Error`. 667 | 668 | It's tempting to set `callTimeoutMs` to `Infinity` but this could be a mistake; If the remote handler never returns a response, the RPC communications will be blocked as soon as `callConcurrency` is exhausted (which is `1` by default). (While this is still an unlikely outcome when using this module for both client *and* server components - interoperability with real world systems can sometimes be unpredictable.) 669 | 670 | ### Class: RPCServerClient : RPCClient 671 | 672 | The RPCServerClient is a subclass of RPCClient. This represents an RPCClient from the server's perspective. It has all the same properties and methods as RPCClient but with a couple of additional properties... 673 | 674 | #### client.handshake 675 | 676 | * {Object} 677 | * `protocols` {Set} - A set of subprotocols purportedly supported by the client. 678 | * `identity` {String} - The identity portion of the connection URL, decoded. 679 | * `password` {Buffer} - If HTTP Basic auth was used in the connection, and the username correctly matches the identity, this field will contain the password (otherwise `undefined`). Typically this password would be a string, but the OCPP specs allow for this to be binary, so it is provided as a `Buffer` for you to interpret as you wish. Read [Security Profile 1](#security-profile-1) for more details of how this works. 680 | * `endpoint` {String} - The endpoint path portion of the connection URL. This is the part of the path before the identity. 681 | * `query` {URLSearchParams} - The query string parsed as [URLSearchParams](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams). 682 | * `remoteAddress` {String} - The remote IP address of the socket. 683 | * `headers` {Object} - The HTTP headers sent in the upgrade request. 684 | * `request` {http.IncomingMessage} - The full HTTP request received by the underlying webserver. 685 | 686 | This property holds information collected during the WebSocket connection handshake. 687 | 688 | #### client.session 689 | 690 | * {*} 691 | 692 | This property can be anything. This is the value passed to `accept()` during the authentication callback. 693 | 694 | ### createValidator(subprotocol, schema) 695 | 696 | * `subprotocol` {String} - The name of the subprotocol that this schema can validate. 697 | * `schema` {Array} - An array of json schemas. 698 | 699 | Returns a `Validator` object which can be used for [strict mode](#strict-validation). 700 | 701 | ### Class: RPCError : Error 702 | 703 | An error representing a violation of the RPC protocol. 704 | 705 | Throwing an RPCError from within a registered handler will pass the RPCError back to the caller. 706 | 707 | To create an RPCError, it is recommended to use the utility method [`createRPCError()`](#createrpcerrortype-message-details). 708 | 709 | #### err.rpcErrorCode 710 | 711 | * {String} 712 | 713 | The OCPP-J RPC error code. 714 | 715 | #### err.details 716 | 717 | * {Object} 718 | 719 | An object containing additional error details. 720 | 721 | ### createRPCError(type[, message[, details]]) 722 | * `type` {String} - One of the supported error types (see below). 723 | * `message` {String} - The error's message. 724 | * `details` {Object} - The details object to pass along with the error. Defaults to `{}`. 725 | 726 | This is a utility function to create a special type of RPC Error to be thrown from a call handler to return a non-generic error response. 727 | 728 | Returns an [`RPCError`](#class-rpcerror--error) which corresponds to the specified type: 729 | 730 | | Type | Description | 731 | | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------- | 732 | | GenericError | A generic error when no more specific error is appropriate. | 733 | | NotImplemented | Requested method is not known. | 734 | | NotSupported | Requested method is recognised but not supported. | 735 | | InternalError | An internal error occurred and the receiver was not able to process the requested method successfully. | 736 | | ProtocolError | Payload for method is incomplete. | 737 | | SecurityError | During the processing of method a security issue occurred preventing receiver from completing the method successfully. | 738 | | FormatViolation | Payload for the method is syntactically incorrect or not conform the PDU structure for the method. | 739 | | FormationViolation | [Deprecated] Same as FormatViolation. Retained for backwards compatibility with OCPP versions 1.6 and below. | 740 | | PropertyConstraintViolation | Payload is syntactically correct but at least one field contains an invalid value. | 741 | | OccurrenceConstraintViolation | Payload for the method is syntactically correct but at least one of the fields violates occurence constraints. | 742 | | OccurenceConstraintViolation | [Deprecated] Same as OccurrenceConstraintViolation. Retained for backwards compatibility with OCPP versions 1.6 and below. | 743 | | TypeConstraintViolation | Payload for the method is syntactically correct but at least one of the fields violates data type constraints. | 744 | | MessageTypeNotSupported | A message with a Message Type Number received is not supported by this implementation. | 745 | | RpcFrameworkError | Content of the call is not a valid RPC Request, for example: MessageId could not be read. | 746 | 747 | ## Strict Validation 748 | 749 | RPC clients can operate in "strict mode", validating calls & responses according to subprotocol schemas. The goal of strict mode is to eliminate the possibility of invalid data structures being sent through RPC. 750 | 751 | To enable strict mode, pass `strictMode: true` in the options to the [`RPCServer`](#new-rpcserveroptions) or [`RPCClient`](#new-rpcclientoptions) constructor. Alternately, you can limit strict mode to specific protocols by passing an array for `strictMode` instead. The schema ultimately used for validation is determined by whichever subprotocol is agreed between client and server. 752 | 753 | Examples: 754 | 755 | ```js 756 | // enable strict mode for all subprotocols 757 | const server = new RPCServer({ 758 | protocols: ['ocpp1.6', 'ocpp2.0.1', 'ocpp2.1'], 759 | strictMode: true, 760 | }); 761 | ``` 762 | 763 | ```js 764 | // only enable strict mode for ocpp1.6 765 | const server = new RPCServer({ 766 | protocols: ['ocpp1.6', 'proprietary0.1'], 767 | strictMode: ['ocpp1.6'], 768 | }); 769 | ``` 770 | 771 | ### Effects of `strictMode` 772 | 773 | As a caller, `strictMode` has the following effects: 774 | * If your method or params fail validation, your call will reject immediately with an [`RPCError`](#class-rpcerror--error). The call will not be sent. 775 | * If a response to your call fails validation, the call will reject with an [`RPCError`](#class-rpcerror--error) and you will not receive the actual response that was sent. 776 | 777 | As a callee, `strictMode` has the following effects: 778 | * If an inbound call's params fail validation, the call will not be passed to a handler. Instead, an error response will be automatically issued to the caller with an appropriate RPC error. 779 | * If your response to a call fails validation, then your response will be discarded and an `"InternalError"` RPC error will be sent instead. 780 | 781 | In all cases, a [`'strictValidationFailure'`](#event-strictvalidationfailure) event will be emitted, detailing the circumstances of the failure. 782 | 783 | **Important:** If you are using `strictMode`, you are strongly encouraged to listen for [`'strictValidationFailure'`](#event-strictvalidationfailure) events, otherwise you may not know if your responses or inbound calls are being dropped for failing validation. 784 | 785 | ### Supported validation schemas 786 | 787 | This module natively supports the following validation schemas: 788 | 789 | | Subprotocol | 790 | | ----------- | 791 | | ocpp1.6 | 792 | | ocpp2.0.1 | 793 | | ocpp2.1 | 794 | 795 | ### Adding additional validation schemas 796 | 797 | If you want to use `strictMode` with a subprotocol which is not included in the list above, you will need to add the appropriate schemas yourself. To do this, you must create a `Validator` for each subprotocol and pass them to the RPC constructor using the `strictModeValidators` option. (It is also possible to override the built-in validators this way.) 798 | 799 | To create a Validator, you should pass the name of the subprotocol and a well-formed json schema to [`createValidator()`](#createvalidatorsubprotocol-schema). An example of a well-formed schema can be found at [`./lib/schemas/ocpp1_6.json`](./lib/schemas/ocpp1_6.json) or [`./lib/schemas/ocpp2_0_1.json`](./lib/schemas/ocpp2_0_1.json) or in the example below. 800 | 801 | Example: 802 | 803 | ```js 804 | // define a validator for subprotocol 'echo1.0' 805 | const echoValidator = createValidator('echo1.0', [ 806 | { 807 | $schema: "http://json-schema.org/draft-07/schema", 808 | $id: "urn:Echo.req", 809 | type: "object", 810 | properties: { 811 | val: { type: "string" } 812 | }, 813 | additionalProperties: false, 814 | required: ["val"] 815 | }, 816 | { 817 | $schema: "http://json-schema.org/draft-07/schema", 818 | $id: "urn:Echo.conf", 819 | type: "object", 820 | properties: { 821 | val: { type: "string" } 822 | }, 823 | additionalProperties: false, 824 | required: ["val"] 825 | } 826 | ]); 827 | 828 | const server = new RPCServer({ 829 | protocols: ['echo1.0'], 830 | strictModeValidators: [echoValidator], 831 | strictMode: true, 832 | }); 833 | 834 | /* 835 | client.call('Echo', {val: 'foo'}); // returns {val: foo} 836 | client.call('Echo', ['bar']); // throws RPCError 837 | */ 838 | ``` 839 | 840 | Once created, the `Validator` is immutable and can be reused as many times as is required. 841 | 842 | ## OCPP Security 843 | 844 | It is possible to achieve all levels of OCPP security using this module. Keep in mind though that many aspects of OCPP security (such as key management, certificate generation, etc...) are beyond the scope of this module and it will be up to you to implement them yourself. 845 | 846 | ### Security Profile 1 847 | 848 | This security profile requires HTTP Basic Authentication. Clients are able to provide a HTTP basic auth password via the `password` option of the [`RPCClient` constructor](#new-rpcclientoptions). Servers are able to validate the password within the callback passed to [`auth()`](#serverauthcallback). 849 | 850 | #### Client & Server Example 851 | 852 | ```js 853 | const cli = new RPCClient({ 854 | identity: "AzureDiamond", 855 | password: "hunter2", 856 | }); 857 | 858 | const server = new RPCServer(); 859 | server.auth((accept, reject, handshake) => { 860 | if (handshake.identity === "AzureDiamond" && handshake.password.toString('utf8') === "hunter2") { 861 | accept(); 862 | } else { 863 | reject(401); 864 | } 865 | }); 866 | 867 | await server.listen(80); 868 | await cli.connect(); 869 | ``` 870 | 871 | #### A note on identities containing colons 872 | 873 | This module supports HTTP Basic auth slightly differently than how it is specified in [RFC7617](https://datatracker.ietf.org/doc/html/rfc7617). In that spec, it is made clear that usernames cannot contain colons (:) as a colon is used to delineate where a username ends and a password begins. 874 | 875 | In the context of OCPP, the basic-auth username must always be equal to the client's identity. However, since OCPP does not forbid colons in identities, this can possibly lead to a conflict and unexpected behaviours. 876 | 877 | In practice, it's not uncommon to see violations of RFC7617 in the wild. All major browsers allow basic-auth usernames to contain colons, despite the fact that this won't make any sense to the server; RFC7617 acknowledges this fact in its text. The established solution to this problem seems to be to simply ignore it. 878 | 879 | However, in OCPP, since we have the luxury of knowing that the username must always be equal to the client's identity, it is no longer necessary to rely upon a colon to delineate the username from the password. This module makes use of this guarantee to enable identities and passwords to contain as many or as few colons as you wish. 880 | 881 | Additionally, the OCPP security whitepaper recommends passwords consist purely of random bytes (for maximum entropy), although this violates the Basic Auth RFC which requires all passwords to be TEXT (US-ASCII compatible with no control characters). For this reason, this library will not make any presumptions about the character encoding (or otherwise) of the password provided, and present the password as a `Buffer`. 882 | 883 | ```js 884 | const { RPCClient, RPCServer } = require('ocpp-rpc'); 885 | 886 | const cli = new RPCClient({ 887 | identity: "this:is:ok", 888 | password: "as:is:this", 889 | }); 890 | 891 | const server = new RPCServer(); 892 | server.auth((accept, reject, handshake) => { 893 | console.log(handshake.identity); // "this:is:ok" 894 | console.log(handshake.password.toString('utf8')); // "as:is:this" 895 | accept(); 896 | }); 897 | 898 | await server.listen(80); 899 | await cli.connect(); 900 | ``` 901 | 902 | If you prefer to use the more conventional (broken) way of parsing the authorization header using something like the [basic-auth](https://www.npmjs.com/package/basic-auth) module, you can do that too. 903 | 904 | 905 | ```js 906 | const auth = require('basic-auth'); 907 | 908 | const cli = new RPCClient({ 909 | identity: "this:is:broken", 910 | password: "as:is:this", 911 | }); 912 | 913 | const server = new RPCServer(); 914 | server.auth((accept, reject, handshake) => { 915 | const cred = auth.parse(handshake.headers.authorization); 916 | 917 | console.log(cred.name); // "this" 918 | console.log(cred.pass.toString('utf8')); // "is:broken:as:is:this" 919 | accept(); 920 | }); 921 | 922 | await server.listen(80); 923 | await cli.connect(); 924 | ``` 925 | 926 | ### Security Profile 2 927 | 928 | This security profile requires that the central system offers a TLS-secured endpoint in addition to HTTP Basic Authentication [(as per profile 1)](#security-profile-1). 929 | 930 | When implementing TLS, keep in mind that OCPP specifies a minimum TLS version and minimum set of cipher suites for maximal compatibility and security. Node.js natively supports this minimum set of requirements, but there's a couple of things you should keep in mind: 931 | 932 | * The minimum TLS version should be explicitly enforced to prevent a client from using a weak TLS version. The OCPP spec currently sets the minimum TLS version at v1.2 (with v1.1 and v1.0 being permitted for OCPP1.6 only under exceptional circumstances). 933 | * The central server role must support both RSA & ECDSA algorithms, so will need a corresponding certificate for each. 934 | 935 | #### TLS Client Example 936 | 937 | ```js 938 | const { RPCClient } = require('ocpp-rpc'); 939 | 940 | const cli = new RPCClient({ 941 | endpoint: 'wss://localhost', 942 | identity: 'EXAMPLE', 943 | password: 'monkey1', 944 | wsOpts: { minVersion: 'TLSv1.2' } 945 | }); 946 | 947 | await cli.connect(); 948 | ``` 949 | 950 | #### TLS Server Example 951 | 952 | Implementing TLS on the server can be achieved in a couple of different ways. The most direct way is to [create an HTTPS server](https://nodejs.org/api/https.html#httpscreateserveroptions-requestlistener), giving you full end-to-end control over the TLS connectivity. 953 | 954 | ```js 955 | const https = require('https'); 956 | const { RPCServer } = require('ocpp-rpc'); 957 | const { readFile } = require('fs/promises'); 958 | 959 | const server = new RPCServer(); 960 | 961 | const httpsServer = https.createServer({ 962 | cert: [ 963 | await readFile('./server.crt', 'utf8'), // RSA certificate 964 | await readFile('./ec_server.crt', 'utf8'), // ECDSA certificate 965 | ], 966 | key: [ 967 | await readFile('./server.key', 'utf8'), // RSA key 968 | await readFile('./ec_server.key', 'utf8'), // ECDSA key 969 | ], 970 | minVersion: 'TLSv1.2', // require TLS >= v1.2 971 | }); 972 | 973 | httpsServer.on('upgrade', server.handleUpgrade); 974 | httpsServer.listen(443); 975 | 976 | server.auth((accept, reject, handshake) => { 977 | const tlsClient = handshake.request.client; 978 | 979 | if (!tlsClient) { 980 | return reject(); 981 | } 982 | 983 | console.log(`${handshake.identity} connected using TLS:`, { 984 | password: handshake.password, // the HTTP auth password 985 | cert: tlsClient.getCertificate(), // the certificate used by the server 986 | cipher: tlsClient.getCipher(), // the cipher suite 987 | version: tlsClient.getProtocol(), // the TLS version 988 | }); 989 | accept(); 990 | }); 991 | ``` 992 | 993 | Alternatively, your TLS endpoint might be terminated at a different service (e.g. an Ingress controller in a Kubernetes environment or a third-party SaaS reverse-proxy such as Cloudflare). In this case, you may either try to manage your server's TLS through configuration of the aforementioned service, or perhaps by inspecting trusted HTTP headers appended to the request by a proxy. 994 | 995 | ### Security Profile 3 996 | 997 | This security profile requires a TLS-secured central system and client-side certificates; This is also known as "Mutual TLS" (or "mTLS" for short). 998 | 999 | The client-side example is fairly straight-forward: 1000 | 1001 | #### mTLS Client Example 1002 | 1003 | ```js 1004 | const { RPCClient } = require('ocpp-rpc'); 1005 | const { readFile } = require('fs/promises'); 1006 | 1007 | // Read PEM-encoded certificate & key 1008 | const cert = await readFile('./client.crt', 'utf8'); 1009 | const key = await readFile('./client.key', 'utf8'); 1010 | 1011 | const cli = new RPCClient({ 1012 | endpoint: 'wss://localhost', 1013 | identity: 'EXAMPLE', 1014 | wsOpts: { cert, key, minVersion: 'TLSv1.2' } 1015 | }); 1016 | 1017 | await cli.connect(); 1018 | ``` 1019 | 1020 | #### mTLS Server Example 1021 | 1022 | This example is very similar to the example for [security profile 2](#security-profile-2), except for these changes: 1023 | 1024 | * The HTTPS server needs the option `requestCert: true` to allow the client to send its certificate. 1025 | * The client's certificate can be inspected during the auth() callback via `handshake.request.client.getPeerCertificate()`. 1026 | * A HTTP auth password is no longer required. 1027 | 1028 | **Note:** If the client does not present a certificate (or the presented certificate is invalid), [`getPeerCertificate()`](https://nodejs.org/api/tls.html#tlssocketgetpeercertificatedetailed) will return an empty object instead. 1029 | 1030 | ```js 1031 | const https = require('https'); 1032 | const { RPCServer } = require('ocpp-rpc'); 1033 | const { readFile } = require('fs/promises'); 1034 | 1035 | const server = new RPCServer(); 1036 | 1037 | const httpsServer = https.createServer({ 1038 | cert: [ 1039 | await readFile('./server.crt', 'utf8'), // RSA certificate 1040 | await readFile('./ec_server.crt', 'utf8'), // ECDSA certificate 1041 | ], 1042 | key: [ 1043 | await readFile('./server.key', 'utf8'), // RSA key 1044 | await readFile('./ec_server.key', 'utf8'), // ECDSA key 1045 | ], 1046 | minVersion: 'TLSv1.2', // require TLS >= v1.2 1047 | requestCert: true, // ask client for a certificate 1048 | }); 1049 | 1050 | httpsServer.on('upgrade', server.handleUpgrade); 1051 | httpsServer.listen(443); 1052 | 1053 | server.auth((accept, reject, handshake) => { 1054 | const tlsClient = handshake.request.client; 1055 | 1056 | if (!tlsClient) { 1057 | return reject(); 1058 | } 1059 | 1060 | console.log(`${handshake.identity} connected using TLS:`, { 1061 | clientCert: tlsClient.getPeerCertificate(), // the certificate used by the client 1062 | serverCert: tlsClient.getCertificate(), // the certificate used by the server 1063 | cipher: tlsClient.getCipher(), // the cipher suite 1064 | version: tlsClient.getProtocol(), // the TLS version 1065 | }); 1066 | 1067 | accept(); 1068 | }); 1069 | ``` 1070 | 1071 | ## RPCClient state lifecycle 1072 | 1073 | ![RPCClient state lifecycle](./docs/statelifecycle.png) 1074 | 1075 | **CLOSED** 1076 | * RPC calls while in this state are rejected. 1077 | * RPC responses will be silently dropped. 1078 | 1079 | **CONNECTING** 1080 | * RPC calls & responses while in this state will be queued. 1081 | 1082 | **OPEN** 1083 | * Previously queued messages are sent to the server upon entering this state. 1084 | * RPC calls & responses now flow freely. 1085 | 1086 | **CLOSING** 1087 | * RPC calls while in this state are rejected. 1088 | * RPC responses will be silently dropped. 1089 | 1090 | ## Upgrading from 1.X -> 2.0 1091 | 1092 | Breaking changes: 1093 | * The `RPCClient` event [`'strictValidationFailure'`](#event-strictvalidationfailure) now fires for both inbound & outbound requests & responses. 1094 | * The `RPCClient` event [`'strictValidationFailure'`](#event-strictvalidationfailure) emits an object containing more information than was previously available. The Error which was previously emitted is now a member of this object. 1095 | * The `password` option in the `RPCClient` [constructor](#new-rpcclientoptions) can now be supplied as a `Buffer`. If a string is provided, it will be encoded as utf8. 1096 | * The `password` field of `RPCServerClient`'s [`handshake`](#clienthandshake) object is now always provided as a Buffer instead of a string. Use `password.toString('utf8')` to convert back to a string as per previous versions. 1097 | 1098 | ## License 1099 | 1100 | [MIT](LICENSE.md) 1101 | -------------------------------------------------------------------------------- /docs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikuso/ocpp-rpc/53919880be61fe8e326128900f84a931a870b0f8/docs/logo.png -------------------------------------------------------------------------------- /docs/statelifecycle.drawio: -------------------------------------------------------------------------------- 1 | 3Vpde6I4FP41Pt29sA8QELxcre3stGPddWZ3eokShG4kNISq/fUbIJFPlVFBZ2ovyOGEhJP3PefkhA4YLtcPxPSdL9iCqKNI1roD7jqKIquK0on+JWuTSFRdSwQL4lpcKRVM3Q/IhRKXhq4Fg5wixRhR188L59jz4JzmZCYheJVXszHKj+qbCz6ilAqmcxPBktq/rkWdRGooeir/BN2FI0aWe/3kztIUyvzBgWNaeJURgVEHDAnGNLlarocQRcYTdkn63e+4u50YgR6t0+Ej0NafJX/89pcxpo/euvv6CXVlPtt3E4X8jbklf/udT5tuhC2gxUzDm5hQBy+wZ6JRKh0QHHoWjAaUWCvVecLYZ0KZCV8hpRu+zmZIMRM5dIn4Xbh26ffM9Uv0qFuNt+7W/MlxYyMaHiWb79lGplfUTLvFLdGvbEBu0wCHZA73WE0sKTXJAtI9ioArRpbLDMEX6AHiJWQzYgoEIpO673nQmRy7i61eurzsgq/wD6y2mHdmtTtKD7E3GMzYxSK6GD49T0d3QsyG2d4p4SFd7Wi5Vo5L4dQ3Y8utmDfIr+xOa79DQuF6r3X43S2VuC/p93h7lWEmFzkZUgq189tTLttTG3ACudiL/B7Bvs9spG1NOiOpQROJjZlJIheGMImf0nsLcaIAbFtif1lR0hcu3ajPjeUGfLybzJolT9yxbC3SOKXuS+bOIRqnzH3JEffsNFZq0lgFV0VjoJedNsIBjFx2ExiLHu56i8YA9houfaFvkrmQJOCSe+eEoFQPgnI+kugNhpK6GARaTQxyZ9mVbg1Z4Z1q45I/boLdGC5CBdt2wGZXBO521BNcqHI4JD1PRuPqgLQL7anERmHgsOf/PRlG8DfjGb2FkA126XDWk/PhzADlcCYrDcUz73VO7vyJ++1t9KS5j85yCj+65bXw4Cox3hC57E0vmxQeF01aSwrVmkxW6iaFKZM1Vek3xeRUR6RZWh6XslQEXPKKvFsDPkGt8AlF2CHEtoDwMFFtF6HhNuoBWY1+5yGwbBQIrJcJDCr4CxrLR7VD+WjRPcKAmjPkBk6DKSr2oXeNyemV7zGNuu5EuarkVCknpwcwSKAdBhx/1xJcauaJha2KfHk0gCtDg3HYlRMHL2dh0ErOpYKCy1bLLlutcNlqYy67XIBjdFmZseu0mbNlU4OzAM//Y8velIcW7Gxwj/fLMhPUrQUKKrTPzPc3/Z/PE+TcPU718ZcJ7g08o1tRuqqBJEmqRlKh/lBCTynvyJQW4M1l0aWcXqM6BK/IGJkk9P4+tqKAXQERTeNQagl2e6e5twT9PB6Phl//HD8cu+ufE2hSmPOdJR1eBMhUBoJyaGq5JAD6hQq3VrMkYDQVn0BFANcHELGtl356vra/8vdrx42zF/5aInBVwe6SGZ1i3Go50uhVOR1oMacDlTkdgQkmgiixI7DBDbcIqpeo1p/K2aP25G0dGAkqnq/E1xJnK+pCVee+J0TcGWJhlg2xLRinMbWoavLdjet1bRTTUaiC+4pQHoGZzcacMVjlI/qlg3Wh/Kf1K4J11Way35TjUSsKp9ogcHCIrBhs269k7s9Rbbk+T1A4t2uuOifiyc/2BYha4Ql+knTuqHJtARDKNSDiiINcTT3qIPcPQsxNRsGPTmyCzGiFkx+9V9h+9AuflB3Q78mggNBkBq2fKovFuJYktVc4UutXnBS1WnYElV8uZSJFbP+zRInWT2gyn2vU2cIddlonJIy1N3k/fiZsKBpoxSkUahKC9DudQkFflrT9HVRjf4ddbmRd+b7HOBXWTL/DTdTTr5nB6H8= -------------------------------------------------------------------------------- /docs/statelifecycle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mikuso/ocpp-rpc/53919880be61fe8e326128900f84a931a870b0f8/docs/statelifecycle.png -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import RPCClient, { IHandlersOption } from "./lib/client"; 2 | import RPCServer from "./lib/server"; 3 | export { createRPCError } from "./lib/util"; 4 | export { createValidator } from "./lib/validator"; 5 | export { NOREPLY } from "./lib/symbols"; 6 | export { RPCError, RPCFormatViolationError, RPCFormationViolationError, RPCFrameworkError, RPCGenericError, RPCInternalError, RPCMessageTypeNotSupportedError, RPCNotImplementedError, RPCNotSupportedError, RPCOccurenceConstraintViolationError, RPCOccurrenceConstraintViolationError, RPCPropertyConstraintViolationError, RPCProtocolError, RPCSecurityError, RPCTypeConstraintViolationError, TimeoutError, UnexpectedHttpResponse, WebsocketUpgradeError, } from "./lib/errors"; 7 | export { RPCServer, RPCClient, IHandlersOption }; 8 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const RPCClient = require('./lib/client'); 2 | const RPCServer = require('./lib/server'); 3 | const errors = require('./lib/errors'); 4 | const symbols = require('./lib/symbols'); 5 | const { createRPCError } = require('./lib/util'); 6 | const { createValidator } = require('./lib/validator'); 7 | 8 | module.exports = { 9 | RPCServer, 10 | RPCClient, 11 | createRPCError, 12 | createValidator, 13 | ...errors, 14 | ...symbols, 15 | }; -------------------------------------------------------------------------------- /lib/client.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import EventEmitter from "events"; 3 | import { Validator } from "./validator"; 4 | import WebSocket from "ws"; 5 | import EventBuffer from "./event-buffer"; 6 | export interface RPC_ClientOptions { 7 | identity: string; 8 | reconnect: boolean; 9 | callTimeoutMs: number; 10 | pingIntervalMs: number; 11 | deferPingsOnActivity: boolean; 12 | respondWithDetailedErrors: boolean; 13 | callConcurrency: number; 14 | strictMode: boolean | string[]; 15 | strictModeValidators: Validator[]; 16 | maxBadMessages: number; 17 | protocols: string[]; 18 | endpoint?: string; 19 | password: string | null; 20 | wsOpts: any; 21 | headers: any; 22 | maxReconnects: number; 23 | query?: string | Record; 24 | backoff: { 25 | initialDelay: number; 26 | maxDelay: number; 27 | factor: number; 28 | randomisationFactor: number; 29 | }; 30 | } 31 | export interface IHandlersOption { 32 | messageId?: string; 33 | method?: string; 34 | params?: Record; 35 | signal?: AbortSignal; 36 | reply?: unknown; 37 | } 38 | type IHandlers = ({ params, reply, method, signal, messageId, }: IHandlersOption) => Promise>; 39 | declare class RPC_Client extends EventEmitter { 40 | protected _identity?: string; 41 | private _wildcardHandler; 42 | private _handlers; 43 | protected _state: number; 44 | private _callQueue; 45 | protected _ws?: WebSocket; 46 | private _wsAbortController?; 47 | private _keepAliveAbortController?; 48 | private _pendingPingResponse; 49 | private _lastPingTime; 50 | private _closePromise?; 51 | private _protocolOptions; 52 | protected _protocol?: string; 53 | private _strictProtocols; 54 | private _strictValidators?; 55 | private _pendingCalls; 56 | private _pendingResponses; 57 | private _outboundMsgBuffer; 58 | private _connectedOnce; 59 | private _backoffStrategy?; 60 | private _badMessagesCount; 61 | private _reconnectAttempt; 62 | protected _options: RPC_ClientOptions; 63 | private _connectionUrl; 64 | private _connectPromise; 65 | private _nextPingTimeout; 66 | static OPEN: number; 67 | static CONNECTING: number; 68 | static CLOSING: number; 69 | static CLOSED: number; 70 | constructor({ ...options }: RPC_ClientOptions); 71 | get identity(): string | undefined; 72 | get protocol(): string | undefined; 73 | get state(): number; 74 | reconfigure(options: RPC_ClientOptions): void; 75 | /** 76 | * Attempt to connect to the RPCServer. 77 | * @returns {Promise} Resolves when connected, rejects on failure 78 | */ 79 | connect(): Promise; 80 | private _keepAlive; 81 | private _tryReconnect; 82 | private _beginConnect; 83 | /** 84 | * Start consuming from a WebSocket 85 | * @param {WebSocket} ws - A WebSocket instance 86 | * @param {EventBuffer} leadMsgBuffer - A buffer which traps all 'message' events 87 | */ 88 | protected _attachWebsocket(ws: WebSocket, leadMsgBuffer?: EventBuffer): void; 89 | private _handleDisconnect; 90 | private _rejectPendingCalls; 91 | /** 92 | * Call a method on a remote RPCClient or RPCServerClient. 93 | * @param {string} method - The RPC method to call. 94 | * @param {*} params - A value to be passed as params to the remote handler. 95 | * @param {Object} options - Call options 96 | * @param {number} options.callTimeoutMs - Call timeout (in milliseconds) 97 | * @param {AbortSignal} options.signal - AbortSignal to cancel the call. 98 | * @param {boolean} options.noReply - If set to true, the call will return immediately. 99 | * @returns Promise<*> - Response value from the remote handler. 100 | */ 101 | call(method: any, params?: any, options?: Record): Promise; 102 | private _call; 103 | /** 104 | * Closes the RPCClient. 105 | * @param {Object} options - Close options 106 | * @param {number} options.code - The websocket CloseEvent code. 107 | * @param {string} options.reason - The websocket CloseEvent reason. 108 | * @param {boolean} options.awaitPending - Wait for in-flight calls & responses to complete before closing. 109 | * @param {boolean} options.force - Terminate websocket immediately without passing code, reason, or waiting. 110 | * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code CloseEvent codes} 111 | * @returns Promise - The CloseEvent (code & reason) for closure. May be different from requested code & reason. 112 | */ 113 | close({ code, reason, awaitPending, force, }?: { 114 | code?: number; 115 | reason?: string; 116 | awaitPending?: any; 117 | force?: any; 118 | }): Promise<{ 119 | code: number | undefined; 120 | reason: string | undefined; 121 | } | undefined>; 122 | private _awaitUntilPendingSettled; 123 | private _deferNextPing; 124 | private _onMessage; 125 | private _onCall; 126 | private _onCallResult; 127 | private _onCallError; 128 | /** 129 | * Send a message to the RPCServer. While socket is connecting, the message is queued and send when open. 130 | * @param {Buffer|String} message - String to send via websocket 131 | */ 132 | sendRaw(message: string): void; 133 | /** 134 | * 135 | * @param {string} [method] - The name of the handled method. 136 | */ 137 | removeHandler(method: string): void; 138 | removeAllHandlers(): void; 139 | /** 140 | * 141 | * @param {string} [method] - The name of the RPC method to handle. 142 | * @param {Function} handler - A function that can handle incoming calls for this method. 143 | */ 144 | handle(method: string | IHandlers, handler?: IHandlers): void; 145 | } 146 | export default RPC_Client; 147 | -------------------------------------------------------------------------------- /lib/client.js: -------------------------------------------------------------------------------- 1 | const { randomUUID} = require('crypto'); 2 | const { EventEmitter, once } = require('events'); 3 | const { setTimeout } = require('timers/promises'); 4 | const { setTimeout: setTimeoutCb } = require('timers'); 5 | const WebSocket = require('ws'); 6 | const { ExponentialStrategy } = require('backoff'); 7 | const { CONNECTING, OPEN, CLOSING, CLOSED } = WebSocket; 8 | const { NOREPLY } = require('./symbols'); 9 | const { TimeoutError, UnexpectedHttpResponse, RPCFrameworkError, RPCGenericError, RPCMessageTypeNotSupportedError } = require('./errors'); 10 | const { getErrorPlainObject, createRPCError, getPackageIdent } = require('./util'); 11 | const Queue = require('./queue'); 12 | const EventBuffer = require('./event-buffer'); 13 | const standardValidators = require('./standard-validators'); 14 | const {isValidStatusCode} = require('./ws-util'); 15 | 16 | const MSG_CALL = 2; 17 | const MSG_CALLRESULT = 3; 18 | const MSG_CALLERROR = 4; 19 | 20 | class RPCClient extends EventEmitter { 21 | constructor(options) { 22 | super(); 23 | 24 | this._identity = undefined; 25 | this._wildcardHandler = null; 26 | this._handlers = new Map(); 27 | this._state = CLOSED; 28 | this._callQueue = new Queue(); 29 | 30 | this._ws = undefined; 31 | this._wsAbortController = undefined; 32 | this._keepAliveAbortController = undefined; 33 | this._pendingPingResponse = false; 34 | this._lastPingTime = 0; 35 | this._closePromise = undefined; 36 | this._protocolOptions = []; 37 | this._protocol = undefined; 38 | this._strictProtocols = []; 39 | this._strictValidators = undefined; 40 | 41 | this._pendingCalls = new Map(); 42 | this._pendingResponses = new Map(); 43 | this._outboundMsgBuffer = []; 44 | this._connectedOnce = false; 45 | 46 | this._backoffStrategy = undefined; 47 | this._badMessagesCount = 0; 48 | this._reconnectAttempt = 0; 49 | 50 | this._options = { 51 | // defaults 52 | endpoint: 'ws://localhost', 53 | password: null, 54 | callTimeoutMs: 1000*60, 55 | pingIntervalMs: 1000*30, 56 | deferPingsOnActivity: false, 57 | wsOpts: {}, 58 | headers: {}, 59 | protocols: [], 60 | reconnect: true, 61 | maxReconnects: Infinity, 62 | respondWithDetailedErrors: false, 63 | callConcurrency: 1, 64 | maxBadMessages: Infinity, 65 | strictMode: false, 66 | strictModeValidators: [], 67 | backoff: { 68 | initialDelay: 1000, 69 | maxDelay: 10*1000, 70 | factor: 2, 71 | randomisationFactor: 0.25, 72 | } 73 | }; 74 | 75 | this.reconfigure(options || {}); 76 | } 77 | 78 | get identity() { 79 | return this._identity; 80 | } 81 | 82 | get protocol() { 83 | return this._protocol; 84 | } 85 | 86 | get state() { 87 | return this._state; 88 | } 89 | 90 | reconfigure(options) { 91 | const newOpts = Object.assign(this._options, options); 92 | 93 | if (!newOpts.identity) { 94 | throw Error(`'identity' is required`); 95 | } 96 | 97 | if (newOpts.strictMode && !newOpts.protocols?.length) { 98 | throw Error(`strictMode requires at least one subprotocol`); 99 | } 100 | 101 | const strictValidators = [...standardValidators]; 102 | if (newOpts.strictModeValidators) { 103 | strictValidators.push(...newOpts.strictModeValidators); 104 | } 105 | 106 | this._strictValidators = strictValidators.reduce((svs, v) => { 107 | svs.set(v.subprotocol, v); 108 | return svs; 109 | }, new Map()); 110 | 111 | this._strictProtocols = []; 112 | if (Array.isArray(newOpts.strictMode)) { 113 | this._strictProtocols = newOpts.strictMode; 114 | } else if (newOpts.strictMode) { 115 | this._strictProtocols = newOpts.protocols; 116 | } 117 | 118 | const missingValidator = this._strictProtocols.find(protocol => !this._strictValidators.has(protocol)); 119 | if (missingValidator) { 120 | throw Error(`Missing strictMode validator for subprotocol '${missingValidator}'`); 121 | } 122 | 123 | this._callQueue.setConcurrency(newOpts.callConcurrency); 124 | this._backoffStrategy = new ExponentialStrategy(newOpts.backoff); 125 | 126 | if ('pingIntervalMs' in options) { 127 | this._keepAlive(); 128 | } 129 | } 130 | 131 | /** 132 | * Attempt to connect to the RPCServer. 133 | * @returns {Promise} Resolves when connected, rejects on failure 134 | */ 135 | async connect() { 136 | this._protocolOptions = this._options.protocols ?? []; 137 | this._protocol = undefined; 138 | this._identity = this._options.identity; 139 | 140 | let connUrl = this._options.endpoint + '/' + encodeURIComponent(this._options.identity); 141 | if (this._options.query) { 142 | const searchParams = new URLSearchParams(this._options.query); 143 | connUrl += '?' + searchParams.toString(); 144 | } 145 | 146 | this._connectionUrl = connUrl; 147 | 148 | if (this._state === CLOSING) { 149 | throw Error(`Cannot connect while closing`); 150 | } 151 | 152 | if (this._state === OPEN) { 153 | // no-op 154 | return; 155 | } 156 | 157 | if (this._state === CONNECTING) { 158 | return this._connectPromise; 159 | } 160 | 161 | try { 162 | return await this._beginConnect(); 163 | } catch (err) { 164 | 165 | this._state = CLOSED; 166 | this.emit('close', {code: 1006, reason: "Abnormal Closure"}); 167 | throw err; 168 | } 169 | } 170 | 171 | /** 172 | * Send a message to the RPCServer. While socket is connecting, the message is queued and send when open. 173 | * @param {Buffer|String} message - String to send via websocket 174 | */ 175 | sendRaw(message) { 176 | if ([OPEN, CLOSING].includes(this._state) && this._ws) { 177 | // can send while closing so long as websocket doesn't mind 178 | this._ws.send(message); 179 | this.emit('message', {message, outbound: true}); 180 | } else if (this._state === CONNECTING) { 181 | this._outboundMsgBuffer.push(message); 182 | } else { 183 | throw Error(`Cannot send message in this state`); 184 | } 185 | } 186 | 187 | /** 188 | * Closes the RPCClient. 189 | * @param {Object} options - Close options 190 | * @param {number} options.code - The websocket CloseEvent code. 191 | * @param {string} options.reason - The websocket CloseEvent reason. 192 | * @param {boolean} options.awaitPending - Wait for in-flight calls & responses to complete before closing. 193 | * @param {boolean} options.force - Terminate websocket immediately without passing code, reason, or waiting. 194 | * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code CloseEvent codes} 195 | * @returns Promise - The CloseEvent (code & reason) for closure. May be different from requested code & reason. 196 | */ 197 | async close({code, reason, awaitPending, force} = {}) { 198 | if ([CLOSED, CLOSING].includes(this._state)) { 199 | // no-op 200 | return this._closePromise; 201 | } 202 | 203 | if (this._state === OPEN) { 204 | this._closePromise = (async () => { 205 | 206 | if (force || !awaitPending) { 207 | // reject pending calls 208 | this._rejectPendingCalls("Client going away"); 209 | } 210 | 211 | if (force) { 212 | this._ws.terminate(); 213 | } else { 214 | // await pending calls & responses 215 | await this._awaitUntilPendingSettled(); 216 | if (!code || !isValidStatusCode(code)) { 217 | code = 1000; 218 | } 219 | this._ws.close(code, reason); 220 | } 221 | 222 | let [codeRes, reasonRes] = await once(this._ws, 'close'); 223 | 224 | if (reasonRes instanceof Buffer) { 225 | reasonRes = reasonRes.toString('utf8'); 226 | } 227 | 228 | return {code: codeRes, reason: reasonRes}; 229 | 230 | })(); 231 | 232 | this._state = CLOSING; 233 | this._connectedOnce = false; 234 | this.emit('closing'); 235 | 236 | return this._closePromise; 237 | 238 | } else if (this._wsAbortController) { 239 | 240 | const result = this._connectedOnce ? 241 | {code, reason} : 242 | {code: 1001, reason: "Connection aborted"}; 243 | 244 | this._wsAbortController.abort(); 245 | this._state = CLOSED; 246 | this._connectedOnce = false; 247 | this.emit('close', result); 248 | return result; 249 | } 250 | } 251 | 252 | /** 253 | * 254 | * @param {string} [method] - The name of the RPC method to handle. 255 | * @param {Function} handler - A function that can handle incoming calls for this method. 256 | */ 257 | handle(method, handler) { 258 | if (method instanceof Function && !handler) { 259 | this._wildcardHandler = method; 260 | } else { 261 | this._handlers.set(method, handler); 262 | } 263 | } 264 | 265 | /** 266 | * 267 | * @param {string} [method] - The name of the handled method. 268 | */ 269 | removeHandler(method) { 270 | if (method == null) { 271 | this._wildcardHandler = null; 272 | } else { 273 | this._handlers.delete(method); 274 | } 275 | } 276 | 277 | removeAllHandlers() { 278 | this._wildcardHandler = null; 279 | this._handlers.clear(); 280 | } 281 | 282 | /** 283 | * Call a method on a remote RPCClient or RPCServerClient. 284 | * @param {string} method - The RPC method to call. 285 | * @param {*} params - A value to be passed as params to the remote handler. 286 | * @param {Object} options - Call options 287 | * @param {number} options.callTimeoutMs - Call timeout (in milliseconds) 288 | * @param {AbortSignal} options.signal - AbortSignal to cancel the call. 289 | * @param {boolean} options.noReply - If set to true, the call will return immediately. 290 | * @returns Promise<*> - Response value from the remote handler. 291 | */ 292 | async call(method, params, options = {}) { 293 | return await this._callQueue.push(this._call.bind(this, method, params, options)); 294 | } 295 | 296 | async _call(method, params, options = {}) { 297 | const timeoutMs = options.callTimeoutMs ?? this._options.callTimeoutMs; 298 | 299 | if ([CLOSED, CLOSING].includes(this._state)) { 300 | throw Error(`Cannot make call while socket not open`); 301 | } 302 | 303 | const msgId = randomUUID(); 304 | const payload = [MSG_CALL, msgId, method, params]; 305 | 306 | if (this._strictProtocols.includes(this._protocol)) { 307 | // perform some strict-mode checks 308 | const validator = this._strictValidators.get(this._protocol); 309 | try { 310 | validator.validate(`urn:${method}.req`, params); 311 | } catch (error) { 312 | this.emit('strictValidationFailure', { 313 | messageId: msgId, 314 | method, 315 | params, 316 | result: null, 317 | error, 318 | outbound: true, 319 | isCall: true, 320 | }); 321 | throw error; 322 | } 323 | } 324 | 325 | const pendingCall = {msgId, method, params}; 326 | 327 | if (!options.noReply) { 328 | const timeoutAc = new AbortController(); 329 | 330 | const cleanup = () => { 331 | if (pendingCall.timeout) { 332 | timeoutAc.abort(); 333 | } 334 | this._pendingCalls.delete(msgId); 335 | }; 336 | 337 | pendingCall.abort = (reason) => { 338 | const err = Error(reason); 339 | err.name = "AbortError"; 340 | pendingCall.reject(err); 341 | }; 342 | 343 | if (options.signal) { 344 | once(options.signal, 'abort').then(() => { 345 | pendingCall.abort(options.signal.reason); 346 | }); 347 | } 348 | 349 | pendingCall.promise = new Promise((resolve, reject) => { 350 | pendingCall.resolve = (...args) => { 351 | cleanup(); 352 | resolve(...args); 353 | }; 354 | pendingCall.reject = (...args) => { 355 | cleanup(); 356 | reject(...args); 357 | }; 358 | }); 359 | 360 | if (timeoutMs && timeoutMs > 0 && timeoutMs < Infinity) { 361 | const timeoutError = new TimeoutError("Call timeout"); 362 | pendingCall.timeout = setTimeout(timeoutMs, null, {signal: timeoutAc.signal}).then(() => { 363 | pendingCall.reject(timeoutError); 364 | }).catch(err=>{}); 365 | } 366 | 367 | this._pendingCalls.set(msgId, pendingCall); 368 | } 369 | 370 | this.emit('call', {outbound: true, payload}); 371 | this.sendRaw(JSON.stringify(payload)); 372 | 373 | if (options.noReply) { 374 | return; 375 | } 376 | 377 | try { 378 | const result = await pendingCall.promise; 379 | 380 | this.emit('callResult', { 381 | outbound: true, 382 | messageId: msgId, 383 | method, 384 | params, 385 | result, 386 | }); 387 | 388 | return result; 389 | 390 | } catch (err) { 391 | 392 | this.emit('callError', { 393 | outbound: true, 394 | messageId: msgId, 395 | method, 396 | params, 397 | error: err, 398 | }); 399 | 400 | throw err; 401 | } 402 | } 403 | 404 | /** 405 | * Start consuming from a WebSocket 406 | * @param {WebSocket} ws - A WebSocket instance 407 | * @param {EventBuffer} leadMsgBuffer - A buffer which traps all 'message' events 408 | */ 409 | _attachWebsocket(ws, leadMsgBuffer) { 410 | ws.once('close', (code, reason) => this._handleDisconnect({code, reason})); 411 | ws.on('error', err => this.emit('socketError', err)); 412 | ws.on('ping', () => { 413 | if (this._options.deferPingsOnActivity) { 414 | this._deferNextPing(); 415 | } 416 | }); 417 | ws.on('pong', () => { 418 | if (this._options.deferPingsOnActivity) { 419 | this._deferNextPing(); 420 | } 421 | this._pendingPingResponse = false; 422 | const rtt = Date.now() - this._lastPingTime; 423 | this.emit('ping', {rtt}); 424 | }); 425 | 426 | this._keepAlive(); 427 | 428 | process.nextTick(() => { 429 | if (leadMsgBuffer) { 430 | const messages = leadMsgBuffer.condense(); 431 | messages.forEach(([msg]) => this._onMessage(msg)); 432 | } 433 | ws.on('message', msg => this._onMessage(msg)); 434 | }); 435 | } 436 | 437 | _rejectPendingCalls(abortReason) { 438 | const pendingCalls = Array.from(this._pendingCalls.values()); 439 | const pendingResponses = Array.from(this._pendingResponses.values()); 440 | [...pendingCalls, ...pendingResponses].forEach(c => c.abort(abortReason)); 441 | } 442 | 443 | async _awaitUntilPendingSettled() { 444 | const pendingCalls = Array.from(this._pendingCalls.values()); 445 | const pendingResponses = Array.from(this._pendingResponses.values()); 446 | return await Promise.allSettled([ 447 | ...pendingResponses.map(c => c.promise), 448 | ...pendingCalls.map(c => c.promise), 449 | ]); 450 | } 451 | 452 | _handleDisconnect({code, reason}) { 453 | if (reason instanceof Buffer) { 454 | reason = reason.toString('utf8'); 455 | } 456 | 457 | // reject any outstanding calls/responses 458 | this._rejectPendingCalls("Client disconnected"); 459 | this._keepAliveAbortController?.abort(); 460 | 461 | this.emit('disconnect', {code, reason}); 462 | 463 | if (this._state === CLOSED) { 464 | // nothing to do here 465 | return; 466 | } 467 | 468 | if (this._state !== CLOSING && this._options.reconnect) { 469 | 470 | this._tryReconnect(); 471 | 472 | } else { 473 | 474 | this._state = CLOSED; 475 | this.emit('close', {code, reason}); 476 | } 477 | } 478 | 479 | _beginConnect() { 480 | this._connectPromise = (async () => { 481 | this._wsAbortController = new AbortController(); 482 | 483 | const wsOpts = Object.assign({ 484 | // defaults 485 | noDelay: true, 486 | signal: this._wsAbortController.signal, 487 | headers: { 488 | 'user-agent': getPackageIdent() 489 | }, 490 | }, this._options.wsOpts ?? {}); 491 | 492 | Object.assign(wsOpts.headers, this._options.headers); 493 | 494 | if (this._options.password != null) { 495 | const usernameBuffer = Buffer.from(this._identity + ':'); 496 | let passwordBuffer = this._options.password; 497 | if (typeof passwordBuffer === 'string') { 498 | passwordBuffer = Buffer.from(passwordBuffer, 'utf8'); 499 | } 500 | 501 | const b64 = Buffer.concat([usernameBuffer, passwordBuffer]).toString('base64'); 502 | wsOpts.headers.authorization = 'Basic ' + b64; 503 | } 504 | 505 | this._ws = new WebSocket( 506 | this._connectionUrl, 507 | this._protocolOptions, 508 | wsOpts, 509 | ); 510 | 511 | const leadMsgBuffer = new EventBuffer(this._ws, 'message'); 512 | let upgradeResponse; 513 | 514 | try { 515 | await new Promise((resolve, reject) => { 516 | this._ws.once('unexpected-response', (request, response) => { 517 | const err = new UnexpectedHttpResponse(response.statusMessage); 518 | err.code = response.statusCode; 519 | err.request = request; 520 | err.response = response; 521 | reject(err); 522 | }); 523 | this._ws.once('upgrade', (response) => { 524 | upgradeResponse = response; 525 | }); 526 | this._ws.once('error', err => reject(err)); 527 | this._ws.once('open', () => resolve()); 528 | }); 529 | 530 | // record which protocol was selected 531 | if (this._protocol === undefined) { 532 | this._protocol = this._ws.protocol; 533 | this.emit('protocol', this._protocol); 534 | } 535 | 536 | // limit protocol options in case of future reconnect 537 | this._protocolOptions = this._protocol ? [this._protocol] : []; 538 | 539 | this._reconnectAttempt = 0; 540 | this._backoffStrategy.reset(); 541 | this._state = OPEN; 542 | this._connectedOnce = true; 543 | this._pendingPingResponse = false; 544 | 545 | this._attachWebsocket(this._ws, leadMsgBuffer); 546 | 547 | // send queued messages 548 | if (this._outboundMsgBuffer.length > 0) { 549 | const buff = this._outboundMsgBuffer; 550 | this._outboundMsgBuffer = []; 551 | buff.forEach(msg => this.sendRaw(msg)); 552 | } 553 | 554 | const result = { 555 | response: upgradeResponse 556 | }; 557 | 558 | this.emit('open', result); 559 | return result; 560 | 561 | } catch (err) { 562 | 563 | this._ws.terminate(); 564 | if (upgradeResponse) { 565 | err.upgrade = upgradeResponse; 566 | } 567 | throw err; 568 | } 569 | 570 | })(); 571 | 572 | this._state = CONNECTING; 573 | this.emit('connecting', {protocols: this._protocolOptions}); 574 | 575 | return this._connectPromise; 576 | } 577 | 578 | _deferNextPing() { 579 | if (!this._nextPingTimeout) { 580 | return; 581 | } 582 | 583 | this._nextPingTimeout.refresh(); 584 | } 585 | 586 | async _keepAlive() { 587 | // abort any previously running keepAlive 588 | this._keepAliveAbortController?.abort(); 589 | 590 | const timerEmitter = new EventEmitter(); 591 | const nextPingTimeout = setTimeoutCb(()=>{ 592 | timerEmitter.emit('next') 593 | }, this._options.pingIntervalMs); 594 | this._nextPingTimeout = nextPingTimeout; 595 | 596 | try { 597 | if (this._state !== OPEN) { 598 | // don't start pinging if connection not open 599 | return; 600 | } 601 | 602 | if (!this._options.pingIntervalMs || this._options.pingIntervalMs <= 0 || this._options.pingIntervalMs > 2147483647) { 603 | // don't ping for unusuable intervals 604 | return; 605 | } 606 | 607 | // setup new abort controller 608 | this._keepAliveAbortController = new AbortController(); 609 | 610 | while (true) { 611 | await once(timerEmitter, 'next', {signal: this._keepAliveAbortController.signal}), 612 | this._keepAliveAbortController.signal.throwIfAborted(); 613 | 614 | if (this._state !== OPEN) { 615 | // keepalive no longer required 616 | break; 617 | } 618 | 619 | if (this._pendingPingResponse) { 620 | // we didn't get a response to our last ping 621 | throw Error("Ping timeout"); 622 | } 623 | 624 | this._lastPingTime = Date.now(); 625 | this._pendingPingResponse = true; 626 | this._ws.ping(); 627 | nextPingTimeout.refresh(); 628 | } 629 | 630 | } catch (err) { 631 | // console.log('keepalive failed', err); 632 | if (err.name !== 'AbortError') { 633 | // throws on ws.ping() error 634 | this._ws.terminate(); 635 | } 636 | } finally { 637 | clearTimeout(nextPingTimeout); 638 | } 639 | } 640 | 641 | async _tryReconnect() { 642 | this._reconnectAttempt++; 643 | if (this._reconnectAttempt > this._options.maxReconnects) { 644 | // give up 645 | this.close({code: 1001, reason: "Giving up"}); 646 | } else { 647 | 648 | try { 649 | this._state = CONNECTING; 650 | const delay = this._backoffStrategy.next(); 651 | await setTimeout(delay, null, {signal: this._wsAbortController.signal}); 652 | 653 | await this._beginConnect().catch(async (err) => { 654 | 655 | const intolerableErrors = [ 656 | 'Maximum redirects exceeded', 657 | 'Server sent no subprotocol', 658 | 'Server sent an invalid subprotocol', 659 | 'Server sent a subprotocol but none was requested', 660 | 'Invalid Sec-WebSocket-Accept header', 661 | ]; 662 | 663 | if (intolerableErrors.includes(err.message)) { 664 | throw err; 665 | } 666 | 667 | this._tryReconnect(); 668 | 669 | }).catch(err => { 670 | 671 | this.close({code: 1001, reason: err.message}); 672 | 673 | }); 674 | } catch (err) { 675 | // aborted timeout 676 | return; 677 | } 678 | } 679 | } 680 | 681 | _onMessage(buffer) { 682 | if (this._options.deferPingsOnActivity) { 683 | this._deferNextPing(); 684 | } 685 | 686 | const message = buffer.toString('utf8'); 687 | 688 | if (!message.length) { 689 | // ignore empty messages 690 | // for compatibility with some particular charge point vendors (naming no names) 691 | return; 692 | } 693 | 694 | this.emit('message', {message, outbound: false}); 695 | 696 | let msgId = '-1'; 697 | let messageType; 698 | 699 | try { 700 | let payload; 701 | try { 702 | payload = JSON.parse(message); 703 | } catch (err) { 704 | throw createRPCError("RpcFrameworkError", "Message must be a JSON structure", {}); 705 | } 706 | 707 | if (!Array.isArray(payload)) { 708 | throw createRPCError("RpcFrameworkError", "Message must be an array", {}); 709 | } 710 | 711 | const [messageTypePart, msgIdPart, ...more] = payload; 712 | 713 | if (typeof messageTypePart !== 'number') { 714 | throw createRPCError("RpcFrameworkError", "Message type must be a number", {}); 715 | } 716 | 717 | // Extension fallback mechanism 718 | // (see section 4.4 of OCPP2.0.1J) 719 | if (![MSG_CALL, MSG_CALLERROR, MSG_CALLRESULT].includes(messageTypePart)) { 720 | throw createRPCError("MessageTypeNotSupported", "Unrecognised message type", {}); 721 | } 722 | 723 | messageType = messageTypePart; 724 | 725 | if (typeof msgIdPart !== 'string') { 726 | throw createRPCError("RpcFrameworkError", "Message ID must be a string", {}); 727 | } 728 | 729 | msgId = msgIdPart; 730 | 731 | switch (messageType) { 732 | case MSG_CALL: 733 | const [method, params] = more; 734 | if (typeof method !== 'string') { 735 | throw new RPCFrameworkError("Method must be a string"); 736 | } 737 | this.emit('call', {outbound: false, payload}); 738 | this._onCall(msgId, method, params); 739 | break; 740 | case MSG_CALLRESULT: 741 | const [result] = more; 742 | this.emit('response', {outbound: false, payload}); 743 | this._onCallResult(msgId, result); 744 | break; 745 | case MSG_CALLERROR: 746 | const [errorCode, errorDescription, errorDetails] = more; 747 | this.emit('response', {outbound: false, payload}); 748 | this._onCallError(msgId, errorCode, errorDescription, errorDetails); 749 | break; 750 | default: 751 | throw new RPCMessageTypeNotSupportedError(`Unexpected message type: ${messageType}`); 752 | } 753 | 754 | this._badMessagesCount = 0; 755 | 756 | } catch (error) { 757 | 758 | const shouldClose = ++this._badMessagesCount > this._options.maxBadMessages; 759 | 760 | let response = null; 761 | let errorMessage = ''; 762 | 763 | if (![MSG_CALLERROR, MSG_CALLRESULT].includes(messageType)) { 764 | // We shouldn't respond to CALLERROR or CALLRESULT, but we may respond 765 | // to any CALL (or other unknown message type) with a CALLERROR 766 | // (see section 4.4 of OCPP2.0.1J - Extension fallback mechanism) 767 | const details = error.details 768 | || (this._options.respondWithDetailedErrors ? getErrorPlainObject(error) : {}); 769 | 770 | errorMessage = error.message || error.rpcErrorMessage || ""; 771 | 772 | response = [ 773 | MSG_CALLERROR, 774 | msgId, 775 | error.rpcErrorCode || 'GenericError', 776 | errorMessage, 777 | details ?? {}, 778 | ]; 779 | } 780 | 781 | this.emit('badMessage', {buffer, error, response}); 782 | 783 | if (shouldClose) { 784 | this.close({ 785 | code: 1002, 786 | reason: (error instanceof RPCGenericError) ? errorMessage : "Protocol error" 787 | }); 788 | } else if (response && this._state === OPEN) { 789 | this.sendRaw(JSON.stringify(response)); 790 | } 791 | } 792 | } 793 | 794 | async _onCall(msgId, method, params) { 795 | // NOTE: This method must not throw or else it risks sending 2 replies 796 | 797 | try { 798 | let payload; 799 | 800 | if (this._state !== OPEN) { 801 | throw Error("Call received while client state not OPEN"); 802 | } 803 | 804 | try { 805 | if (this._pendingResponses.has(msgId)) { 806 | throw createRPCError("RpcFrameworkError", `Already processing a call with message ID: ${msgId}`, {}); 807 | } 808 | 809 | let handler = this._handlers.get(method); 810 | if (!handler) { 811 | handler = this._wildcardHandler; 812 | } 813 | 814 | if (!handler) { 815 | throw createRPCError("NotImplemented", `Unable to handle '${method}' calls`, {}); 816 | } 817 | 818 | if (this._strictProtocols.includes(this._protocol)) { 819 | // perform some strict-mode checks 820 | const validator = this._strictValidators.get(this._protocol); 821 | try { 822 | validator.validate(`urn:${method}.req`, params); 823 | } catch (error) { 824 | this.emit('strictValidationFailure', { 825 | messageId: msgId, 826 | method, 827 | params, 828 | result: null, 829 | error, 830 | outbound: false, 831 | isCall: true, 832 | }); 833 | throw error; 834 | } 835 | } 836 | 837 | const ac = new AbortController(); 838 | const callPromise = new Promise(async (resolve, reject) => { 839 | function reply(val) { 840 | if (val instanceof Error) { 841 | reject(val); 842 | } else { 843 | resolve(val); 844 | } 845 | } 846 | 847 | try { 848 | reply(await handler({ 849 | messageId: msgId, 850 | method, 851 | params, 852 | signal: ac.signal, 853 | reply, 854 | })); 855 | } catch (err) { 856 | reply(err); 857 | } 858 | }); 859 | 860 | const pending = {abort: ac.abort.bind(ac), promise: callPromise}; 861 | this._pendingResponses.set(msgId, pending); 862 | const result = await callPromise; 863 | 864 | this.emit('callResult', { 865 | outbound: false, 866 | messageId: msgId, 867 | method, 868 | params, 869 | result, 870 | }); 871 | 872 | if (result === NOREPLY) { 873 | return; // don't send a reply 874 | } 875 | 876 | payload = [MSG_CALLRESULT, msgId, result]; 877 | 878 | if (this._strictProtocols.includes(this._protocol)) { 879 | // perform some strict-mode checks 880 | const validator = this._strictValidators.get(this._protocol); 881 | try { 882 | validator.validate(`urn:${method}.conf`, result); 883 | } catch (error) { 884 | this.emit('strictValidationFailure', { 885 | messageId: msgId, 886 | method, 887 | params, 888 | result, 889 | error, 890 | outbound: true, 891 | isCall: false, 892 | }); 893 | throw createRPCError("InternalError"); 894 | } 895 | } 896 | 897 | } catch (err) { 898 | // catch here to prevent this error from being considered a 'badMessage'. 899 | 900 | const details = err.details 901 | || (this._options.respondWithDetailedErrors ? getErrorPlainObject(err) : {}); 902 | 903 | let rpcErrorCode = err.rpcErrorCode || 'GenericError'; 904 | 905 | if (this.protocol === 'ocpp1.6') { 906 | // Workaround for some mistakes in the spec in OCPP1.6J 907 | // (clarified in section 5 of OCPP1.6J errata v1.0) 908 | switch (rpcErrorCode) { 909 | case 'FormatViolation': 910 | rpcErrorCode = 'FormationViolation'; 911 | break; 912 | case 'OccurenceConstraintViolation': 913 | rpcErrorCode = 'OccurrenceConstraintViolation'; 914 | break; 915 | } 916 | } 917 | 918 | payload = [ 919 | MSG_CALLERROR, 920 | msgId, 921 | rpcErrorCode, 922 | err.message || err.rpcErrorMessage || "", 923 | details ?? {}, 924 | ]; 925 | 926 | this.emit('callError', { 927 | outbound: false, 928 | messageId: msgId, 929 | method, 930 | params, 931 | error: err, 932 | }); 933 | 934 | } finally { 935 | this._pendingResponses.delete(msgId); 936 | } 937 | 938 | this.emit('response', {outbound: true, payload}); 939 | this.sendRaw(JSON.stringify(payload)); 940 | 941 | } catch (err) { 942 | this.close({code: 1000, reason: "Unable to send call result"}); 943 | } 944 | } 945 | 946 | _onCallResult(msgId, result) { 947 | const pendingCall = this._pendingCalls.get(msgId); 948 | if (pendingCall) { 949 | 950 | if (this._strictProtocols.includes(this._protocol)) { 951 | // perform some strict-mode checks 952 | const validator = this._strictValidators.get(this._protocol); 953 | try { 954 | validator.validate(`urn:${pendingCall.method}.conf`, result); 955 | } catch (error) { 956 | this.emit('strictValidationFailure', { 957 | messageId: msgId, 958 | method: pendingCall.method, 959 | params: pendingCall.params, 960 | result, 961 | error, 962 | outbound: false, 963 | isCall: false, 964 | }); 965 | return pendingCall.reject(error); 966 | } 967 | } 968 | 969 | return pendingCall.resolve(result); 970 | 971 | } else { 972 | throw createRPCError("RpcFrameworkError", `Received CALLRESULT for unrecognised message ID: ${msgId}`, { 973 | msgId, 974 | result 975 | }); 976 | } 977 | } 978 | 979 | _onCallError(msgId, errorCode, errorDescription, errorDetails) { 980 | const pendingCall = this._pendingCalls.get(msgId); 981 | if (pendingCall) { 982 | const err = createRPCError(errorCode, errorDescription, errorDetails); 983 | pendingCall.reject(err); 984 | } else { 985 | throw createRPCError("RpcFrameworkError", `Received CALLERROR for unrecognised message ID: ${msgId}`, { 986 | msgId, 987 | errorCode, 988 | errorDescription, 989 | errorDetails 990 | }); 991 | } 992 | } 993 | } 994 | 995 | RPCClient.OPEN = OPEN; 996 | RPCClient.CONNECTING = CONNECTING; 997 | RPCClient.CLOSING = CLOSING; 998 | RPCClient.CLOSED = CLOSED; 999 | 1000 | module.exports = RPCClient; 1001 | -------------------------------------------------------------------------------- /lib/errors.d.ts: -------------------------------------------------------------------------------- 1 | export declare class TimeoutError extends Error { 2 | } 3 | export declare class UnexpectedHttpResponse extends Error { 4 | code: any; 5 | request: any; 6 | response: any; 7 | } 8 | export declare class RPCError extends Error { 9 | rpcErrorMessage: string; 10 | rpcErrorCode: string; 11 | } 12 | export declare class RPCGenericError extends RPCError { 13 | rpcErrorMessage: string; 14 | rpcErrorCode: string; 15 | } 16 | export declare class RPCNotImplementedError extends RPCError { 17 | rpcErrorMessage: string; 18 | rpcErrorCode: string; 19 | } 20 | export declare class RPCNotSupportedError extends RPCError { 21 | rpcErrorMessage: string; 22 | rpcErrorCode: string; 23 | } 24 | export declare class RPCInternalError extends RPCError { 25 | rpcErrorMessage: string; 26 | rpcErrorCode: string; 27 | } 28 | export declare class RPCProtocolError extends RPCError { 29 | rpcErrorMessage: string; 30 | rpcErrorCode: string; 31 | } 32 | export declare class RPCSecurityError extends RPCError { 33 | rpcErrorMessage: string; 34 | rpcErrorCode: string; 35 | } 36 | export declare class RPCFormatViolationError extends RPCError { 37 | rpcErrorMessage: string; 38 | rpcErrorCode: string; 39 | } 40 | export declare class RPCFormationViolationError extends RPCError { 41 | rpcErrorMessage: string; 42 | rpcErrorCode: string; 43 | } 44 | export declare class RPCPropertyConstraintViolationError extends RPCError { 45 | rpcErrorMessage: string; 46 | rpcErrorCode: string; 47 | } 48 | export declare class RPCOccurenceConstraintViolationError extends RPCError { 49 | rpcErrorMessage: string; 50 | rpcErrorCode: string; 51 | } 52 | export declare class RPCOccurrenceConstraintViolationError extends RPCError { 53 | rpcErrorMessage: string; 54 | rpcErrorCode: string; 55 | } 56 | export declare class RPCTypeConstraintViolationError extends RPCError { 57 | rpcErrorMessage: string; 58 | rpcErrorCode: string; 59 | } 60 | export declare class RPCMessageTypeNotSupportedError extends RPCError { 61 | rpcErrorMessage: string; 62 | rpcErrorCode: string; 63 | } 64 | export declare class RPCFrameworkError extends RPCError { 65 | rpcErrorMessage: string; 66 | rpcErrorCode: string; 67 | } 68 | export declare class WebsocketUpgradeError extends Error { 69 | code: any; 70 | constructor(code: any, message: string | undefined); 71 | } 72 | declare const _default: { 73 | WebsocketUpgradeError: typeof WebsocketUpgradeError; 74 | TimeoutError: typeof TimeoutError; 75 | UnexpectedHttpResponse: typeof UnexpectedHttpResponse; 76 | RPCError: typeof RPCError; 77 | RPCGenericError: typeof RPCGenericError; 78 | RPCNotImplementedError: typeof RPCNotImplementedError; 79 | RPCNotSupportedError: typeof RPCNotSupportedError; 80 | RPCInternalError: typeof RPCInternalError; 81 | RPCProtocolError: typeof RPCProtocolError; 82 | RPCSecurityError: typeof RPCSecurityError; 83 | RPCFormatViolationError: typeof RPCFormatViolationError; 84 | RPCFormationViolationError: typeof RPCFormationViolationError; 85 | RPCPropertyConstraintViolationError: typeof RPCPropertyConstraintViolationError; 86 | RPCOccurrenceConstraintViolationError: typeof RPCOccurrenceConstraintViolationError; 87 | RPCOccurenceConstraintViolationError: typeof RPCOccurenceConstraintViolationError; 88 | RPCTypeConstraintViolationError: typeof RPCTypeConstraintViolationError; 89 | RPCMessageTypeNotSupportedError: typeof RPCMessageTypeNotSupportedError; 90 | RPCFrameworkError: typeof RPCFrameworkError; 91 | }; 92 | export default _default; 93 | -------------------------------------------------------------------------------- /lib/errors.js: -------------------------------------------------------------------------------- 1 | 2 | class TimeoutError extends Error {}; 3 | class UnexpectedHttpResponse extends Error {}; 4 | 5 | class RPCError extends Error { 6 | rpcErrorMessage = ''; 7 | rpcErrorCode = 'GenericError'; 8 | } 9 | class RPCGenericError extends RPCError { 10 | rpcErrorMessage = ''; 11 | rpcErrorCode = 'GenericError'; 12 | }; 13 | class RPCNotImplementedError extends RPCError { 14 | rpcErrorMessage = 'Requested method is not known'; 15 | rpcErrorCode = 'NotImplemented'; 16 | }; 17 | class RPCNotSupportedError extends RPCError { 18 | rpcErrorMessage = 'Requested method is recognised but not supported'; 19 | rpcErrorCode = 'NotSupported'; 20 | }; 21 | class RPCInternalError extends RPCError { 22 | rpcErrorMessage = 'An internal error occurred and the receiver was not able to process the requested method successfully'; 23 | rpcErrorCode = 'InternalError'; 24 | }; 25 | class RPCProtocolError extends RPCError { 26 | rpcErrorMessage = 'Payload for method is incomplete'; 27 | rpcErrorCode = 'ProtocolError'; 28 | }; 29 | class RPCSecurityError extends RPCError { 30 | rpcErrorMessage = 'During the processing of method a security issue occurred preventing receiver from completing the method successfully'; 31 | rpcErrorCode = 'SecurityError'; 32 | }; 33 | class RPCFormatViolationError extends RPCError { 34 | rpcErrorMessage = 'Payload for the method is syntactically incorrect or not conform the PDU structure for the method'; 35 | rpcErrorCode = 'FormatViolation'; 36 | }; 37 | class RPCFormationViolationError extends RPCError { 38 | rpcErrorMessage = 'Payload for the method is syntactically incorrect or not conform the PDU structure for the method'; 39 | rpcErrorCode = 'FormationViolation'; 40 | }; 41 | class RPCPropertyConstraintViolationError extends RPCError { 42 | rpcErrorMessage = 'Payload is syntactically correct but at least one field contains an invalid value'; 43 | rpcErrorCode = 'PropertyConstraintViolation'; 44 | }; 45 | class RPCOccurenceConstraintViolationError extends RPCError { 46 | rpcErrorMessage = 'Payload for the method is syntactically correct but at least one of the fields violates occurence constraints'; 47 | rpcErrorCode = 'OccurenceConstraintViolation'; 48 | }; 49 | class RPCOccurrenceConstraintViolationError extends RPCError { 50 | rpcErrorMessage = 'Payload for the method is syntactically correct but at least one of the fields violates occurence constraints'; 51 | rpcErrorCode = 'OccurrenceConstraintViolation'; 52 | }; 53 | class RPCTypeConstraintViolationError extends RPCError { 54 | rpcErrorMessage = 'Payload for the method is syntactically correct but at least one of the fields violates data type constraints'; 55 | rpcErrorCode = 'TypeConstraintViolation'; 56 | }; 57 | class RPCMessageTypeNotSupportedError extends RPCError { 58 | rpcErrorMessage = 'A message with a Message Type Number received is not supported by this implementation.'; 59 | rpcErrorCode = 'MessageTypeNotSupported'; 60 | }; 61 | class RPCFrameworkError extends RPCError { 62 | rpcErrorMessage = 'Content of the call is not a valid RPC Request, for example: MessageId could not be read.'; 63 | rpcErrorCode = 'RpcFrameworkError'; 64 | }; 65 | 66 | class WebsocketUpgradeError extends Error { 67 | constructor(code, message) { 68 | super(message); 69 | this.code = code; 70 | } 71 | } 72 | 73 | module.exports = { 74 | WebsocketUpgradeError, 75 | TimeoutError, 76 | UnexpectedHttpResponse, 77 | RPCError, 78 | RPCGenericError, 79 | RPCNotImplementedError, 80 | RPCNotSupportedError, 81 | RPCInternalError, 82 | RPCProtocolError, 83 | RPCSecurityError, 84 | RPCFormatViolationError, 85 | RPCFormationViolationError, // to allow for mistake in ocpp1.6j spec 86 | RPCPropertyConstraintViolationError, 87 | RPCOccurrenceConstraintViolationError, 88 | RPCOccurenceConstraintViolationError, // to allow for mistake in ocpp1.6j spec 89 | RPCTypeConstraintViolationError, 90 | RPCMessageTypeNotSupportedError, 91 | RPCFrameworkError, 92 | }; -------------------------------------------------------------------------------- /lib/event-buffer.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | import { EventEmitter } from "stream"; 4 | declare class EventBuffer { 5 | private _emitter; 6 | private _event; 7 | private _collector; 8 | private _buffer; 9 | constructor(emitter: EventEmitter, event: string | symbol); 10 | condense(): any; 11 | } 12 | export default EventBuffer; 13 | -------------------------------------------------------------------------------- /lib/event-buffer.js: -------------------------------------------------------------------------------- 1 | 2 | class EventBuffer { 3 | constructor(emitter, event) { 4 | this._emitter = emitter; 5 | this._event = event; 6 | 7 | this._collecter = (...args) => { 8 | this._buffer.push(args); 9 | }; 10 | this._buffer = []; 11 | this._emitter.on(event, this._collecter); 12 | } 13 | 14 | condense() { 15 | this._emitter.off(this._event, this._collecter); 16 | return this._buffer; 17 | } 18 | } 19 | 20 | module.exports = EventBuffer; -------------------------------------------------------------------------------- /lib/queue.d.ts: -------------------------------------------------------------------------------- 1 | declare class Queue { 2 | private _pending; 3 | private _concurrency; 4 | private _queue; 5 | constructor(); 6 | setConcurrency(concurrency: number): void; 7 | push(fn: any): Promise; 8 | private _next; 9 | } 10 | export default Queue; 11 | -------------------------------------------------------------------------------- /lib/queue.js: -------------------------------------------------------------------------------- 1 | 2 | class Queue { 3 | constructor() { 4 | this._pending = 0; 5 | this._concurrency = Infinity; 6 | this._queue = []; 7 | } 8 | 9 | setConcurrency(concurrency) { 10 | this._concurrency = concurrency; 11 | this._next(); 12 | } 13 | 14 | push(fn) { 15 | return new Promise((resolve, reject) => { 16 | this._queue.push({ 17 | fn, 18 | resolve, 19 | reject, 20 | }); 21 | 22 | this._next(); 23 | }); 24 | } 25 | 26 | async _next() { 27 | if (this._pending >= this._concurrency) { 28 | return false; 29 | } 30 | 31 | const job = this._queue.shift(); 32 | if (!job) { 33 | return false; 34 | } 35 | 36 | this._pending++; 37 | 38 | try { 39 | job.resolve(await job.fn()); 40 | } catch (err) { 41 | job.reject(err); 42 | } 43 | 44 | this._pending--; 45 | this._next(); 46 | } 47 | } 48 | 49 | module.exports = Queue; 50 | -------------------------------------------------------------------------------- /lib/server-client.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | import WebSocket from "ws"; 4 | import RPC_Client, { RPC_ClientOptions } from "./client"; 5 | import { IncomingHttpHeaders, IncomingMessage } from "http"; 6 | export interface IHandshakeInterface { 7 | remoteAddress: string | undefined; 8 | headers: IncomingHttpHeaders; 9 | protocols: Set; 10 | endpoint: string; 11 | identity: string; 12 | query: URLSearchParams; 13 | request: IncomingMessage; 14 | password: Buffer | undefined; 15 | } 16 | declare class RpcServerClient extends RPC_Client { 17 | private _session; 18 | private _handshake; 19 | constructor({ ...options }: RPC_ClientOptions, { ws, handshake, session, }: { 20 | ws: WebSocket; 21 | session: Record; 22 | handshake: IHandshakeInterface; 23 | }); 24 | get handshake(): IHandshakeInterface; 25 | get session(): Record; 26 | connect(): Promise; 27 | } 28 | export default RpcServerClient; 29 | -------------------------------------------------------------------------------- /lib/server-client.js: -------------------------------------------------------------------------------- 1 | const RPCClient = require("./client"); 2 | const { OPEN } = require("ws"); 3 | 4 | class RPCServerClient extends RPCClient { 5 | constructor(options, {ws, handshake, session}) { 6 | super(options); 7 | 8 | this._session = session; 9 | this._handshake = handshake; 10 | 11 | this._state = OPEN; 12 | this._identity = this._options.identity; 13 | this._ws = ws; 14 | this._protocol = ws.protocol; 15 | this._attachWebsocket(this._ws); 16 | } 17 | 18 | get handshake() { 19 | return this._handshake; 20 | } 21 | 22 | get session() { 23 | return this._session; 24 | } 25 | 26 | async connect() { 27 | throw Error("Cannot connect from server to client"); 28 | } 29 | } 30 | 31 | module.exports = RPCServerClient; -------------------------------------------------------------------------------- /lib/server.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | /// 5 | /// 6 | import { IncomingMessage, Server } from "http"; 7 | import { ServerOptions } from "ws"; 8 | import { EventEmitter } from "stream"; 9 | import { Validator } from "./validator"; 10 | import { IHandshakeInterface } from "./server-client"; 11 | import { Socket } from "net"; 12 | interface IOccpServiceOptions { 13 | wssOptions?: ServerOptions; 14 | protocols?: string[]; 15 | callTimeoutMs?: number; 16 | pingIntervalMs?: number; 17 | deferPingsOnActivity?: boolean; 18 | respondWithDetailedErrors?: boolean; 19 | callConcurrency?: number; 20 | maxBadMessages?: number; 21 | strictMode?: boolean | string[]; 22 | strictModeValidators?: Validator[]; 23 | } 24 | declare class RPCServer extends EventEmitter { 25 | private _httpServerAbortControllers; 26 | private _state; 27 | private _clients; 28 | private _pendingUpgrades; 29 | private _options; 30 | private _wss; 31 | private _strictValidators; 32 | authCallback: (accept: (session?: Record, protocol?: string | false) => void, reject: (code: number, message: string) => void, handshake: IHandshakeInterface, signal: AbortSignal) => void; 33 | constructor({ ...options }: IOccpServiceOptions, _callback?: () => void); 34 | reconfigure(options: any): void; 35 | private _onConnection; 36 | get handleUpgrade(): (request: IncomingMessage, socket: Socket, head: Buffer) => Promise; 37 | auth(cb: (accept: (session?: Record, protocol?: string | false) => void, reject: (code: number, message: string) => void, handshake: IHandshakeInterface, signal?: AbortSignal) => void): void; 38 | listen(port: any, host?: any, options?: Record): Promise>; 39 | close({ code, reason, awaitPending, force }: Record): Promise; 40 | } 41 | export default RPCServer; 42 | -------------------------------------------------------------------------------- /lib/server.js: -------------------------------------------------------------------------------- 1 | const {EventEmitter, once} = require('events'); 2 | const {WebSocketServer, OPEN, CLOSING, CLOSED} = require('ws'); 3 | const {createServer} = require('http'); 4 | const RPCServerClient = require('./server-client'); 5 | const { abortHandshake, parseSubprotocols } = require('./ws-util'); 6 | const standardValidators = require('./standard-validators'); 7 | const { getPackageIdent } = require('./util'); 8 | const { WebsocketUpgradeError } = require('./errors'); 9 | 10 | class RPCServer extends EventEmitter { 11 | constructor(options) { 12 | super(); 13 | 14 | this._httpServerAbortControllers = new Set(); 15 | this._state = OPEN; 16 | this._clients = new Set(); 17 | this._pendingUpgrades = new WeakMap(); 18 | 19 | this._options = { 20 | // defaults 21 | wssOptions: {}, 22 | protocols: [], 23 | callTimeoutMs: 1000*30, 24 | pingIntervalMs: 1000*30, 25 | deferPingsOnActivity: false, 26 | respondWithDetailedErrors: false, 27 | callConcurrency: 1, 28 | maxBadMessages: Infinity, 29 | strictMode: false, 30 | strictModeValidators: [], 31 | }; 32 | 33 | this.reconfigure(options || {}); 34 | 35 | this._wss = new WebSocketServer({ 36 | ...this._options.wssOptions, 37 | noServer: true, 38 | handleProtocols: (protocols, request) => { 39 | const {protocol} = this._pendingUpgrades.get(request); 40 | return protocol; 41 | }, 42 | }); 43 | 44 | this._wss.on('headers', h => h.push(`Server: ${getPackageIdent()}`)); 45 | this._wss.on('error', err => this.emit('error', err)); 46 | this._wss.on('connection', this._onConnection.bind(this)); 47 | } 48 | 49 | reconfigure(options) { 50 | const newOpts = Object.assign({}, this._options, options); 51 | 52 | if (newOpts.strictMode && !newOpts.protocols?.length) { 53 | throw Error(`strictMode requires at least one subprotocol`); 54 | } 55 | 56 | const strictValidators = [...standardValidators]; 57 | if (newOpts.strictModeValidators) { 58 | strictValidators.push(...newOpts.strictModeValidators); 59 | } 60 | 61 | this._strictValidators = strictValidators.reduce((svs, v) => { 62 | svs.set(v.subprotocol, v); 63 | return svs; 64 | }, new Map()); 65 | 66 | let strictProtocols = []; 67 | if (Array.isArray(newOpts.strictMode)) { 68 | strictProtocols = newOpts.strictMode; 69 | } else if (newOpts.strictMode) { 70 | strictProtocols = newOpts.protocols; 71 | } 72 | 73 | const missingValidator = strictProtocols.find(protocol => !this._strictValidators.has(protocol)); 74 | if (missingValidator) { 75 | throw Error(`Missing strictMode validator for subprotocol '${missingValidator}'`); 76 | } 77 | 78 | this._options = newOpts; 79 | } 80 | 81 | get handleUpgrade() { 82 | return async (request, socket, head) => { 83 | 84 | let resolved = false; 85 | 86 | const ac = new AbortController(); 87 | const {signal} = ac; 88 | 89 | const url = new URL('http://localhost' + (request.url || '/')); 90 | const pathParts = url.pathname.split('/'); 91 | const identity = decodeURIComponent(pathParts.pop()); 92 | 93 | const abortUpgrade = (error) => { 94 | resolved = true; 95 | 96 | if (error && error instanceof WebsocketUpgradeError) { 97 | abortHandshake(socket, error.code, error.message); 98 | } else { 99 | abortHandshake(socket, 500); 100 | } 101 | 102 | if (!signal.aborted) { 103 | ac.abort(error); 104 | this.emit('upgradeAborted', { 105 | error, 106 | socket, 107 | request, 108 | identity, 109 | }); 110 | } 111 | }; 112 | 113 | socket.on('error', (err) => { 114 | abortUpgrade(err); 115 | }); 116 | 117 | try { 118 | if (this._state !== OPEN) { 119 | throw new WebsocketUpgradeError(500, "Server not open"); 120 | } 121 | 122 | if (socket.readyState !== 'open') { 123 | throw new WebsocketUpgradeError(400, `Client readyState = '${socket.readyState}'`); 124 | } 125 | 126 | const headers = request.headers; 127 | 128 | if (headers.upgrade.toLowerCase() !== 'websocket') { 129 | throw new WebsocketUpgradeError(400, "Can only upgrade websocket upgrade requests"); 130 | } 131 | 132 | const endpoint = pathParts.join('/') || '/'; 133 | const remoteAddress = request.socket.remoteAddress; 134 | const protocols = ('sec-websocket-protocol' in request.headers) 135 | ? parseSubprotocols(request.headers['sec-websocket-protocol']) 136 | : new Set(); 137 | 138 | let password; 139 | if (headers.authorization) { 140 | try { 141 | /** 142 | * This is a non-standard basic auth parser because it supports 143 | * colons in usernames (which is normally disallowed). 144 | * However, this shouldn't cause any confusion as we have a 145 | * guarantee from OCPP that the username will always be equal to 146 | * the identity. 147 | * It also supports binary passwords, which is also a spec violation 148 | * but is necessary for allowing truly random binary keys as 149 | * recommended by the OCPP security whitepaper. 150 | */ 151 | const b64up = headers.authorization.match(/^ *(?:[Bb][Aa][Ss][Ii][Cc]) +([A-Za-z0-9._~+/-]+=*) *$/)[1]; 152 | const userPassBuffer = Buffer.from(b64up, 'base64'); 153 | 154 | const clientIdentityUserBuffer = Buffer.from(identity + ':'); 155 | 156 | if (clientIdentityUserBuffer.compare(userPassBuffer, 0, clientIdentityUserBuffer.length) === 0) { 157 | // first part of buffer matches `${identity}:` 158 | password = userPassBuffer.subarray(clientIdentityUserBuffer.length); 159 | } 160 | } catch (err) { 161 | // failing to parse authorization header is no big deal. 162 | // just leave password as undefined as if no header was sent. 163 | } 164 | } 165 | 166 | const handshake = { 167 | remoteAddress, 168 | headers, 169 | protocols, 170 | endpoint, 171 | identity, 172 | query: url.searchParams, 173 | request, 174 | password, 175 | }; 176 | 177 | const accept = (session, protocol) => { 178 | if (resolved) return; 179 | resolved = true; 180 | 181 | try { 182 | if (socket.readyState !== 'open') { 183 | throw new WebsocketUpgradeError(400, `Client readyState = '${socket.readyState}'`); 184 | } 185 | 186 | if (protocol === undefined) { 187 | // pick first subprotocol (preferred by server) that is also supported by the client 188 | protocol = (this._options.protocols ?? []).find(p => protocols.has(p)); 189 | } else if (protocol !== false && !protocols.has(protocol)) { 190 | throw new WebsocketUpgradeError(400, `Client doesn't support expected subprotocol`); 191 | } 192 | 193 | // cache auth results for connection creation 194 | this._pendingUpgrades.set(request, { 195 | session: session ?? {}, 196 | protocol, 197 | handshake 198 | }); 199 | 200 | this._wss.handleUpgrade(request, socket, head, ws => { 201 | this._wss.emit('connection', ws, request); 202 | }); 203 | } catch (err) { 204 | abortUpgrade(err); 205 | } 206 | }; 207 | 208 | const reject = (code = 404, message = 'Not found') => { 209 | if (resolved) return; 210 | resolved = true; 211 | abortUpgrade(new WebsocketUpgradeError(code, message)); 212 | }; 213 | 214 | socket.once('end', () => { 215 | reject(400, `Client connection closed before upgrade complete`); 216 | }); 217 | 218 | socket.once('close', () => { 219 | reject(400, `Client connection closed before upgrade complete`); 220 | }); 221 | 222 | if (this.authCallback) { 223 | await this.authCallback( 224 | accept, 225 | reject, 226 | handshake, 227 | signal 228 | ); 229 | } else { 230 | accept(); 231 | } 232 | 233 | } catch (err) { 234 | abortUpgrade(err); 235 | } 236 | }; 237 | } 238 | 239 | async _onConnection(websocket, request) { 240 | try { 241 | if (this._state !== OPEN) { 242 | throw Error("Server is no longer open"); 243 | } 244 | 245 | const {handshake, session} = this._pendingUpgrades.get(request); 246 | 247 | const client = new RPCServerClient({ 248 | identity: handshake.identity, 249 | reconnect: false, 250 | callTimeoutMs: this._options.callTimeoutMs, 251 | pingIntervalMs: this._options.pingIntervalMs, 252 | deferPingsOnActivity: this._options.deferPingsOnActivity, 253 | respondWithDetailedErrors: this._options.respondWithDetailedErrors, 254 | callConcurrency: this._options.callConcurrency, 255 | strictMode: this._options.strictMode, 256 | strictModeValidators: this._options.strictModeValidators, 257 | maxBadMessages: this._options.maxBadMessages, 258 | protocols: this._options.protocols, 259 | }, { 260 | ws: websocket, 261 | session, 262 | handshake, 263 | }); 264 | 265 | this._clients.add(client); 266 | client.once('close', () => this._clients.delete(client)); 267 | this.emit('client', client); 268 | 269 | } catch (err) { 270 | websocket.close(err.statusCode || 1000, err.message); 271 | } 272 | } 273 | 274 | auth(cb) { 275 | this.authCallback = cb; 276 | } 277 | 278 | async listen(port, host, options = {}) { 279 | const ac = new AbortController(); 280 | this._httpServerAbortControllers.add(ac); 281 | if (options.signal) { 282 | once(options.signal, 'abort').then(() => { 283 | ac.abort(options.signal.reason); 284 | }); 285 | } 286 | const httpServer = createServer({ 287 | noDelay: true, 288 | }, (req, res) => { 289 | res.setHeader('Server', getPackageIdent()); 290 | res.statusCode = 404; 291 | res.end(); 292 | }); 293 | httpServer.on('upgrade', this.handleUpgrade); 294 | httpServer.once('close', () => this._httpServerAbortControllers.delete(ac)); 295 | await new Promise((resolve, reject) => { 296 | httpServer.listen({ 297 | port, 298 | host, 299 | signal: ac.signal, 300 | }, err => err ? reject(err) : resolve()); 301 | }); 302 | return httpServer; 303 | } 304 | 305 | async close({code, reason, awaitPending, force} = {}) { 306 | if (this._state === OPEN) { 307 | this._state = CLOSING; 308 | this.emit('closing'); 309 | code = code ?? 1001; 310 | await Array.from(this._clients).map(cli => cli.close({code, reason, awaitPending, force})); 311 | await new Promise((resolve, reject) => { 312 | this._wss.close(err => err ? reject(err) : resolve()); 313 | this._httpServerAbortControllers.forEach(ac => ac.abort("Closing")); 314 | }); 315 | this._state = CLOSED; 316 | this.emit('close'); 317 | } 318 | } 319 | } 320 | 321 | module.exports = RPCServer; -------------------------------------------------------------------------------- /lib/standard-validators.d.ts: -------------------------------------------------------------------------------- 1 | declare const _default: readonly [import("./validator").Validator, import("./validator").Validator]; 2 | export default _default; 3 | -------------------------------------------------------------------------------- /lib/standard-validators.js: -------------------------------------------------------------------------------- 1 | const { createValidator } = require('./validator'); 2 | 3 | module.exports = [ 4 | createValidator('ocpp1.6', require('./schemas/ocpp1_6.json')), 5 | createValidator('ocpp2.0.1', require('./schemas/ocpp2_0_1.json')), 6 | createValidator('ocpp2.1', require('./schemas/ocpp2_1.json')), 7 | ]; -------------------------------------------------------------------------------- /lib/symbols.d.ts: -------------------------------------------------------------------------------- 1 | export declare const NOREPLY: { 2 | NOREPLY: symbol; 3 | }; 4 | export default NOREPLY; 5 | -------------------------------------------------------------------------------- /lib/symbols.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | NOREPLY: Symbol("NOREPLY"), 3 | }; -------------------------------------------------------------------------------- /lib/util.d.ts: -------------------------------------------------------------------------------- 1 | export declare function getPackageIdent(): string; 2 | export declare function getErrorPlainObject(err: Error): any; 3 | export declare function createRPCError(type: string, message?: any, details?: {}): Record; 4 | declare const _default: { 5 | getErrorPlainObject: typeof getErrorPlainObject; 6 | createRPCError: typeof createRPCError; 7 | getPackageIdent: typeof getPackageIdent; 8 | }; 9 | export default _default; 10 | -------------------------------------------------------------------------------- /lib/util.js: -------------------------------------------------------------------------------- 1 | const errors = require('./errors'); 2 | const package = require('../package.json'); 3 | 4 | const rpcErrorLUT = { 5 | 'GenericError' : errors.RPCGenericError, 6 | 'NotImplemented' : errors.RPCNotImplementedError, 7 | 'NotSupported' : errors.RPCNotSupportedError, 8 | 'InternalError' : errors.RPCInternalError, 9 | 'ProtocolError' : errors.RPCProtocolError, 10 | 'SecurityError' : errors.RPCSecurityError, 11 | 'FormationViolation' : errors.RPCFormationViolationError, 12 | 'FormatViolation' : errors.RPCFormatViolationError, 13 | 'PropertyConstraintViolation' : errors.RPCPropertyConstraintViolationError, 14 | 'OccurenceConstraintViolation' : errors.RPCOccurenceConstraintViolationError, 15 | 'OccurrenceConstraintViolation' : errors.RPCOccurrenceConstraintViolationError, 16 | 'TypeConstraintViolation' : errors.RPCTypeConstraintViolationError, 17 | 'MessageTypeNotSupported' : errors.RPCMessageTypeNotSupportedError, 18 | 'RpcFrameworkError' : errors.RPCFrameworkError, 19 | }; 20 | 21 | function getPackageIdent() { 22 | return `${package.name}/${package.version} (${process.platform})`; 23 | } 24 | 25 | function getErrorPlainObject(err) { 26 | try { 27 | 28 | // (nasty hack) 29 | // attempt to serialise into JSON to ensure the error is, in fact, serialisable 30 | return JSON.parse(JSON.stringify(err, Object.getOwnPropertyNames(err))); 31 | 32 | } catch (e) { 33 | // cannot serialise into JSON. 34 | // return just stack and message instead 35 | return { 36 | stack: err.stack, 37 | message: err.message, 38 | }; 39 | } 40 | } 41 | 42 | function createRPCError(type, message, details) { 43 | const E = rpcErrorLUT[type] ?? errors.RPCGenericError; 44 | const err = new E(message ?? ''); 45 | err.details = details ?? {}; 46 | return err; 47 | } 48 | 49 | module.exports = { 50 | getErrorPlainObject, 51 | createRPCError, 52 | getPackageIdent, 53 | }; -------------------------------------------------------------------------------- /lib/validator.d.ts: -------------------------------------------------------------------------------- 1 | import Ajv, { AnySchema, AsyncSchema, SchemaObject } from "ajv"; 2 | export declare class Validator { 3 | _subprotocol: string; 4 | _ajv: Ajv; 5 | constructor(subprotocol: string, ajv: Ajv); 6 | get subprotocol(): string; 7 | validate(schemaId: string, params: any): boolean | Promise; 8 | } 9 | export declare function createValidator(subprotocol: string, json: boolean | SchemaObject | AsyncSchema | AnySchema[]): Validator; 10 | -------------------------------------------------------------------------------- /lib/validator.js: -------------------------------------------------------------------------------- 1 | const Ajv = require('ajv'); 2 | const addFormats = require('ajv-formats'); 3 | const { createRPCError } = require('./util'); 4 | 5 | const errorCodeLUT = { 6 | 'maximum' : "FormatViolation", 7 | 'minimum' : "FormatViolation", 8 | 'maxLength' : "FormatViolation", 9 | 'minLength' : "FormatViolation", 10 | 'exclusiveMaximum' : "OccurenceConstraintViolation", 11 | 'exclusiveMinimum' : "OccurenceConstraintViolation", 12 | 'multipleOf' : "OccurenceConstraintViolation", 13 | 'maxItems' : "OccurenceConstraintViolation", 14 | 'minItems' : "OccurenceConstraintViolation", 15 | 'maxProperties' : "OccurenceConstraintViolation", 16 | 'minProperties' : "OccurenceConstraintViolation", 17 | 'additionalItems' : "OccurenceConstraintViolation", 18 | 'required' : "OccurenceConstraintViolation", 19 | 'pattern' : "PropertyConstraintViolation", 20 | 'propertyNames' : "PropertyConstraintViolation", 21 | 'additionalProperties' : "PropertyConstraintViolation", 22 | 'type' : "TypeConstraintViolation", 23 | }; 24 | 25 | class Validator { 26 | constructor(subprotocol, ajv) { 27 | this._subprotocol = subprotocol; 28 | this._ajv = ajv; 29 | } 30 | 31 | get subprotocol() { 32 | return this._subprotocol; 33 | } 34 | 35 | validate(schemaId, params) { 36 | const validator = this._ajv.getSchema(schemaId); 37 | 38 | if (!validator) { 39 | throw createRPCError("ProtocolError", `Schema '${schemaId}' is missing from subprotocol schema '${this._subprotocol}'`); 40 | } 41 | 42 | const res = validator(params); 43 | if (!res && validator.errors?.length > 0) { 44 | const [first] = validator.errors; 45 | const rpcErrorCode = errorCodeLUT[first.keyword] ?? "FormatViolation"; 46 | 47 | throw createRPCError(rpcErrorCode, this._ajv.errorsText(validator.errors), { 48 | errors: validator.errors, 49 | data: params, 50 | }); 51 | } 52 | 53 | return res; 54 | } 55 | } 56 | 57 | function createValidator(subprotocol, json) { 58 | const ajv = new Ajv({strictSchema: false}); 59 | addFormats(ajv); 60 | ajv.addSchema(json); 61 | 62 | ajv.removeKeyword("multipleOf"); 63 | ajv.addKeyword({ 64 | keyword: "multipleOf", 65 | type: "number", 66 | compile(schema) { 67 | return data => { 68 | const result = data / schema; 69 | const epsilon = 1e-6; // small value to account for floating point precision errors 70 | return Math.abs(Math.round(result) - result) < epsilon; 71 | }; 72 | }, 73 | errors: false, 74 | metaSchema: { 75 | type: "number", 76 | }, 77 | }); 78 | 79 | return new Validator(subprotocol, ajv); 80 | } 81 | 82 | module.exports = {Validator, createValidator}; 83 | -------------------------------------------------------------------------------- /lib/ws-util.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { Socket } from "net"; 3 | export declare function abortHandshake(socket: Socket, code: number | string, message?: string, headers?: Record): void; 4 | export declare function parseSubprotocols(header: string): Set; 5 | /** 6 | * Checks if a status code is allowed in a close frame. 7 | * 8 | * @param {Number} code The status code 9 | * @return {Boolean} `true` if the status code is valid, else `false` 10 | * @public 11 | */ 12 | export declare function isValidStatusCode(code: number): boolean; 13 | declare const _default: { 14 | abortHandshake: typeof abortHandshake; 15 | parseSubprotocols: typeof parseSubprotocols; 16 | isValidStatusCode: typeof isValidStatusCode; 17 | }; 18 | export default _default; 19 | -------------------------------------------------------------------------------- /lib/ws-util.js: -------------------------------------------------------------------------------- 1 | // Excerpt of code taken from the 'ws' module (necessary because it is not exported) 2 | 3 | // Copyright (c) 2011 Einar Otto Stangvik 4 | 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | const http = require('http'); 24 | 25 | function abortHandshake(socket, code, message, headers) { 26 | if (socket.writable) { 27 | message = message || http.STATUS_CODES[code]; 28 | headers = { 29 | Connection: 'close', 30 | 'Content-Type': 'text/html', 31 | 'Content-Length': Buffer.byteLength(message), 32 | ...headers 33 | }; 34 | 35 | socket.write( 36 | `HTTP/1.1 ${code} ${http.STATUS_CODES[code]}\r\n` + 37 | Object.keys(headers) 38 | .map((h) => `${h}: ${headers[h]}`) 39 | .join('\r\n') + 40 | '\r\n\r\n' + 41 | message 42 | ); 43 | } 44 | 45 | socket.removeAllListeners('error'); 46 | socket.destroy(); 47 | } 48 | 49 | 50 | const tokenChars = [ 51 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0 - 15 52 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 16 - 31 53 | 0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 1, 0, // 32 - 47 54 | 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, // 48 - 63 55 | 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 64 - 79 56 | 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, // 80 - 95 57 | 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 96 - 111 58 | 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 0 // 112 - 127 59 | ]; 60 | 61 | function parseSubprotocols(header) { 62 | const protocols = new Set(); 63 | let start = -1; 64 | let end = -1; 65 | let i = 0; 66 | 67 | for (i; i < header.length; i++) { 68 | const code = header.charCodeAt(i); 69 | 70 | if (end === -1 && tokenChars[code] === 1) { 71 | if (start === -1) start = i; 72 | } else if ( 73 | i !== 0 && 74 | (code === 0x20 /* ' ' */ || code === 0x09) /* '\t' */ 75 | ) { 76 | if (end === -1 && start !== -1) end = i; 77 | } else if (code === 0x2c /* ',' */ ) { 78 | if (start === -1) { 79 | throw new SyntaxError(`Unexpected character at index ${i}`); 80 | } 81 | 82 | if (end === -1) end = i; 83 | 84 | const protocol = header.slice(start, end); 85 | 86 | if (protocols.has(protocol)) { 87 | throw new SyntaxError(`The "${protocol}" subprotocol is duplicated`); 88 | } 89 | 90 | protocols.add(protocol); 91 | start = end = -1; 92 | } else { 93 | throw new SyntaxError(`Unexpected character at index ${i}`); 94 | } 95 | } 96 | 97 | if (start === -1 || end !== -1) { 98 | throw new SyntaxError('Unexpected end of input'); 99 | } 100 | 101 | const protocol = header.slice(start, i); 102 | 103 | if (protocols.has(protocol)) { 104 | throw new SyntaxError(`The "${protocol}" subprotocol is duplicated`); 105 | } 106 | 107 | protocols.add(protocol); 108 | return protocols; 109 | } 110 | 111 | /** 112 | * Checks if a status code is allowed in a close frame. 113 | * 114 | * @param {Number} code The status code 115 | * @return {Boolean} `true` if the status code is valid, else `false` 116 | * @public 117 | */ 118 | function isValidStatusCode(code) { 119 | return ( 120 | (code >= 1000 && 121 | code <= 1014 && 122 | code !== 1004 && 123 | code !== 1005 && 124 | code !== 1006) || 125 | (code >= 3000 && code <= 4999) 126 | ); 127 | } 128 | 129 | module.exports = { 130 | abortHandshake, 131 | parseSubprotocols, 132 | isValidStatusCode, 133 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ocpp-rpc", 3 | "version": "2.2.0", 4 | "description": "A client & server implementation of the WAMP-like RPC-over-websocket system defined in the OCPP protocols (e.g. OCPP1.6-J and OCPP2.0.1).", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "mocha", 8 | "coverage": "nyc --reporter=lcov --reporter=text mocha" 9 | }, 10 | "engines": { 11 | "node": ">=17.3.0" 12 | }, 13 | "keywords": [ 14 | "ocpp", 15 | "rpc", 16 | "websocket rpc", 17 | "wamp", 18 | "websockets", 19 | "ocpp-j", 20 | "ocpp1.6", 21 | "ocpp2.0.1", 22 | "ocpp1.6j", 23 | "ocpp2.0.1j", 24 | "ocpp2.1", 25 | "rpc server", 26 | "rpc client", 27 | "ocpp rpc", 28 | "ocpp websockets" 29 | ], 30 | "author": { 31 | "name": "Gareth Hughes", 32 | "url": "https://github.com/mikuso" 33 | }, 34 | "bugs": { 35 | "url": "https://github.com/mikuso/ocpp-rpc/issues" 36 | }, 37 | "repository": "github:mikuso/ocpp-rpc", 38 | "homepage": "https://github.com/mikuso/ocpp-rpc#readme", 39 | "license": "MIT", 40 | "dependencies": { 41 | "ajv": "8.14.0", 42 | "ajv-formats": "^2.1.1", 43 | "backoff": "^2.5.0", 44 | "ws": "^8.5.0" 45 | }, 46 | "devDependencies": { 47 | "mocha": "^10.0.0", 48 | "nyc": "^15.1.0" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /test/server-client.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert/strict'); 2 | const { once } = require('events'); 3 | const RPCClient = require("../lib/client"); 4 | const RPCServer = require("../lib/server"); 5 | const { setTimeout } = require('timers/promises'); 6 | const { createValidator } = require('../lib/validator'); 7 | 8 | function getEchoValidator() { 9 | return createValidator('echo1.0', [ 10 | { 11 | "$schema": "http://json-schema.org/draft-07/schema", 12 | "$id": "urn:Echo.req", 13 | "type": "object", 14 | "properties": { 15 | "val": { 16 | "type": "string" 17 | } 18 | }, 19 | "additionalProperties": false, 20 | "required": ["val"] 21 | }, 22 | { 23 | "$schema": "http://json-schema.org/draft-07/schema", 24 | "$id": "urn:Echo.conf", 25 | "type": "object", 26 | "properties": { 27 | "val": { 28 | "type": "string" 29 | } 30 | }, 31 | "additionalProperties": false, 32 | "required": ["val"] 33 | } 34 | ]); 35 | } 36 | 37 | describe('RPCServerClient', function(){ 38 | this.timeout(500); 39 | 40 | async function createServer(options = {}, extra = {}) { 41 | const server = new RPCServer(options); 42 | const httpServer = await server.listen(0); 43 | const port = httpServer.address().port; 44 | const endpoint = `ws://localhost:${port}`; 45 | const close = (...args) => server.close(...args); 46 | server.on('client', client => { 47 | client.handle('Echo', async ({params}) => { 48 | return params; 49 | }); 50 | client.handle('Sleep', async ({params, signal}) => { 51 | await setTimeout(params.ms, null, {signal}); 52 | return `Waited ${params.ms}ms`; 53 | }); 54 | client.handle('Reject', async ({params}) => { 55 | const err = Error("Rejecting"); 56 | Object.assign(err, params); 57 | throw err; 58 | }); 59 | if (extra.withClient) { 60 | extra.withClient(client); 61 | } 62 | }); 63 | return {server, httpServer, port, endpoint, close}; 64 | } 65 | 66 | describe('#connect', function(){ 67 | 68 | it('should throw', async () => { 69 | 70 | let servCli; 71 | const {endpoint, close} = await createServer({}, { 72 | withClient: cli => { 73 | servCli = cli; 74 | } 75 | }); 76 | const cli = new RPCClient({ 77 | endpoint, 78 | identity: 'X', 79 | }); 80 | 81 | await cli.connect(); 82 | await assert.rejects(servCli.connect()); 83 | 84 | await cli.close(); 85 | await close(); 86 | 87 | }); 88 | 89 | }); 90 | 91 | it('should inherit server options', async () => { 92 | 93 | const inheritableOptions = { 94 | callTimeoutMs: Math.floor(Math.random()*99999), 95 | pingIntervalMs: Math.floor(Math.random()*99999), 96 | deferPingsOnActivity: true, 97 | respondWithDetailedErrors: true, 98 | callConcurrency: Math.floor(Math.random()*99999), 99 | strictMode: true, 100 | strictModeValidators: [ 101 | getEchoValidator(), 102 | ], 103 | maxBadMessages: Math.floor(Math.random()*99999), 104 | }; 105 | 106 | const server = new RPCServer({ 107 | protocols: ['echo1.0'], 108 | ...inheritableOptions 109 | }); 110 | const httpServer = await server.listen(0); 111 | const port = httpServer.address().port; 112 | const endpoint = `ws://localhost:${port}`; 113 | 114 | const test = new Promise((resolve, reject) => { 115 | server.on('client', cli => { 116 | const optionKeys = Object.keys(inheritableOptions); 117 | for (const optionKey of optionKeys) { 118 | const option = cli._options[optionKey]; 119 | if (option !== inheritableOptions[optionKey]) { 120 | reject(Error(`RPCServerClient did not inherit option "${optionKey}" from RPCServer`)); 121 | } 122 | } 123 | resolve(); 124 | }); 125 | }); 126 | 127 | const cli = new RPCClient({ 128 | endpoint, 129 | identity: 'X', 130 | reconnect: false, 131 | deferPingsOnActivity: false, 132 | pingIntervalMs: 40 133 | }); 134 | await cli.connect(); 135 | await cli.close(); 136 | await server.close(); 137 | await test; 138 | }); 139 | 140 | }); -------------------------------------------------------------------------------- /test/server.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert/strict'); 2 | const http = require('http'); 3 | const { once } = require('events'); 4 | const RPCClient = require("../lib/client"); 5 | const { TimeoutError, UnexpectedHttpResponse, WebsocketUpgradeError } = require('../lib/errors'); 6 | const RPCServer = require("../lib/server"); 7 | const { setTimeout } = require('timers/promises'); 8 | const { createValidator } = require('../lib/validator'); 9 | const { abortHandshake } = require('../lib/ws-util'); 10 | 11 | describe('RPCServer', function(){ 12 | this.timeout(500); 13 | 14 | async function createServer(options = {}, extra = {}) { 15 | const server = new RPCServer(options); 16 | const httpServer = await server.listen(0); 17 | const port = httpServer.address().port; 18 | const endpoint = `ws://localhost:${port}`; 19 | const close = (...args) => server.close(...args); 20 | server.on('client', client => { 21 | client.handle('Echo', async ({params}) => { 22 | return params; 23 | }); 24 | client.handle('Sleep', async ({params, signal}) => { 25 | await setTimeout(params.ms, null, {signal}); 26 | return `Waited ${params.ms}ms`; 27 | }); 28 | client.handle('Reject', async ({params}) => { 29 | const err = Error("Rejecting"); 30 | Object.assign(err, params); 31 | throw err; 32 | }); 33 | if (extra.withClient) { 34 | extra.withClient(client); 35 | } 36 | }); 37 | return {server, httpServer, port, endpoint, close}; 38 | } 39 | 40 | 41 | function getEchoValidator() { 42 | return createValidator('echo1.0', [ 43 | { 44 | "$schema": "http://json-schema.org/draft-07/schema", 45 | "$id": "urn:Echo.req", 46 | "type": "object", 47 | "properties": { 48 | "val": { 49 | "type": "string" 50 | } 51 | }, 52 | "additionalProperties": false, 53 | "required": ["val"] 54 | }, 55 | { 56 | "$schema": "http://json-schema.org/draft-07/schema", 57 | "$id": "urn:Echo.conf", 58 | "type": "object", 59 | "properties": { 60 | "val": { 61 | "type": "string" 62 | } 63 | }, 64 | "additionalProperties": false, 65 | "required": ["val"] 66 | } 67 | ]); 68 | } 69 | 70 | describe('#constructor', function(){ 71 | 72 | it('should throw if strictMode = true and not all protocol schemas found', async () => { 73 | 74 | assert.throws(() => { 75 | new RPCServer({ 76 | protocols: ['ocpp1.6', 'echo1.0', 'other0.1'], 77 | strictMode: true, 78 | }); 79 | }); 80 | 81 | assert.throws(() => { 82 | new RPCServer({ 83 | protocols: ['ocpp1.6', 'echo1.0', 'other0.1'], 84 | strictMode: ['ocpp1.6', 'other0.1'], 85 | }); 86 | }); 87 | 88 | assert.throws(() => { 89 | // trying to use strict mode with no protocols specified 90 | new RPCServer({ 91 | protocols: [], 92 | strictMode: true, 93 | }); 94 | }); 95 | 96 | assert.throws(() => { 97 | // trying to use strict mode with no protocols specified 98 | new RPCServer({ 99 | strictMode: true, 100 | }); 101 | }); 102 | 103 | assert.doesNotThrow(() => { 104 | new RPCServer({ 105 | protocols: ['ocpp1.6', 'echo1.0', 'other0.1'], 106 | strictModeValidators: [getEchoValidator()], 107 | strictMode: ['ocpp1.6', 'echo1.0'], 108 | }); 109 | }); 110 | 111 | assert.doesNotThrow(() => { 112 | new RPCServer({ 113 | protocols: ['ocpp1.6', 'echo1.0'], 114 | strictModeValidators: [getEchoValidator()], 115 | strictMode: true, 116 | }); 117 | }); 118 | 119 | }); 120 | 121 | }); 122 | 123 | describe('events', function(){ 124 | 125 | it('should emit "client" when client connects', async () => { 126 | 127 | const {endpoint, close, server} = await createServer(); 128 | const cli = new RPCClient({endpoint, identity: 'X'}); 129 | 130 | try { 131 | 132 | const clientProm = once(server, 'client'); 133 | await cli.connect(); 134 | const [client] = await clientProm; 135 | assert.equal(client.identity, 'X'); 136 | 137 | } finally { 138 | await cli.close(); 139 | close(); 140 | } 141 | 142 | }); 143 | 144 | it('should correctly decode the client identity', async () => { 145 | 146 | const identity = 'RPC/ /123'; 147 | const {endpoint, close, server} = await createServer(); 148 | const cli = new RPCClient({endpoint, identity}); 149 | 150 | try { 151 | 152 | const clientProm = once(server, 'client'); 153 | await cli.connect(); 154 | const [client] = await clientProm; 155 | assert.equal(client.identity, identity); 156 | 157 | } finally { 158 | await cli.close(); 159 | close(); 160 | } 161 | 162 | }); 163 | 164 | }); 165 | 166 | 167 | describe('#auth', function(){ 168 | 169 | it("should refuse client with error 400 when subprotocol incorrectly forced", async () => { 170 | 171 | const {endpoint, close, server} = await createServer({protocols: ['a', 'b']}); 172 | 173 | server.auth((accept, reject, handshake) => { 174 | accept({}, 'b'); 175 | }); 176 | 177 | const cli = new RPCClient({ 178 | endpoint, 179 | identity: 'X', 180 | protocols: ['a'], 181 | }); 182 | 183 | try { 184 | 185 | const err = await cli.connect().catch(e=>e); 186 | assert.equal(err.code, 400); 187 | 188 | } finally { 189 | close(); 190 | } 191 | 192 | }); 193 | 194 | it("should not throw on double-accept", async () => { 195 | 196 | const {endpoint, close, server} = await createServer(); 197 | 198 | let allOk; 199 | let waitOk = new Promise(r => {allOk = r;}); 200 | 201 | server.auth((accept, reject, handshake) => { 202 | accept(); 203 | accept(); 204 | allOk(); 205 | }); 206 | 207 | const cli = new RPCClient({ 208 | endpoint, 209 | identity: 'X' 210 | }); 211 | 212 | try { 213 | await cli.connect(); 214 | await waitOk; 215 | } finally { 216 | await cli.close(); 217 | close(); 218 | } 219 | 220 | }); 221 | 222 | it("should not throw on double-reject", async () => { 223 | 224 | const {endpoint, close, server} = await createServer(); 225 | 226 | let allOk; 227 | let waitOk = new Promise(r => {allOk = r;}); 228 | 229 | server.auth((accept, reject, handshake) => { 230 | reject(); 231 | reject(); 232 | allOk(); 233 | }); 234 | 235 | const cli = new RPCClient({ 236 | endpoint, 237 | identity: 'X' 238 | }); 239 | 240 | try { 241 | await assert.rejects(cli.connect(), {code: 404}); 242 | await waitOk; 243 | } finally { 244 | await cli.close(); 245 | close(); 246 | } 247 | 248 | }); 249 | 250 | it("should not throw on reject-after-accept", async () => { 251 | 252 | const {endpoint, close, server} = await createServer(); 253 | 254 | let allOk; 255 | let waitOk = new Promise(r => {allOk = r;}); 256 | 257 | server.auth((accept, reject, handshake) => { 258 | accept(); 259 | reject(); 260 | allOk(); 261 | }); 262 | 263 | const cli = new RPCClient({ 264 | endpoint, 265 | identity: 'X' 266 | }); 267 | 268 | try { 269 | await cli.connect(); 270 | await waitOk; 271 | } finally { 272 | await cli.close(); 273 | close(); 274 | } 275 | 276 | }); 277 | 278 | it("should not throw on accept-after-reject", async () => { 279 | 280 | const {endpoint, close, server} = await createServer(); 281 | 282 | let allOk; 283 | let waitOk = new Promise(r => {allOk = r;}); 284 | 285 | server.auth((accept, reject, handshake) => { 286 | reject(); 287 | accept(); 288 | allOk(); 289 | }); 290 | 291 | const cli = new RPCClient({ 292 | endpoint, 293 | identity: 'X' 294 | }); 295 | 296 | try { 297 | await assert.rejects(cli.connect(), {code: 404}); 298 | await waitOk; 299 | } finally { 300 | await cli.close(); 301 | close(); 302 | } 303 | 304 | }); 305 | 306 | it('should pass identity and endpoint path to auth', async () => { 307 | 308 | const identity = 'RPC/ /123'; 309 | const extraPath = '/extra/long/path'; 310 | const {endpoint, close, server} = await createServer({protocols: ['a', 'b']}); 311 | const cli = new RPCClient({ 312 | endpoint: endpoint + extraPath, 313 | identity, 314 | protocols: ['a', 'b'], 315 | }); 316 | 317 | try { 318 | 319 | let hs; 320 | server.auth((accept, reject, handshake) => { 321 | hs = handshake; 322 | accept(); 323 | }); 324 | 325 | const serverClientProm = once(server, 'client'); 326 | await cli.connect(); 327 | const [serverClient] = await serverClientProm; 328 | 329 | assert.equal(serverClient.identity, identity); 330 | assert.equal(hs.identity, identity); 331 | assert.equal(hs.endpoint, extraPath); 332 | assert.equal(serverClient.protocol, 'a'); 333 | 334 | assert.equal(cli.protocol, serverClient.protocol); 335 | assert.equal(cli.identity, serverClient.identity); 336 | 337 | } finally { 338 | await cli.close(); 339 | close(); 340 | } 341 | 342 | }); 343 | 344 | it('should correctly parse endpoints with double slashes and dots', async () => { 345 | 346 | const identity = 'XX'; 347 | const {endpoint, close, server} = await createServer({}); 348 | 349 | try { 350 | const endpointPaths = [ 351 | {append: '/ocpp', expect: '/ocpp'}, 352 | {append: '//', expect: '//'}, 353 | {append: '//ocpp', expect: '//ocpp'}, 354 | {append: '/ocpp/', expect: '/ocpp/'}, 355 | {append: '/', expect: '/'}, 356 | {append: '///', expect: '///'}, 357 | {append: '/../', expect: '/'}, 358 | {append: '//../', expect: '/'}, 359 | {append: '/ocpp/..', expect: '/'}, 360 | {append: '/ocpp/../', expect: '/'}, 361 | {append: '//ocpp/../', expect: '//'}, 362 | {append: '', expect: '/'}, 363 | ]; 364 | 365 | for (const endpointPath of endpointPaths) { 366 | const fullEndpoint = endpoint + endpointPath.append; 367 | 368 | let hs; 369 | server.auth((accept, reject, handshake) => { 370 | hs = handshake; 371 | accept(); 372 | }); 373 | 374 | const cli = new RPCClient({ 375 | endpoint: fullEndpoint, 376 | identity, 377 | }); 378 | 379 | await cli.connect(); 380 | await cli.close({force: true}); 381 | 382 | assert.equal(hs.endpoint, endpointPath.expect); 383 | assert.equal(hs.identity, identity); 384 | } 385 | 386 | } finally { 387 | close(); 388 | } 389 | 390 | }); 391 | 392 | it('should attach session properties to client', async () => { 393 | 394 | let serverClient; 395 | const extraPath = '/extra/path'; 396 | const identity = 'X'; 397 | const proto = 'a'; 398 | const sessionData = {a: 123, b: {c: 456}}; 399 | 400 | const {endpoint, close, server} = await createServer({protocols: ['x', 'b', proto]}, { 401 | withClient: client => { 402 | serverClient = client; 403 | } 404 | }); 405 | 406 | server.auth((accept, reject, handshake) => { 407 | accept(sessionData, proto); 408 | }); 409 | 410 | const cli = new RPCClient({ 411 | endpoint: endpoint + extraPath, 412 | identity, 413 | protocols: ['x', 'c', proto], 414 | }); 415 | 416 | try { 417 | 418 | await cli.connect(); 419 | assert.deepEqual(serverClient.session, sessionData); 420 | assert.equal(serverClient.protocol, proto); 421 | assert.equal(cli.protocol, proto); 422 | 423 | } finally { 424 | await cli.close(); 425 | close(); 426 | } 427 | 428 | }); 429 | 430 | it('should disconnect client if auth failed', async () => { 431 | 432 | const {endpoint, close, server} = await createServer(); 433 | server.auth((accept, reject) => { 434 | reject(500); 435 | }); 436 | const cli = new RPCClient({endpoint, identity: 'X'}); 437 | 438 | const err = await cli.connect().catch(e=>e); 439 | assert.ok(err instanceof UnexpectedHttpResponse); 440 | assert.equal(err.code, 500); 441 | 442 | close(); 443 | 444 | }); 445 | 446 | it("should disconnect client if server closes during auth", async () => { 447 | 448 | const {endpoint, close, server} = await createServer(); 449 | server.auth((accept, reject) => { 450 | close(); 451 | accept(); 452 | }); 453 | const cli = new RPCClient({endpoint, identity: 'X', reconnect: false}); 454 | 455 | const closeProm = once(cli, 'close'); 456 | await cli.connect(); 457 | 458 | const [closed] = await closeProm; 459 | 460 | assert.equal(closed.code, 1000); 461 | assert.equal(closed.reason, 'Server is no longer open'); 462 | 463 | }); 464 | 465 | 466 | 467 | it('should recognise passwords with colons', async () => { 468 | 469 | const password = 'hun:ter:2'; 470 | 471 | const {endpoint, close, server} = await createServer({}, {withClient: cli => { 472 | cli.handle('GetPassword', () => { 473 | return cli.session.pwd; 474 | }); 475 | }}); 476 | 477 | server.auth((accept, reject, handshake) => { 478 | accept({pwd: handshake.password.toString('utf8')}); 479 | }); 480 | 481 | const cli = new RPCClient({ 482 | endpoint, 483 | identity: 'X', 484 | password, 485 | }); 486 | 487 | try { 488 | await cli.connect(); 489 | const pass = await cli.call('GetPassword'); 490 | assert.equal(password, pass); 491 | 492 | } finally { 493 | cli.close(); 494 | close(); 495 | } 496 | }); 497 | 498 | it('should not get confused with identities and passwords containing colons', async () => { 499 | 500 | const identity = 'a:colonified:ident'; 501 | const password = 'a:colonified:p4ss'; 502 | 503 | let recIdent; 504 | let recPass; 505 | 506 | const {endpoint, close, server} = await createServer(); 507 | server.auth((accept, reject, handshake) => { 508 | recIdent = handshake.identity; 509 | recPass = handshake.password; 510 | accept(); 511 | }); 512 | 513 | const cli = new RPCClient({ 514 | endpoint, 515 | identity, 516 | password, 517 | }); 518 | 519 | try { 520 | await cli.connect(); 521 | assert.equal(password, recPass.toString('utf8')); 522 | assert.equal(identity, recIdent); 523 | 524 | } finally { 525 | cli.close(); 526 | close(); 527 | } 528 | }); 529 | 530 | it('should recognise empty passwords', async () => { 531 | 532 | const password = ''; 533 | let recPass; 534 | 535 | const {endpoint, close, server} = await createServer(); 536 | server.auth((accept, reject, handshake) => { 537 | recPass = handshake.password; 538 | accept(); 539 | }); 540 | 541 | const cli = new RPCClient({ 542 | endpoint, 543 | identity: 'X', 544 | password, 545 | }); 546 | 547 | try { 548 | await cli.connect(); 549 | assert.equal(password, recPass.toString('utf8')); 550 | 551 | } finally { 552 | cli.close(); 553 | close(); 554 | } 555 | }); 556 | 557 | it('should provide undefined password if no authorization header sent', async () => { 558 | 559 | let recPass; 560 | 561 | const {endpoint, close, server} = await createServer(); 562 | server.auth((accept, reject, handshake) => { 563 | recPass = handshake.password; 564 | accept(); 565 | }); 566 | 567 | const cli = new RPCClient({ 568 | endpoint, 569 | identity: 'X', 570 | }); 571 | 572 | try { 573 | await cli.connect(); 574 | assert.equal(undefined, recPass); 575 | 576 | } finally { 577 | cli.close(); 578 | close(); 579 | } 580 | }); 581 | 582 | it('should provide undefined password when identity mismatches username', async () => { 583 | 584 | let recPass; 585 | 586 | const {endpoint, close, server} = await createServer(); 587 | server.auth((accept, reject, handshake) => { 588 | recPass = handshake.password; 589 | accept(); 590 | }); 591 | 592 | const cli = new RPCClient({ 593 | endpoint, 594 | identity: 'X', 595 | headers: { 596 | 'authorization': 'Basic WjoxMjM=', 597 | } 598 | }); 599 | 600 | try { 601 | await cli.connect(); 602 | assert.equal(undefined, recPass); 603 | 604 | } finally { 605 | cli.close(); 606 | close(); 607 | } 608 | }); 609 | 610 | it('should provide undefined password on bad authorization header', async () => { 611 | 612 | let recPass; 613 | 614 | const {endpoint, close, server} = await createServer(); 615 | server.auth((accept, reject, handshake) => { 616 | recPass = handshake.password; 617 | accept(); 618 | }); 619 | 620 | const cli = new RPCClient({ 621 | endpoint, 622 | identity: 'X', 623 | headers: { 624 | 'authorization': 'Basic ?', 625 | } 626 | }); 627 | 628 | try { 629 | await cli.connect(); 630 | assert.equal(undefined, recPass); 631 | 632 | } finally { 633 | cli.close(); 634 | close(); 635 | } 636 | }); 637 | 638 | it('should recognise binary passwords', async () => { 639 | 640 | const password = Buffer.from([ 641 | 0,1,2,3,4,5,6,7,8,9, 642 | 65,66,67,68,69, 643 | 251,252,253,254,255, 644 | ]); 645 | 646 | const {endpoint, close, server} = await createServer({}, {withClient: cli => { 647 | cli.handle('GetPassword', () => { 648 | return cli.session.pwd; 649 | }); 650 | }}); 651 | 652 | server.auth((accept, reject, handshake) => { 653 | accept({pwd: handshake.password.toString('hex')}); 654 | }); 655 | 656 | const cli = new RPCClient({ 657 | endpoint, 658 | identity: 'X', 659 | password, 660 | }); 661 | 662 | try { 663 | await cli.connect(); 664 | const pass = await cli.call('GetPassword'); 665 | assert.equal(password.toString('hex'), pass); 666 | 667 | } finally { 668 | cli.close(); 669 | close(); 670 | } 671 | }); 672 | 673 | }); 674 | 675 | 676 | describe('#close', function(){ 677 | 678 | it('should not allow new connections after close (before clients kicked)', async () => { 679 | 680 | let callReceived; 681 | const callReceivedPromise = new Promise(r => {callReceived = r;}); 682 | 683 | const {endpoint, close, server} = await createServer({}, { 684 | withClient: client => { 685 | client.handle('Test', async () => { 686 | callReceived(); 687 | await setTimeout(50); 688 | return 123; 689 | }); 690 | } 691 | }); 692 | 693 | const cli1 = new RPCClient({ 694 | endpoint, 695 | identity: '1', 696 | reconnect: false, 697 | }); 698 | const cli2 = new RPCClient({ 699 | endpoint, 700 | identity: '2', 701 | }); 702 | 703 | try { 704 | 705 | await cli1.connect(); 706 | const callP = cli1.call('Test'); 707 | await callReceivedPromise; 708 | close({awaitPending: true}); 709 | const [callResult, connResult] = await Promise.allSettled([ 710 | callP, 711 | cli2.connect() 712 | ]); 713 | 714 | assert.equal(callResult.status, 'fulfilled'); 715 | assert.equal(callResult.value, 123); 716 | assert.equal(connResult.status, 'rejected'); 717 | assert.equal(connResult.reason.code, 'ECONNREFUSED'); 718 | 719 | } finally { 720 | close(); 721 | } 722 | 723 | }); 724 | 725 | }); 726 | 727 | describe('#listen', function(){ 728 | 729 | it('should attach to an existing http server', async () => { 730 | 731 | const server = new RPCServer(); 732 | server.on('client', client => { 733 | client.handle('Test', () => { 734 | return 123; 735 | }); 736 | }); 737 | 738 | const httpServer = http.createServer({}, (req, res) => res.end()); 739 | httpServer.on('upgrade', server.handleUpgrade); 740 | await new Promise((resolve, reject) => { 741 | httpServer.listen({port: 0}, err => err ? reject(err) : resolve()); 742 | }); 743 | 744 | const endpoint = 'ws://localhost:'+httpServer.address().port; 745 | 746 | const cli1 = new RPCClient({ 747 | endpoint, 748 | identity: '1' 749 | }); 750 | const cli2 = new RPCClient({ 751 | endpoint, 752 | identity: '2' 753 | }); 754 | 755 | await cli1.connect(); 756 | httpServer.close(); 757 | const [callResult, connResult] = await Promise.allSettled([ 758 | cli1.call('Test'), 759 | cli2.connect(), 760 | ]); 761 | await cli1.close(); // httpServer.close() won't kick clients 762 | 763 | assert.equal(callResult.status, 'fulfilled'); 764 | assert.equal(callResult.value, 123); 765 | assert.equal(connResult.status, 'rejected'); 766 | assert.equal(connResult.reason.code, 'ECONNREFUSED'); 767 | 768 | }); 769 | 770 | it('should create multiple http servers with listen()', async () => { 771 | 772 | const server = new RPCServer(); 773 | const s1 = await server.listen(); 774 | const e1 = 'ws://localhost:'+s1.address().port; 775 | const s2 = await server.listen(); 776 | const e2 = 'ws://localhost:'+s2.address().port; 777 | 778 | const cli1 = new RPCClient({endpoint: e1, identity: '1', reconnect: false}); 779 | const cli2 = new RPCClient({endpoint: e2, identity: '2', reconnect: false}); 780 | 781 | await cli1.connect(); 782 | await cli2.connect(); 783 | 784 | const droppedProm = Promise.all([ 785 | once(cli1, 'close'), 786 | once(cli2, 'close'), 787 | ]); 788 | 789 | server.close({code: 4050}); 790 | await droppedProm; 791 | 792 | }); 793 | 794 | it('should abort with signal', async () => { 795 | 796 | const ac = new AbortController(); 797 | const server = new RPCServer(); 798 | const httpServer = await server.listen(undefined, undefined, {signal: ac.signal}); 799 | const port = httpServer.address().port; 800 | const endpoint = 'ws://localhost:'+port; 801 | 802 | const cli = new RPCClient({endpoint, identity: 'X', reconnect: false}); 803 | 804 | await cli.connect(); 805 | const {code} = await cli.close({code: 4080}); 806 | assert.equal(code, 4080); 807 | 808 | ac.abort(); 809 | 810 | const err = await cli.connect().catch(e=>e); 811 | 812 | assert.equal(err.code, 'ECONNREFUSED'); 813 | 814 | await server.close(); 815 | }); 816 | 817 | 818 | it('should automatically ping clients', async () => { 819 | 820 | const pingIntervalMs = 40; 821 | let pingResolve; 822 | let pingPromise = new Promise(r => {pingResolve = r;}) 823 | 824 | const {endpoint, close, server} = await createServer({ 825 | pingIntervalMs, 826 | }, { 827 | withClient: async (client) => { 828 | const pingRes = await once(client, 'ping'); 829 | pingResolve(pingRes[0]); 830 | } 831 | }); 832 | const cli = new RPCClient({ 833 | endpoint, 834 | identity: 'X', 835 | }); 836 | 837 | try { 838 | const start = Date.now(); 839 | await cli.connect(); 840 | const ping = await pingPromise; 841 | const fin = Date.now() - start; 842 | 843 | assert.ok(fin >= pingIntervalMs); 844 | assert.ok(fin <= pingIntervalMs * 2); 845 | assert.ok(ping.rtt <= pingIntervalMs * 2); 846 | 847 | } finally { 848 | await cli.close(); 849 | close(); 850 | } 851 | }); 852 | 853 | it('should reject non-websocket requests with a 404', async () => { 854 | 855 | const {port, close, server} = await createServer(); 856 | 857 | try { 858 | 859 | const req = http.request('http://localhost:'+port); 860 | req.end(); 861 | 862 | const [res] = await once(req, 'response'); 863 | assert.equal(res.statusCode, 404); 864 | 865 | } catch (err) { 866 | console.log({err}); 867 | } finally { 868 | close(); 869 | } 870 | }); 871 | 872 | }); 873 | 874 | describe('#handleUpgrade', function() { 875 | 876 | it("should not throw if abortHandshake() called after socket already destroyed", async () => { 877 | 878 | const {endpoint, close, server} = await createServer(); 879 | const cli = new RPCClient({ 880 | endpoint, 881 | identity: 'X', 882 | }); 883 | 884 | let completeAuth; 885 | let authCompleted = new Promise(r => {completeAuth = r;}) 886 | 887 | server.auth(async (accept, reject, handshake) => { 888 | reject(400); 889 | abortHandshake(handshake.request.socket, 500); 890 | completeAuth(); 891 | }); 892 | 893 | try { 894 | const conn = cli.connect(); 895 | await assert.rejects(conn, {message: "Bad Request"}); 896 | await authCompleted; 897 | 898 | } finally { 899 | await cli.close(); 900 | close(); 901 | } 902 | 903 | }); 904 | 905 | 906 | it("should abort handshake if server not open", async () => { 907 | 908 | const server = new RPCServer(); 909 | 910 | let abortEvent; 911 | server.on('upgradeAborted', event => { 912 | abortEvent = event; 913 | }); 914 | 915 | let authed = false; 916 | server.auth((accept) => { 917 | // shouldn't get this far 918 | authed = true; 919 | accept() 920 | }); 921 | 922 | let onUpgrade; 923 | let upgradeProm = new Promise(r => {onUpgrade = r;}); 924 | 925 | const httpServer = http.createServer({}, (req, res) => res.end()); 926 | httpServer.on('upgrade', (...args) => onUpgrade(args)); 927 | 928 | await new Promise((resolve, reject) => { 929 | httpServer.listen({port: 0}, err => err ? reject(err) : resolve()); 930 | }); 931 | 932 | const endpoint = 'ws://localhost:'+httpServer.address().port; 933 | 934 | const cli = new RPCClient({ 935 | endpoint, 936 | identity: 'X' 937 | }); 938 | 939 | cli.connect(); 940 | const upgrade = await upgradeProm; 941 | await server.close(); 942 | assert.doesNotReject(server.handleUpgrade(...upgrade)); 943 | assert.equal(authed, false); 944 | assert.equal(abortEvent.error.code, 500); 945 | httpServer.close(); 946 | 947 | }); 948 | 949 | 950 | it("should abort handshake for non-websocket upgrades", async () => { 951 | 952 | const {endpoint, close, server} = await createServer(); 953 | 954 | let abortEvent; 955 | server.on('upgradeAborted', event => { 956 | abortEvent = event; 957 | }); 958 | 959 | let authed = false; 960 | server.auth((accept) => { 961 | // shouldn't get this far 962 | authed = true; 963 | accept() 964 | }); 965 | 966 | try { 967 | 968 | const req = http.request(endpoint.replace(/^ws/,'http') + '/X', { 969 | headers: { 970 | connection: 'Upgrade', 971 | upgrade: '_UNKNOWN_', 972 | 'user-agent': 'test/0', 973 | } 974 | }); 975 | req.end(); 976 | 977 | const [res] = await once(req, 'response'); 978 | 979 | assert.equal(res.statusCode, 400); 980 | assert.equal(authed, false); 981 | assert.ok(abortEvent.error instanceof WebsocketUpgradeError); 982 | assert.equal(abortEvent.request.headers['user-agent'], 'test/0'); 983 | 984 | } finally { 985 | close(); 986 | } 987 | 988 | }); 989 | 990 | 991 | it("should emit upgradeAborted event on auth reject", async () => { 992 | 993 | const {endpoint, close, server} = await createServer(); 994 | 995 | let abortEvent; 996 | server.on('upgradeAborted', event => { 997 | abortEvent = event; 998 | }); 999 | 1000 | server.auth((accept, reject) => { 1001 | reject(499); 1002 | }); 1003 | 1004 | try { 1005 | 1006 | const cli = new RPCClient({ 1007 | endpoint, 1008 | identity: 'X' 1009 | }); 1010 | 1011 | await assert.rejects(cli.connect()); 1012 | 1013 | assert.ok(abortEvent.error instanceof WebsocketUpgradeError); 1014 | assert.equal(abortEvent.error.code, 499); 1015 | 1016 | } finally { 1017 | close(); 1018 | } 1019 | 1020 | }); 1021 | 1022 | it("should abort auth on upgrade error", async () => { 1023 | 1024 | const {endpoint, close, server} = await createServer(); 1025 | const cli = new RPCClient({ 1026 | endpoint, 1027 | identity: 'X', 1028 | }); 1029 | 1030 | let completeAuth; 1031 | let authCompleted = new Promise(r => {completeAuth = r;}) 1032 | 1033 | server.auth(async (accept, reject, handshake, signal) => { 1034 | const abortProm = once(signal, 'abort'); 1035 | await cli.close({force: true, awaitPending: false}); 1036 | await abortProm; 1037 | completeAuth(); 1038 | }); 1039 | 1040 | try { 1041 | const connErr = await cli.connect().catch(e=>e); 1042 | await authCompleted; 1043 | assert.ok(connErr instanceof Error); 1044 | 1045 | } finally { 1046 | await cli.close(); 1047 | close(); 1048 | } 1049 | 1050 | }); 1051 | 1052 | 1053 | }); 1054 | 1055 | }); -------------------------------------------------------------------------------- /test/util.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const { createRPCError, getErrorPlainObject } = require("../lib/util"); 3 | const errors = require('../lib/errors'); 4 | 5 | describe('util', function(){ 6 | 7 | describe('createRPCError', function(){ 8 | 9 | it('should create errors according to their type', () => { 10 | 11 | assert.ok(createRPCError('GenericError') instanceof errors.RPCGenericError); 12 | assert.ok(createRPCError('NotImplemented') instanceof errors.RPCNotImplementedError); 13 | assert.ok(createRPCError('NotSupported') instanceof errors.RPCNotSupportedError); 14 | assert.ok(createRPCError('InternalError') instanceof errors.RPCInternalError); 15 | assert.ok(createRPCError('ProtocolError') instanceof errors.RPCProtocolError); 16 | assert.ok(createRPCError('SecurityError') instanceof errors.RPCSecurityError); 17 | assert.ok(createRPCError('FormationViolation') instanceof errors.RPCFormationViolationError); 18 | assert.ok(createRPCError('FormatViolation') instanceof errors.RPCFormatViolationError); 19 | assert.ok(createRPCError('PropertyConstraintViolation') instanceof errors.RPCPropertyConstraintViolationError); 20 | assert.ok(createRPCError('OccurenceConstraintViolation') instanceof errors.RPCOccurenceConstraintViolationError); 21 | assert.ok(createRPCError('OccurrenceConstraintViolation') instanceof errors.RPCOccurrenceConstraintViolationError); 22 | assert.ok(createRPCError('TypeConstraintViolation') instanceof errors.RPCTypeConstraintViolationError); 23 | assert.ok(createRPCError('MessageTypeNotSupported') instanceof errors.RPCMessageTypeNotSupportedError); 24 | assert.ok(createRPCError('RpcFrameworkError') instanceof errors.RPCFrameworkError); 25 | 26 | }); 27 | 28 | it('should create generic error if code not found', () => { 29 | 30 | assert.ok(createRPCError('_NOTFOUND_') instanceof errors.RPCGenericError); 31 | 32 | }); 33 | 34 | }); 35 | 36 | describe('getErrorPlainObject', function(){ 37 | 38 | it('should fallback to simple error object if json stringification fails', () => { 39 | 40 | const msg = "TEST"; 41 | const err = Error(msg); 42 | err.nested = err; 43 | const plain = getErrorPlainObject(err); 44 | 45 | assert.ok(plain); 46 | assert.ok(plain instanceof Object); 47 | assert.ok(!(plain instanceof Error)); 48 | assert.equal(plain.message, msg); 49 | 50 | }); 51 | 52 | }); 53 | 54 | }); -------------------------------------------------------------------------------- /test/validator.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const errors = require('../lib/errors'); 3 | const { createValidator } = require('../lib/validator'); 4 | 5 | describe('Validator', function(){ 6 | 7 | describe('#validate', function(){ 8 | 9 | it("should throw RPCFormatViolation if validation keyword unknown", () => { 10 | 11 | const validator = createValidator('test', [{ 12 | $schema: "http://json-schema.org/draft-07/schema", 13 | $id: "urn:Test.req", 14 | type: "object", 15 | properties: {}, 16 | }]); 17 | 18 | validator._ajv.errorsText = () => ''; 19 | validator._ajv.getSchema = () => { 20 | const noop = function(){}; 21 | noop.errors = [{ 22 | keyword: '_UNKNOWN_' 23 | }]; 24 | return noop; 25 | }; 26 | 27 | assert.throws(() => { 28 | validator.validate('urn:Test.req', {}); 29 | }, errors.RPCFormatViolationError); 30 | 31 | }); 32 | 33 | }); 34 | 35 | }); -------------------------------------------------------------------------------- /test/ws-util.js: -------------------------------------------------------------------------------- 1 | // Excerpt of test code taken from the 'ws' module 2 | 3 | // Copyright (c) 2011 Einar Otto Stangvik 4 | 5 | // Permission is hereby granted, free of charge, to any person obtaining a copy 6 | // of this software and associated documentation files (the "Software"), to deal 7 | // in the Software without restriction, including without limitation the rights 8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | // copies of the Software, and to permit persons to whom the Software is 10 | // furnished to do so, subject to the following conditions: 11 | 12 | // The above copyright notice and this permission notice shall be included in all 13 | // copies or substantial portions of the Software. 14 | 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | // SOFTWARE. 22 | 23 | const assert = require('assert'); 24 | const { parseSubprotocols: parse } = require('../lib/ws-util'); 25 | 26 | describe('subprotocol', () => { 27 | describe('parse', () => { 28 | it('parses a single subprotocol', () => { 29 | assert.deepStrictEqual(parse('foo'), new Set(['foo'])); 30 | }); 31 | 32 | it('parses multiple subprotocols', () => { 33 | assert.deepStrictEqual( 34 | parse('foo,bar,baz'), 35 | new Set(['foo', 'bar', 'baz']) 36 | ); 37 | }); 38 | 39 | it('ignores the optional white spaces', () => { 40 | const header = 'foo , bar\t, \tbaz\t , qux\t\t,norf'; 41 | 42 | assert.deepStrictEqual( 43 | parse(header), 44 | new Set(['foo', 'bar', 'baz', 'qux', 'norf']) 45 | ); 46 | }); 47 | 48 | it('throws an error if a subprotocol is empty', () => { 49 | [ 50 | [',', 0], 51 | ['foo,,', 4], 52 | ['foo, ,', 6] 53 | ].forEach((element) => { 54 | assert.throws( 55 | () => parse(element[0]), 56 | new RegExp( 57 | `^SyntaxError: Unexpected character at index ${element[1]}$` 58 | ) 59 | ); 60 | }); 61 | }); 62 | 63 | it('throws an error if a subprotocol is duplicated', () => { 64 | ['foo,foo,bar', 'foo,bar,foo'].forEach((header) => { 65 | assert.throws( 66 | () => parse(header), 67 | /^SyntaxError: The "foo" subprotocol is duplicated$/ 68 | ); 69 | }); 70 | }); 71 | 72 | it('throws an error if a white space is misplaced', () => { 73 | [ 74 | ['f oo', 2], 75 | [' foo', 0] 76 | ].forEach((element) => { 77 | assert.throws( 78 | () => parse(element[0]), 79 | new RegExp( 80 | `^SyntaxError: Unexpected character at index ${element[1]}$` 81 | ) 82 | ); 83 | }); 84 | }); 85 | 86 | it('throws an error if a subprotocol contains invalid characters', () => { 87 | [ 88 | ['f@o', 1], 89 | ['f\\oo', 1], 90 | ['foo,b@r', 5] 91 | ].forEach((element) => { 92 | assert.throws( 93 | () => parse(element[0]), 94 | new RegExp( 95 | `^SyntaxError: Unexpected character at index ${element[1]}$` 96 | ) 97 | ); 98 | }); 99 | }); 100 | 101 | it('throws an error if the header value ends prematurely', () => { 102 | ['foo ', 'foo, ', 'foo,bar ', 'foo,bar,'].forEach((header) => { 103 | assert.throws( 104 | () => parse(header), 105 | /^SyntaxError: Unexpected end of input$/ 106 | ); 107 | }); 108 | }); 109 | }); 110 | }); --------------------------------------------------------------------------------