├── .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 |
--------------------------------------------------------------------------------