├── .gitignore ├── sample ├── sample.html ├── package.json ├── sample.js └── Gruntfile.js ├── package.json ├── mqtt-json-rpc.d.ts ├── eslint.mjs ├── README.md └── mqtt-json-rpc.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | sample/sample.bundle.js 3 | -------------------------------------------------------------------------------- /sample/sample.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Sample 6 | 7 | 8 | 9 | Please watch the console! 10 | 11 | 12 | -------------------------------------------------------------------------------- /sample/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sample", 3 | "version": "0.0.0", 4 | "description": "", 5 | "dependencies": { 6 | "@babel/core": "7.22.8", 7 | "mqtt": "4.3.7", 8 | "mqtt-json-rpc": ".." 9 | }, 10 | "devDependencies": { 11 | "grunt": "1.6.1", 12 | "grunt-cli": "1.4.3", 13 | "grunt-browserify": "6.0.0", 14 | "browserify": "17.0.0", 15 | "babelify": "10.0.0", 16 | "@babel/preset-env": "7.22.7" 17 | }, 18 | "scripts": { 19 | "build": "grunt default", 20 | "start": "node sample.js", 21 | "start:browser": "open sample.html", 22 | "clean": "rimraf node_modules sample.bundle.js" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /sample/sample.js: -------------------------------------------------------------------------------- 1 | 2 | const MQTT = require("mqtt") 3 | const RPC = require("mqtt-json-rpc") 4 | 5 | const mqtt = MQTT.connect("wss://127.0.0.1:8889", { 6 | rejectUnauthorized: false, 7 | username: "example", 8 | password: "example" 9 | }) 10 | 11 | const rpc = new RPC(mqtt) 12 | 13 | mqtt.on("error", (err) => { console.log("ERROR", err) }) 14 | mqtt.on("offline", () => { console.log("OFFLINE") }) 15 | mqtt.on("close", () => { console.log("CLOSE") }) 16 | mqtt.on("reconnect", () => { console.log("RECONNECT") }) 17 | mqtt.on("message", (topic, message) => { console.log("RECEIVED", topic, message.toString()) }) 18 | 19 | mqtt.on("connect", () => { 20 | console.log("CONNECT") 21 | rpc.register("example/hello", (a1, a2) => { 22 | console.log("example/hello: request: ", a1, a2) 23 | return `${a1}:${a2}` 24 | }) 25 | rpc.call("example/hello", "world", 42).then((result) => { 26 | console.log("example/hello sucess: ", result) 27 | mqtt.end() 28 | }).catch((err) => { 29 | console.log("example/hello error: ", err) 30 | }) 31 | }) 32 | 33 | -------------------------------------------------------------------------------- /sample/Gruntfile.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = function (grunt) { 3 | grunt.loadNpmTasks("grunt-browserify") 4 | grunt.initConfig({ 5 | browserify: { 6 | "sample": { 7 | files: { 8 | "sample.bundle.js": [ "./sample.js" ] 9 | }, 10 | options: { 11 | transform: [ 12 | [ "babelify", { 13 | presets: [ 14 | [ "@babel/preset-env", { 15 | "targets": { 16 | "browsers": "last 2 versions, > 1%, ie 11" 17 | } 18 | } ] 19 | ] 20 | } ], 21 | // "aliasify" 22 | ], 23 | browserifyOptions: { 24 | standalone: "Sample", 25 | debug: false 26 | } 27 | } 28 | } 29 | } 30 | }) 31 | grunt.registerTask("default", [ "browserify" ]) 32 | } 33 | 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mqtt-json-rpc", 3 | "version": "2.0.0", 4 | "description": "JSON-RPC protocol over MQTT communication", 5 | "keywords": [ "json-rpc", "json", "rpc", "mqtt" ], 6 | "main": "./mqtt-json-rpc.js", 7 | "types": "./mqtt-json-rpc.d.ts", 8 | "license": "MIT", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/rse/mqtt-json-rpc.git" 12 | }, 13 | "author": { 14 | "name": "Dr. Ralf S. Engelschall", 15 | "email": "rse@engelschall.com", 16 | "url": "http://engelschall.com" 17 | }, 18 | "homepage": "https://github.com/rse/mqtt-json-rpc", 19 | "bugs": "https://github.com/rse/mqtt-json-rpc/issues", 20 | "devDependencies": { 21 | "eslint": "9.9.0", 22 | "eslint-plugin-node": "11.1.0", 23 | "globals": "15.9.0", 24 | "@eslint/js": "9.9.0", 25 | "@eslint/eslintrc": "3.1.0", 26 | "mqtt": "5.9.1" 27 | }, 28 | "peerDependencies": { 29 | "mqtt": ">=4.0.0" 30 | }, 31 | "dependencies": { 32 | "pure-uuid": "1.8.1", 33 | "encodr": "1.3.5", 34 | "jsonrpc-lite": "2.2.0" 35 | }, 36 | "engines": { 37 | "node": ">=12.0.0" 38 | }, 39 | "scripts": { 40 | "prepublishOnly": "eslint --config eslint.mjs mqtt-json-rpc.js sample/sample.js" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /mqtt-json-rpc.d.ts: -------------------------------------------------------------------------------- 1 | /* 2 | ** MQTT-JSON-RPC -- JSON-RPC protocol over MQTT communication 3 | ** Copyright (c) 2018-2023 Dr. Ralf S. Engelschall 4 | ** 5 | ** Permission is hereby granted, free of charge, to any person obtaining 6 | ** a copy of this software and associated documentation files (the 7 | ** "Software"), to deal in the Software without restriction, including 8 | ** without limitation the rights to use, copy, modify, merge, publish, 9 | ** distribute, sublicense, and/or sell copies of the Software, and to 10 | ** permit persons to whom the Software is furnished to do so, subject to 11 | ** the following conditions: 12 | ** 13 | ** The above copyright notice and this permission notice shall be included 14 | ** in all copies or substantial portions of the Software. 15 | ** 16 | ** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | ** EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | ** MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | ** IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | ** CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | ** TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | ** SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | */ 24 | 25 | declare module "mqtt-json-rpc" { 26 | export class API { 27 | constructor( 28 | mqtt: any, 29 | options: { 30 | encoding?: string, 31 | timeout?: number 32 | } 33 | ) 34 | registered( 35 | method: string 36 | ): boolean 37 | register( 38 | method: string, 39 | callback: (...params: any[]) => any 40 | ): Promise 41 | unregister( 42 | method: string 43 | ): Promise 44 | notify ( 45 | method: string, 46 | ...params: any[] 47 | ): void 48 | call ( 49 | method: string, 50 | ...params: any[] 51 | ): Promise 52 | } 53 | } 54 | 55 | -------------------------------------------------------------------------------- /eslint.mjs: -------------------------------------------------------------------------------- 1 | /* 2 | ** MQTT-JSON-RPC -- JSON-RPC protocol over MQTT communication 3 | ** Copyright (c) 2018-2023 Dr. Ralf S. Engelschall 4 | ** 5 | ** Permission is hereby granted, free of charge, to any person obtaining 6 | ** a copy of this software and associated documentation files (the 7 | ** "Software"), to deal in the Software without restriction, including 8 | ** without limitation the rights to use, copy, modify, merge, publish, 9 | ** distribute, sublicense, and/or sell copies of the Software, and to 10 | ** permit persons to whom the Software is furnished to do so, subject to 11 | ** the following conditions: 12 | ** 13 | ** The above copyright notice and this permission notice shall be included 14 | ** in all copies or substantial portions of the Software. 15 | ** 16 | ** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | ** EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | ** MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | ** IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | ** CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | ** TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | ** SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | */ 24 | 25 | import globals from "globals" 26 | import path from "node:path" 27 | import { fileURLToPath } from "node:url" 28 | import js from "@eslint/js" 29 | import { FlatCompat } from "@eslint/eslintrc" 30 | 31 | const __filename = fileURLToPath(import.meta.url) 32 | const __dirname = path.dirname(__filename) 33 | const compat = new FlatCompat({ 34 | baseDirectory: __dirname, 35 | recommendedConfig: js.configs.recommended, 36 | allConfig: js.configs.all 37 | }) 38 | 39 | export default [ 40 | ...compat.extends("eslint:recommended"), { 41 | languageOptions: { 42 | globals: { 43 | ...globals.browser, 44 | ...Object.fromEntries(Object.entries(globals.node).map(([key]) => [key, "off"])), 45 | ...globals.commonjs, 46 | ...globals.worker, 47 | ...globals.serviceworker, 48 | process: true, 49 | }, 50 | ecmaVersion: 12, 51 | sourceType: "module", 52 | parserOptions: { ecmaFeatures: { jsx: false } } 53 | }, 54 | rules: { 55 | "indent": [ "error", 4, { SwitchCase: 1 } ], 56 | "linebreak-style": [ "error", "unix" ], 57 | "semi": [ "error", "never" ], 58 | "operator-linebreak": [ "error", "after", { overrides: { "&&": "before", "||": "before", ":": "before" } } ], 59 | "brace-style": [ "error", "stroustrup", { allowSingleLine: true } ], 60 | "quotes": [ "error", "double" ], 61 | "no-multi-spaces": "off", 62 | "no-multiple-empty-lines": "off", 63 | "key-spacing": "off", 64 | "object-property-newline": "off", 65 | "curly": "off", 66 | "space-in-parens": "off", 67 | "no-console": "off", 68 | "lines-between-class-members": "off", 69 | "array-bracket-spacing": "off", 70 | }, 71 | }] 72 | 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | MQTT-JSON-RPC 3 | ============= 4 | 5 | [JSON-RPC](http://www.jsonrpc.org/) protocol over [MQTT](http://mqtt.org/) communication. 6 | 7 |

