mqtt-plus 1.4.2 → 1.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
  ChangeLog
3
3
  =========
4
4
 
5
+ 1.4.3 (2026-02-21)
6
+ ------------------
7
+
8
+ - IMPROVEMENT: allow JWT expirations
9
+ - DOCUMENTATION: document more internals
10
+
5
11
  1.4.2 (2026-02-21)
6
12
  ------------------
7
13
 
package/README.md CHANGED
@@ -117,10 +117,15 @@ mqtt.on("connect", async () => {
117
117
  Documentation
118
118
  -------------
119
119
 
120
+ Main documentation:
121
+
120
122
  - [**Communication Patterns**](doc/mqtt-plus-comm.md)
121
123
  - [**Application Programming Interface (API)**](doc/mqtt-plus-api.md)
122
- - [**Architecture Overview**](doc/mqtt-plus-architecture.md)
123
- - [Extra: Internals](doc/mqtt-plus-internals.md)
124
+
125
+ Additional auxilliary documentation:
126
+
127
+ - [Extra: Architecture Overview](doc/mqtt-plus-architecture.md)
128
+ - [Extra: Internal Protocol](doc/mqtt-plus-internals.md)
124
129
  - [Extra: Broker Setup](doc/mqtt-plus-broker-setup.md)
125
130
 
126
131
  Notice
@@ -139,7 +144,7 @@ Notice
139
144
  License
140
145
  -------
141
146
 
142
- Copyright (c) 2018-2026 Dr. Ralf S. Engelschall (http://engelschall.com/)
147
+ Copyright © 2018-2026 Dr. Ralf S. Engelschall (http://engelschall.com/)
143
148
 
144
149
  Permission is hereby granted, free of charge, to any person obtaining
145
150
  a copy of this software and associated documentation files (the
@@ -159,3 +164,4 @@ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
159
164
  CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
160
165
  TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
161
166
  SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
167
+
@@ -1,59 +1,380 @@
1
1
 
2
2
  MQTT+ Internals
3
- ---------------
3
+ ===============
4
4
 
5
- In the following, we assume that an **MQTT+** instance is created with:
5
+ Overview
6
+ --------
6
7
 
7
- ```ts
8
- import MQTT from "mqtt"
9
- import MQTTp from "mqtt-plus"
8
+ **MQTT+** implements a message-oriented protocol on top of standard MQTT.
9
+ Each MQTT+ instance is identified by a unique **peer ID** (a NanoID).
10
+ All messages are encoded as structured objects and transported as MQTT
11
+ payloads on well-defined MQTT topics. The protocol supports four
12
+ communication patterns: **Event Emission**, **Service Call**, **Sink
13
+ Push**, and **Source Fetch**.
10
14
 
11
- export type API = {
12
- "example/sample": Event<(a1: string, a2: number) => void>
13
- ...
14
- }
15
- const mqtt = MQTT.connect("...", { ... })
16
- const mqttp = new MQTTp<API>(mqtt, { codec: "json" })
17
- ```
15
+ Message Encoding
16
+ ----------------
17
+
18
+ Messages are encoded using one of two codecs, selected at instance creation:
19
+
20
+ - **CBOR** (default): Binary encoding via the `cbor2` library.
21
+ `Uint8Array` and `Buffer` values are encoded natively.
22
+ MQTT payloads are `Uint8Array`.
23
+
24
+ - **JSON**: Text encoding via a custom `JSONX` serializer.
25
+ `Uint8Array` values are encoded as `{ "__Uint8Array": "<base64>" }`.
26
+ MQTT payloads are UTF-8 strings.
27
+
28
+ Message Base Structure
29
+ ----------------------
30
+
31
+ Every MQTT+ message shares a common base structure:
32
+
33
+ | Field | Type | Description |
34
+ |------------|-------------------|------------------------------------------------|
35
+ | `version` | `string` | Protocol version identifier, format `MQTT+/X.X` (e.g. `MQTT+/1.4`). Must match between peers. |
36
+ | `type` | `string` | One of the 11 message types (see below). |
37
+ | `id` | `string` | NanoID correlating requests with their responses. |
38
+ | `sender` | `string?` | NanoID of the sending peer. |
39
+ | `receiver` | `string?` | NanoID of the intended receiving peer. |
40
+
41
+ The `version` field is checked on every incoming message. A mismatch
42
+ causes the message to be rejected.
43
+
44
+ Message Types
45
+ -------------
46
+
47
+ The protocol defines 11 message types, grouped by communication pattern:
48
+
49
+ ### Event Emission (1 message type)
50
+
51
+ | Type | Direction | Purpose |
52
+ |--------------------|-----------|-------------------------------|
53
+ | `event-emission` | one-way | Fire-and-forget event notification |
54
+
55
+ ### Service Call (2 message types)
56
+
57
+ | Type | Direction | Purpose |
58
+ |--------------------------|-------------|--------------------------|
59
+ | `service-call-request` | caller -> callee | RPC request with parameters |
60
+ | `service-call-response` | callee -> caller | RPC result or error |
61
+
62
+ ### Sink Push (4 message types)
63
+
64
+ | Type | Direction | Purpose |
65
+ |----------------------|------------------|-------------------------------|
66
+ | `sink-push-request` | pusher -> sink | Initiate data push |
67
+ | `sink-push-response` | sink -> pusher | Acknowledge (ack) or reject (nak) |
68
+ | `sink-push-chunk` | pusher -> sink | Transfer a data chunk |
69
+ | `sink-push-credit` | sink -> pusher | Replenish flow control credit |
70
+
71
+ ### Source Fetch (4 message types)
72
+
73
+ | Type | Direction | Purpose |
74
+ |-------------------------|--------------------|-------------------------------|
75
+ | `source-fetch-request` | fetcher -> source | Initiate data fetch |
76
+ | `source-fetch-response` | source -> fetcher | Acknowledge (ack) or reject (nak) |
77
+ | `source-fetch-chunk` | source -> fetcher | Transfer a data chunk |
78
+ | `source-fetch-credit` | fetcher -> source | Replenish flow control credit |
79
+
80
+ Message Fields by Type
81
+ ----------------------
82
+
83
+ ### `event-emission`
84
+
85
+ | Field | Type | Required | Description |
86
+ |----------|------------------------|----------|-------------------------------|
87
+ | `name` | `string` | yes | Endpoint name |
88
+ | `params` | `any[]` | no | Event parameters (max 64) |
89
+ | `auth` | `string[]` | no | JWT tokens (max 8, each max 8192 chars) |
90
+ | `meta` | `Record<string, any>` | no | Arbitrary metadata (non-array object) |
91
+
92
+ ### `service-call-request`
93
+
94
+ | Field | Type | Required | Description |
95
+ |----------|------------------------|----------|-------------------------------|
96
+ | `name` | `string` | yes | Service endpoint name |
97
+ | `params` | `any[]` | no | Call parameters (max 64) |
98
+ | `auth` | `string[]` | no | JWT tokens (max 8) |
99
+ | `meta` | `Record<string, any>` | no | Arbitrary metadata |
18
100
 
19
- Internally, remote services are assigned to MQTT topics. When calling a
20
- remote service named `example/hello` with parameters `"world"` and `42` via...
101
+ ### `service-call-response`
21
102
 
22
- ```ts
23
- mqttp.call("example/hello", "world", 42).then((result) => {
24
- ...
25
- })
103
+ | Field | Type | Required | Description |
104
+ |----------|-----------|----------|--------------------------------------|
105
+ | `result` | `any` | no | Return value on success |
106
+ | `error` | `string` | no | Error message on failure |
107
+
108
+ Exactly one of `result` or `error` is present.
109
+
110
+ ### `sink-push-request`
111
+
112
+ | Field | Type | Required | Description |
113
+ |----------|------------------------|----------|-------------------------------|
114
+ | `name` | `string` | yes | Sink endpoint name |
115
+ | `params` | `any[]` | no | Push parameters (max 64) |
116
+ | `auth` | `string[]` | no | JWT tokens (max 8) |
117
+ | `meta` | `Record<string, any>` | no | Arbitrary metadata |
118
+
119
+ ### `sink-push-response`
120
+
121
+ | Field | Type | Required | Description |
122
+ |----------|------------------------|----------|-------------------------------|
123
+ | `name` | `string` | yes | Sink endpoint name |
124
+ | `error` | `string` | no | Error message (nak) or absent (ack) |
125
+ | `auth` | `string[]` | no | JWT tokens (max 8) |
126
+ | `meta` | `Record<string, any>` | no | Arbitrary metadata |
127
+ | `credit` | `integer` | no | Initial flow control credit (min 1) |
128
+
129
+ ### `sink-push-chunk`
130
+
131
+ | Field | Type | Required | Description |
132
+ |---------|--------------|----------|------------------------------------|
133
+ | `name` | `string` | yes | Sink endpoint name |
134
+ | `chunk` | `Uint8Array` | no | Data chunk payload |
135
+ | `error` | `string` | no | Error message (aborts the stream) |
136
+ | `final` | `boolean` | no | `true` on the last chunk |
137
+
138
+ ### `sink-push-credit`
139
+
140
+ | Field | Type | Required | Description |
141
+ |----------|-----------|----------|-------------------------------------|
142
+ | `name` | `string` | yes | Sink endpoint name |
143
+ | `credit` | `integer` | yes | Number of additional credits (min 1)|
144
+
145
+ ### `source-fetch-request`
146
+
147
+ | Field | Type | Required | Description |
148
+ |----------|------------------------|----------|-------------------------------|
149
+ | `name` | `string` | yes | Source endpoint name |
150
+ | `params` | `any[]` | no | Fetch parameters (max 64) |
151
+ | `auth` | `string[]` | no | JWT tokens (max 8) |
152
+ | `meta` | `Record<string, any>` | no | Arbitrary metadata |
153
+ | `credit` | `integer` | no | Initial flow control credit (min 1) |
154
+
155
+ ### `source-fetch-response`
156
+
157
+ | Field | Type | Required | Description |
158
+ |----------|------------------------|----------|-------------------------------|
159
+ | `name` | `string` | yes | Source endpoint name |
160
+ | `error` | `string` | no | Error message (nak) or absent (ack) |
161
+ | `auth` | `string[]` | no | JWT tokens (max 8) |
162
+ | `meta` | `Record<string, any>` | no | Arbitrary metadata |
163
+
164
+ ### `source-fetch-chunk`
165
+
166
+ | Field | Type | Required | Description |
167
+ |---------|--------------|----------|------------------------------------|
168
+ | `name` | `string` | yes | Source endpoint name |
169
+ | `chunk` | `Uint8Array` | no | Data chunk payload |
170
+ | `error` | `string` | no | Error message (aborts the stream) |
171
+ | `final` | `boolean` | no | `true` on the last chunk |
172
+
173
+ ### `source-fetch-credit`
174
+
175
+ | Field | Type | Required | Description |
176
+ |----------|-----------|----------|-------------------------------------|
177
+ | `name` | `string` | yes | Source endpoint name |
178
+ | `credit` | `integer` | yes | Number of additional credits (min 1)|
179
+
180
+ MQTT Topic Structure
181
+ --------------------
182
+
183
+ MQTT+ maps messages to MQTT topics using the pattern:
184
+
185
+ ```
186
+ {name}/{operation}/{peerId}
26
187
  ```
27
188
 
28
- ...the following message is sent to the permanent MQTT topic
29
- `example/hello/service-call-request/any` (the shown NanoIDs are just
30
- pseudo ones):
189
+ - **`name`**: The endpoint name (e.g. `example/hello`).
190
+ - **`operation`**: The message type (e.g. `service-call-request`).
191
+ - **`peerId`**: Either the target peer's NanoID (for directed messages) or
192
+ `any` (for broadcast messages).
193
+
194
+ ### Broadcast Topics (Requests)
195
+
196
+ Request messages are published to broadcast topics when no specific
197
+ receiver is targeted:
198
+
199
+ | Pattern | Operation | Purpose |
200
+ |----------------------------|-------------------------|------------------------|
201
+ | `{name}/event-emission/any` | `event-emission` | Broadcast event |
202
+ | `{name}/event-emission/{peerId}` | `event-emission` | Directed event |
203
+ | `$share/{share}/{name}/service-call-request/any` | `service-call-request` | Shared service request |
204
+ | `$share/{share}/{name}/source-fetch-request/any` | `source-fetch-request` | Shared fetch request |
205
+ | `$share/{share}/{name}/sink-push-request/any` | `sink-push-request` | Shared push request |
206
+
207
+ Service, source, and sink requests use **MQTT shared subscriptions**
208
+ (`$share/{group}/...`) to distribute load across multiple handlers
209
+ (default group: `"default"`). Event emissions do *not* use shared
210
+ subscriptions by default (all registered handlers receive the event).
211
+
212
+ ### Direct Topics (Responses and Chunks)
213
+
214
+ Response messages, chunks, and credits are sent to peer-specific topics:
215
+
216
+ | Pattern | Operation |
217
+ |--------------------------------------------------|---------------------------|
218
+ | `{name}/service-call-response/{clientId}` | `service-call-response` |
219
+ | `{name}/sink-push-response/{clientId}` | `sink-push-response` |
220
+ | `{name}/sink-push-chunk/{sinkId}` | `sink-push-chunk` |
221
+ | `{name}/sink-push-credit/{pusherId}` | `sink-push-credit` |
222
+ | `{name}/source-fetch-response/{clientId}` | `source-fetch-response` |
223
+ | `{name}/source-fetch-chunk/{clientId}` | `source-fetch-chunk` |
224
+ | `{name}/source-fetch-credit/{sourceId}` | `source-fetch-credit` |
225
+
226
+ The `{clientId}` is the `sender` field from the corresponding request
227
+ message, ensuring responses are routed back to the originating peer only.
228
+
229
+ ### Topic Customization
230
+
231
+ The topic structure is fully customizable through the `topicMake` and
232
+ `topicMatch` options at instance creation time.
233
+
234
+ MQTT QoS Levels
235
+ ---------------
236
+
237
+ | Communication Pattern | QoS | Rationale |
238
+ |-----------------------|-----|-----------------------------------|
239
+ | Event Emission | 0 | Best-effort, fire-and-forget |
240
+ | Service Call | 2 | Exactly-once for reliable RPC |
241
+ | Sink Push | 2 | Exactly-once for reliable data transfer |
242
+ | Source Fetch | 2 | Exactly-once for reliable data transfer |
243
+
244
+ Credit-Based Flow Control
245
+ -------------------------
246
+
247
+ Sink Push and Source Fetch patterns use a **credit-based flow control**
248
+ mechanism to prevent the data producer from overwhelming the consumer.
249
+
250
+ ### How It Works
251
+
252
+ 1. The **consumer** grants an initial number of credits (default: 4)
253
+ to the **producer** (via the response message or request message).
254
+ 2. Each chunk sent by the producer **consumes one credit**.
255
+ 3. When credits are exhausted, the producer **blocks** (waits).
256
+ 4. As the consumer processes chunks, it sends **credit messages** to
257
+ replenish the producer's credit, unblocking it.
258
+ 5. The chunk size is configurable (default: 16 KB).
259
+
260
+ ### Configuration
261
+
262
+ | Option | Default | Description |
263
+ |---------------|-----------|------------------------------------------|
264
+ | `chunkSize` | `16384` | Maximum bytes per chunk (16 KB) |
265
+ | `chunkCredit` | `4` | Number of chunks allowed in-flight |
266
+
267
+ Setting `chunkCredit` to `0` disables flow control entirely.
268
+
269
+ ### Direction of Credit
270
+
271
+ | Pattern | Credit Sender | Credit Receiver | Credit Message Type |
272
+ |--------------|---------------|-----------------|-------------------------|
273
+ | Sink Push | Sink | Pusher | `sink-push-credit` |
274
+ | Source Fetch | Fetcher | Source | `source-fetch-credit` |
275
+
276
+ Authentication
277
+ --------------
278
+
279
+ MQTT+ provides optional JWT-based authentication and role-based
280
+ authorization on any endpoint.
281
+
282
+ ### Setup
283
+
284
+ 1. The **server** sets a shared secret via `credential(secret)`.
285
+ The secret is derived into a 256-bit key using PBKDF2-SHA256
286
+ (600,000 iterations).
287
+
288
+ 2. The server **issues JWT tokens** via `issue({ roles, id?, exp? })`,
289
+ signed with HS256.
290
+
291
+ 3. The **client** stores tokens via `authenticate(token)`.
292
+
293
+ ### Token Transmission
294
+
295
+ When a client sends a request (`event-emission`, `service-call-request`,
296
+ `sink-push-request`, or `source-fetch-request`), all stored JWT tokens
297
+ are included in the `auth` field (max 8 tokens, each max 8192 characters).
298
+
299
+ ### Validation
300
+
301
+ On the server side, the handler validates the tokens against
302
+ the configured credential and required roles:
303
+
304
+ - The token must be a valid HS256-signed JWT.
305
+ - If the token payload contains an `id` field, it must match the `sender`
306
+ peer ID of the request.
307
+ - If the token payload contains an `exp` field, the token must not be
308
+ expired.
309
+ - The token's `roles` array must contain at least one of the
310
+ required roles.
311
+
312
+ ### Authentication Modes
313
+
314
+ | Mode | Behavior |
315
+ |------------|----------------------------------------------------|
316
+ | `require` | Request is rejected if no valid token is found. |
317
+ | `optional` | Request passes even if no valid token is found. |
318
+
319
+ Message Dispatching
320
+ -------------------
321
+
322
+ ### Request Dispatching
323
+
324
+ Incoming request messages are dispatched based on the combination of
325
+ their `type` and `name` fields. The dispatch key is
326
+ `{operation}:{name}` (e.g. `service-call-request:example/hello`).
327
+
328
+ ### Response Dispatching
329
+
330
+ Incoming response messages are dispatched based on the combination
331
+ of their `type` and `id` fields. The dispatch key is
332
+ `{operation}:{requestId}` (e.g. `service-call-response:vwLzfQDu2uEeOdOfIlT42`).
333
+ This ensures responses are correlated to their originating requests.
334
+
335
+ Timeouts
336
+ --------
337
+
338
+ All bi-directional patterns (Service Call, Sink Push, Source Fetch) are
339
+ guarded by a configurable timeout (default: 10 seconds). If no response
340
+ or progress is received within the timeout, the operation is aborted
341
+ with a timeout error. For streaming patterns, each chunk or credit
342
+ message resets the timeout.
343
+
344
+ Error Handling
345
+ --------------
346
+
347
+ Errors are communicated in two ways, depending on timing:
348
+
349
+ 1. **Before data transfer starts**: The response message carries an
350
+ `error` field (nak response).
351
+
352
+ 2. **During data transfer**: A chunk message carries an `error` field
353
+ and `final: true`, terminating the stream.
354
+
355
+ Example Message Exchange
356
+ ------------------------
357
+
358
+ A service call to `example/hello` with parameters `"world"` and `42`:
359
+
360
+ **Request** (published to `example/hello/service-call-request/any`):
31
361
 
32
362
  ```json
33
363
  {
34
- "type": "service-call-request",
35
- "id": "vwLzfQDu2uEeOdOfIlT42",
36
- "name": "example/hello",
37
- "params": [ "world", 42 ],
38
- "sender": "2IBMSk0NPnrz1AeTERoea"
364
+ "version": "MQTT+/1.4",
365
+ "type": "service-call-request",
366
+ "id": "vwLzfQDu2uEeOdOfIlT42",
367
+ "name": "example/hello",
368
+ "params": [ "world", 42 ],
369
+ "sender": "2IBMSk0NPnrz1AeTERoea"
39
370
  }
40
371
  ```
41
372
 
42
- Beforehand, this `example/hello` service should have been established with...
43
-
44
- ```ts
45
- mqttp.service("example/hello", (a1, a2) => {
46
- return `${a1}:${a2}`
47
- })
48
- ```
49
-
50
- ...and then its result, in the above `mqttp.call()` example `"world:42"`, is then
51
- sent back as the following success response
52
- message to the temporary (client-specific) MQTT topic
53
- `example/hello/service-call-response/2IBMSk0NPnrz1AeTERoea`:
373
+ **Response** (published to `example/hello/service-call-response/2IBMSk0NPnrz1AeTERoea`):
54
374
 
55
375
  ```json
56
376
  {
377
+ "version": "MQTT+/1.4",
57
378
  "type": "service-call-response",
58
379
  "id": "vwLzfQDu2uEeOdOfIlT42",
59
380
  "result": "world:42",
@@ -62,7 +383,6 @@ message to the temporary (client-specific) MQTT topic
62
383
  }
63
384
  ```
64
385
 
65
- The `sender` field is the NanoID of the MQTT+ sender instance and
66
- `id` is the NanoID of the particular service request. The `sender` is
67
- used for sending back the response message to the requestor only. The
68
- `id` is used for correlating the response to the request only.
386
+ The `id` field correlates the response to the request. The `sender`
387
+ field in the request is used as the peer-specific suffix in the response
388
+ topic, ensuring only the caller receives the response.
@@ -9,6 +9,7 @@ export type AuthOption = AuthRole | {
9
9
  type TokenPayload = {
10
10
  roles: AuthRole[];
11
11
  id?: string;
12
+ exp?: number;
12
13
  };
13
14
  export declare class AuthTrait<T extends APISchema = APISchema> extends MetaTrait<T> {
14
15
  private _credential;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mqtt-plus",
3
- "version": "1.4.2",
3
+ "version": "1.4.3",
4
4
  "description": "MQTT Communication Patterns",
5
5
  "keywords": [ "mqtt",
6
6
  "event", "emit",
@@ -36,7 +36,7 @@ import { MetaTrait } from "./mqtt-plus-meta"
36
36
  export type AuthMode = "require" | "optional"
37
37
  export type AuthRole = string
38
38
  export type AuthOption = AuthRole | { mode: AuthMode, roles: AuthRole[] }
39
- type TokenPayload = { roles: AuthRole[], id?: string }
39
+ type TokenPayload = { roles: AuthRole[], id?: string, exp?: number }
40
40
 
41
41
  /* authentication trait */
42
42
  export class AuthTrait<T extends APISchema = APISchema> extends MetaTrait<T> {