8 | 9 | 10 |

11 | 12 | 13 | Installation 14 | ------------ 15 | 16 | ```shell 17 | $ npm install mqtt mqtt-json-rpc 18 | ``` 19 | 20 | About 21 | ----- 22 | 23 | This is an addon API for the 24 | [MQTT.js](https://www.npmjs.com/package/mqtt) API of 25 | [Node.js](https://nodejs.org/), for 26 | [Remote Procedure Call](https://en.wikipedia.org/wiki/Remote_procedure_call) (RPC) 27 | communication based on the [JSON-RPC](http://www.jsonrpc.org/) 28 | protocol. This allows a bi-directional request/response-style communication over 29 | the technically uni-directional message protocol [MQTT](http://mqtt.org). 30 | 31 | Usage 32 | ----- 33 | 34 | #### Server: 35 | 36 | ```js 37 | const MQTT = require("mqtt") 38 | const RPC = require("mqtt-json-rpc") 39 | 40 | const mqtt = MQTT.connect("wss://127.0.0.1:8889", { ... }) 41 | const rpc = new RPC(mqtt) 42 | 43 | mqtt.on("connect", () => { 44 | rpc.register("example/hello", (a1, a2) => { 45 | console.log("example/hello: request: ", a1, a2) 46 | return `${a1}:${a2}` 47 | }) 48 | }) 49 | ``` 50 | 51 | #### Client: 52 | 53 | ```js 54 | const MQTT = require("mqtt") 55 | const RPC = require("mqtt-json-rpc") 56 | 57 | const mqtt = MQTT.connect("wss://127.0.0.1:8889", { ... }) 58 | const rpc = new RPC(mqtt) 59 | 60 | mqtt.on("connect", () => { 61 | rpc.call("example/hello", "world", 42).then((response) => { 62 | console.log("example/hello response: ", response) 63 | mqtt.end() 64 | }) 65 | }) 66 | ``` 67 | 68 | Application Programming Interface 69 | --------------------------------- 70 | 71 | The MQTT-JSON-RPC API provides the following methods (check out the 72 | corresponding [TypeScript definition](mqtt-json-rpc.d.ts)) file): 73 | 74 | - `constructor(mqtt: MQTT, options?: { encoding?: string, timeout?: number }): MQTT-JSON-RPC`:
75 | The `mqtt` is the [MQTT.js](https://www.npmjs.com/package/mqtt) instance. 76 | The optional `encoding` option can be either `json` (default), `msgpack` or `cbor`. 77 | The optional `timeout` option is the timeout in seconds. 78 | 79 | - `MQTT-JSON-RPC#registered(method: string): boolean`:
80 | Check for the previous registration of a method. The `method` has to 81 | be a valid MQTT topic name. The method returns `true` if `method` is 82 | already registered, else it returns `false`. 83 | 84 | - `MQTT-JSON-RPC#register(method: string, callback: (...args: any[]) => any): Promise`:
85 | Register a method. The `method` has to be a valid MQTT topic 86 | name. The `callback` is called with the `params` passed to 87 | the remote `MQTT-JSON-RPC#notify()` or `MQTT-JSON-RPC#call()`. For 88 | a remote `MQTT-JSON-RPC#notify()`, the return value of `callback` will be 89 | ignored. For a remote `MQTT-JSON-RPC#call()`, the return value of `callback` 90 | will resolve the promise returned by the remote `MQTT-JSON-RPC#call()`. 91 | Internally, on the MQTT broker the topic `${method}/request` is 92 | subscribed. 93 | 94 | - `MQTT-JSON-RPC#unregister(method: string): Promise`:
95 | Unregister a previously registered method. 96 | Internally, on the MQTT broker the topic `${method}/request` is unsubscribed. 97 | 98 | - `MQTT-JSON-RPC#notify(method: string, ...params: any[]): void`:
99 | Notify a method. The remote `MQTT-JSON-RPC#register()` `callback` is called 100 | with `params` and its return value is silently ignored. 101 | 102 | - `MQTT-JSON-RPC#call(method: string, ...params: any[]): Promise`:
103 | Call a method. The remote `MQTT-JSON-RPC#register()` `callback` is 104 | called with `params` and its return value resolves the returned 105 | `Promise`. If the remote `callback` throws an exception, this rejects 106 | the returned `Promise`. Internally, on the MQTT broker the topic 107 | `${method}/response/` is temporarily subscribed for receiving the 108 | response (`` is a UUID v1 to uniquely identify the MQTT-JSON-RPC 109 | caller instance). 110 | 111 | Internals 112 | --------- 113 | 114 | Internally, remote methods are assigned to MQTT topics. When calling a 115 | remote method named `example/hello` with parameters `"world"` and `42` via... 116 | 117 | ```js 118 | rpc.call("example/hello", "world", 42).then((result) => { 119 | ... 120 | }) 121 | ``` 122 | 123 | ...the following JSON-RPC 2.0 request message is sent to the permanent MQTT 124 | topic `example/hello/request`: 125 | 126 | ```json 127 | { 128 | "jsonrpc": "2.0", 129 | "id": "d1acc980-0e4e-11e8-98f0-ab5030b47df4:d1db7aa0-0e4e-11e8-b1d9-5f0ab230c0d9", 130 | "method": "example/hello", 131 | "params": [ "world", 42 ] 132 | } 133 | ``` 134 | 135 | Beforehand, this `example/hello` method should have been registered with... 136 | 137 | ```js 138 | rpc.register("example/hello", (a1, a2) => { 139 | return `${a1}:${a2}` 140 | }) 141 | ``` 142 | 143 | ...and then its result, in the above `rpc.call` example `"world:42"`, is then 144 | sent back as the following JSON-RPC 2.0 success response 145 | message to the temporary (client-specific) MQTT topic 146 | `example/hello/response/d1acc980-0e4e-11e8-98f0-ab5030b47df4`: 147 | 148 | ```json 149 | { 150 | "jsonrpc": "2.0", 151 | "id": "d1acc980-0e4e-11e8-98f0-ab5030b47df4:d1db7aa0-0e4e-11e8-b1d9-5f0ab230c0d9", 152 | "result": "world:42" 153 | } 154 | ``` 155 | 156 | The JSON-RPC 2.0 `id` field always consists of `:`, where 157 | `` is the UUID v1 of the MQTT-JSON-RPC instance and `` is 158 | the UUID v1 of the particular method request. The `` is used for 159 | sending back the JSON-RPC 2.0 response message to the requestor only. 160 | 161 | Example 162 | ------- 163 | 164 | For a real test-drive of MQTT-JSON-RPC, install the 165 | [Mosquitto](https://mosquitto.org/) MQTT broker with at least a "MQTT 166 | over Secure-WebSockets" lister in the `mosquitto.conf` file like... 167 | 168 | ``` 169 | [...] 170 | 171 | password_file mosquitto-pwd.txt 172 | acl_file mosquitto-acl.txt 173 | 174 | [...] 175 | 176 | # additional listener (wss: MQTT over WebSockets+SSL/TLS) 177 | listener 8889 127.0.0.1 178 | max_connections -1 179 | protocol websockets 180 | cafile mosquitto-ca.crt.pem 181 | certfile mosquitto-sv.crt.pem 182 | keyfile mosquitto-sv.key.pem 183 | require_certificate false 184 | 185 | [...] 186 | ``` 187 | 188 | ...and an access control list in `mosquitto-acl.txt` like... 189 | 190 | ``` 191 | user example 192 | topic readwrite example/# 193 | ``` 194 | 195 | ...and an `example` user (with password `example`) in `mosquitto-pwd.txt` like: 196 | 197 | ``` 198 | example:$6$awYNe6oCAi+xlvo5$mWIUqyy4I0O3nJ99lP1mkRVqsDGymF8en5NChQQxf7KrVJLUp1SzrrVDe94wWWJa3JGIbOXD9wfFGZdi948e6A== 199 | ``` 200 | 201 | Then test-drive MQTT-JSON-RPC with a complete [sample](sample/sample.js) to see 202 | MQTT-JSON-RPC in action and tracing its communication: 203 | 204 | ```js 205 | const MQTT = require("mqtt") 206 | const RPC = require("mqtt-json-rpc") 207 | 208 | const mqtt = MQTT.connect("wss://127.0.0.1:8889", { 209 | rejectUnauthorized: false, 210 | username: "example", 211 | password: "example" 212 | }) 213 | 214 | const rpc = new RPC(mqtt) 215 | 216 | mqtt.on("error", (err) => { console.log("ERROR", err) }) 217 | mqtt.on("offline", () => { console.log("OFFLINE") }) 218 | mqtt.on("close", () => { console.log("CLOSE") }) 219 | mqtt.on("reconnect", () => { console.log("RECONNECT") }) 220 | mqtt.on("message", (topic, message) => { console.log("RECEIVED", topic, message.toString()) }) 221 | 222 | mqtt.on("connect", () => { 223 | console.log("CONNECT") 224 | rpc.register("example/hello", (a1, a2) => { 225 | console.log("example/hello: request: ", a1, a2) 226 | return `${a1}:${a2}` 227 | }) 228 | rpc.call("example/hello", "world", 42).then((result) => { 229 | console.log("example/hello sucess: ", result) 230 | mqtt.end() 231 | }).catch((err) => { 232 | console.log("example/hello error: ", err) 233 | }) 234 | }) 235 | ``` 236 | 237 | The output will be: 238 | 239 | ``` 240 | $ node sample.js 241 | CONNECT 242 | RECEIVED example/hello/request {"jsonrpc":"2.0","id":"1099cb50-bd2b-11eb-8198-43568ad728c4:10bf7bc0-bd2b-11eb-bac6-439c565b651a","method":"example/hello","params":["world",42]} 243 | example/hello: request: world 42 244 | RECEIVED example/hello/response/1099cb50-bd2b-11eb-8198-43568ad728c4 {"jsonrpc":"2.0","id":"1099cb50-bd2b-11eb-8198-43568ad728c4:10bf7bc0-bd2b-11eb-bac6-439c565b651a","result":"world:42"} 245 | example/hello sucess: world:42 246 | CLOSE 247 | ``` 248 | 249 | License 250 | ------- 251 | 252 | Copyright (c) 2018-2023 Dr. Ralf S. Engelschall (http://engelschall.com/) 253 | 254 | Permission is hereby granted, free of charge, to any person obtaining 255 | a copy of this software and associated documentation files (the 256 | "Software"), to deal in the Software without restriction, including 257 | without limitation the rights to use, copy, modify, merge, publish, 258 | distribute, sublicense, and/or sell copies of the Software, and to 259 | permit persons to whom the Software is furnished to do so, subject to 260 | the following conditions: 261 | 262 | The above copyright notice and this permission notice shall be included 263 | in all copies or substantial portions of the Software. 264 | 265 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 266 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 267 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 268 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 269 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 270 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 271 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 272 | 273 | -------------------------------------------------------------------------------- /mqtt-json-rpc.js: -------------------------------------------------------------------------------- 1 | /* 2 | ** MQTT-JSON-RPC -- JSON-RPC protocol over MQTT communication 3 | ** Copyright (c) 2018-2023 Dr. Ralf S. Engelschall 4 | ** 5 | ** Permission is hereby granted, free of charge, to any person obtaining 6 | ** a copy of this software and associated documentation files (the 7 | ** "Software"), to deal in the Software without restriction, including 8 | ** without limitation the rights to use, copy, modify, merge, publish, 9 | ** distribute, sublicense, and/or sell copies of the Software, and to 10 | ** permit persons to whom the Software is furnished to do so, subject to 11 | ** the following conditions: 12 | ** 13 | ** The above copyright notice and this permission notice shall be included 14 | ** in all copies or substantial portions of the Software. 15 | ** 16 | ** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | ** EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | ** MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | ** IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | ** CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | ** TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | ** SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | */ 24 | 25 | /* external requirements */ 26 | const UUID = require("pure-uuid") 27 | const JSONRPC = require("jsonrpc-lite") 28 | const Encodr = require("encodr") 29 | 30 | /* the API class */ 31 | class API { 32 | constructor (mqtt, options = {}) { 33 | /* determine options */ 34 | this.options = Object.assign({ 35 | encoding: "json", 36 | timeout: 10 * 1000 37 | }, options) 38 | 39 | /* remember the underlying MQTT Client instance */ 40 | this.mqtt = mqtt 41 | 42 | /* make an encoder */ 43 | this.encodr = new Encodr(this.options.encoding) 44 | 45 | /* generate unique client identifier */ 46 | this.cid = (new UUID(1)).format("std") 47 | 48 | /* internal states */ 49 | this.registry = {} 50 | this.requests = {} 51 | this.subscriptions = {} 52 | 53 | /* hook into the MQTT message processing */ 54 | this.mqtt.on("message", (topic, message) => { 55 | this._onServer(topic, message) 56 | this._onClient(topic, message) 57 | }) 58 | } 59 | 60 | /* 61 | * RPC server/response side 62 | */ 63 | 64 | /* check for the registration of an RPC method */ 65 | registered (method) { 66 | return (this.registry[method] !== undefined) 67 | } 68 | 69 | /* register an RPC method */ 70 | register (method, callback) { 71 | if (this.registry[method] !== undefined) 72 | throw new Error(`register: method "${method}" already registered`) 73 | this.registry[method] = callback 74 | return new Promise((resolve, reject) => { 75 | this.mqtt.subscribe(`${method}/request`, { qos: 2 }, (err, granted) => { 76 | if (err) 77 | reject(err) 78 | else 79 | resolve(granted) 80 | }) 81 | }) 82 | } 83 | 84 | /* unregister an RPC method */ 85 | unregister (method) { 86 | if (this.registry[method] === undefined) 87 | throw new Error(`unregister: method "${method}" not registered`) 88 | delete this.registry[method] 89 | return new Promise((resolve, reject) => { 90 | this.mqtt.unsubscribe(`${method}/request`, (err, packet) => { 91 | if (err) 92 | reject(err) 93 | else 94 | resolve(packet) 95 | }) 96 | }) 97 | } 98 | 99 | /* handle incoming RPC method request */ 100 | _onServer (topic, message) { 101 | /* ensure we handle only MQTT RPC requests */ 102 | let m 103 | if ((m = topic.match(/^(.+)\/request$/)) === null) 104 | return 105 | const method = m[1] 106 | 107 | /* ensure we handle only JSON-RPC payloads */ 108 | const parsed = JSONRPC.parseObject(this.encodr.decode(message)) 109 | if (!(typeof parsed === "object" && typeof parsed.type === "string")) 110 | return 111 | 112 | /* ensure we handle a consistent JSON-RPC method request */ 113 | if (parsed.payload.method !== method) 114 | return 115 | 116 | /* dispatch according to JSON-RPC type */ 117 | if (parsed.type === "notification") { 118 | /* just deliver notification */ 119 | if (typeof this.registry[method] === "function") 120 | this.registry[method](...parsed.payload.params) 121 | } 122 | else if (parsed.type === "request") { 123 | /* deliver request and send response */ 124 | let response 125 | if (typeof this.registry[method] === "function") 126 | response = Promise.resolve().then(() => this.registry[method](...parsed.payload.params)) 127 | else 128 | response = Promise.reject(JSONRPC.JsonRpcError.methodNotFound({ method, id: parsed.payload.id })) 129 | response.then((response) => { 130 | /* create JSON-RPC success response */ 131 | return JSONRPC.success(parsed.payload.id, response) 132 | }, (error) => { 133 | /* create JSON-RPC error response */ 134 | return this._buildError(parsed.payload, error) 135 | }).then((response) => { 136 | /* send MQTT response message */ 137 | response = this.encodr.encode(response) 138 | const m = parsed.payload.id.match(/^(.+):.+$/) 139 | const cid = m[1] 140 | this.mqtt.publish(`${method}/response/${cid}`, response, { qos: 0 }) 141 | }) 142 | } 143 | } 144 | 145 | /* 146 | * RPC client/request side 147 | */ 148 | 149 | /* notify peer ("fire and forget") */ 150 | notify (method, ...params) { 151 | let request = JSONRPC.notification(method, params) 152 | request = this.encodr.encode(request) 153 | this.mqtt.publish(`${method}/request`, request, { qos: 0 }) 154 | } 155 | 156 | /* call peer ("request and response") */ 157 | call (method, ...params) { 158 | /* remember callback and create JSON-RPC request */ 159 | const rid = `${this.cid}:${(new UUID(1)).format("std")}` 160 | const promise = new Promise((resolve, reject) => { 161 | let timer = setTimeout(() => { 162 | reject(new Error("communication timeout")) 163 | timer = null 164 | }, this.options.timeout) 165 | this.requests[rid] = (err, result) => { 166 | if (timer !== null) { 167 | clearTimeout(timer) 168 | timer = null 169 | } 170 | if (err) reject(err) 171 | else resolve(result) 172 | } 173 | }) 174 | let request = JSONRPC.request(rid, method, params) 175 | 176 | /* subscribe for response */ 177 | this._responseSubscribe(method) 178 | 179 | /* send MQTT request message */ 180 | request = this.encodr.encode(request) 181 | this.mqtt.publish(`${method}/request`, request, { qos: 2 }, (err) => { 182 | if (err) { 183 | /* handle request failure */ 184 | this._responseUnsubscribe(method) 185 | this.requests[rid](err, undefined) 186 | } 187 | }) 188 | 189 | return promise 190 | } 191 | 192 | /* handle incoming RPC method response */ 193 | _onClient (topic, message) { 194 | /* ensure we handle only MQTT RPC responses */ 195 | let m 196 | if ((m = topic.match(/^(.+)\/response\/(.+)$/)) === null) 197 | return 198 | const [ , method, cid ] = m 199 | 200 | /* ensure we really handle only MQTT RPC responses for us */ 201 | if (cid !== this.cid) 202 | return 203 | 204 | /* ensure we handle only JSON-RPC payloads */ 205 | const parsed = JSONRPC.parseObject(this.encodr.decode(message)) 206 | if (!(typeof parsed === "object" && typeof parsed.type === "string")) 207 | return 208 | 209 | /* dispatch according to JSON-RPC type */ 210 | if (parsed.type === "success" || parsed.type === "error") { 211 | const rid = parsed.payload.id 212 | if (typeof this.requests[rid] === "function") { 213 | /* call callback function */ 214 | if (parsed.type === "success") 215 | this.requests[rid](undefined, parsed.payload.result) 216 | else 217 | this.requests[rid](parsed.payload.error, undefined) 218 | 219 | /* unsubscribe from response */ 220 | delete this.requests[rid] 221 | this._responseUnsubscribe(method) 222 | } 223 | } 224 | } 225 | 226 | /* subscribe to RPC response */ 227 | _responseSubscribe (method) { 228 | const topic = `${method}/response/${this.cid}` 229 | if (this.subscriptions[topic] === undefined) { 230 | this.subscriptions[topic] = 0 231 | this.mqtt.subscribe(topic, { qos: 2 }) 232 | } 233 | this.subscriptions[topic]++ 234 | } 235 | 236 | /* unsubscribe from RPC response */ 237 | _responseUnsubscribe (method) { 238 | const topic = `${method}/response/${this.cid}` 239 | this.subscriptions[topic]-- 240 | if (this.subscriptions[topic] === 0) { 241 | delete this.subscriptions[topic] 242 | this.mqtt.unsubscribe(topic) 243 | } 244 | } 245 | 246 | /* determine RPC error */ 247 | _buildError (payload, error) { 248 | let rpcError 249 | switch (typeof error) { 250 | case "undefined": 251 | rpcError = new JSONRPC.JsonRpcError("undefined error", 0) 252 | break 253 | case "string": 254 | rpcError = new JSONRPC.JsonRpcError(error, -1) 255 | break 256 | case "number": 257 | case "bigint": 258 | rpcError = new JSONRPC.JsonRpcError("application error", error) 259 | break 260 | case "object": 261 | if (error === null) 262 | rpcError = new JSONRPC.JsonRpcError("undefined error", 0) 263 | else { 264 | if (error instanceof JSONRPC.JsonRpcError) 265 | rpcError = error 266 | else if (error instanceof Error) 267 | rpcError = new JSONRPC.JsonRpcError(error.toString(), -100, error) 268 | else 269 | rpcError = new JSONRPC.JsonRpcError("application error", -100, error) 270 | } 271 | break 272 | default: 273 | rpcError = new JSONRPC.JsonRpcError("unspecified error", 0, { data: error }) 274 | break 275 | } 276 | return JSONRPC.error(payload.id, rpcError) 277 | } 278 | } 279 | 280 | /* export the standard way */ 281 | module.exports = API 282 | 283 | --------------------------------------------------------------------------------