mqtt-plus 1.4.25 → 1.4.27

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.
Files changed (68) hide show
  1. package/.ase/config.yaml +1 -0
  2. package/.ase/service.log +34 -0
  3. package/.ase/service.yaml +1 -1
  4. package/.ase/task/TASK-P18.md +1 -1
  5. package/.ase/task/TASK-P21.md +1 -1
  6. package/.claude/settings.local.json +4 -1
  7. package/AGENTS.md +1 -1
  8. package/CHANGELOG.md +29 -5
  9. package/README.md +2 -2
  10. package/doc/mqtt-plus-broker-setup.md +1 -1
  11. package/doc/mqtt-plus-comm.md +3 -3
  12. package/doc/mqtt-plus-internals.md +6 -0
  13. package/dst-stage1/mqtt-plus-base.d.ts +2 -2
  14. package/dst-stage1/mqtt-plus-base.js +13 -10
  15. package/dst-stage1/mqtt-plus-error.js +54 -28
  16. package/dst-stage1/mqtt-plus-event.js +3 -1
  17. package/dst-stage1/mqtt-plus-options.js +7 -0
  18. package/dst-stage1/mqtt-plus-service.js +9 -6
  19. package/dst-stage1/mqtt-plus-sink.js +27 -54
  20. package/dst-stage1/mqtt-plus-source.js +13 -9
  21. package/dst-stage1/mqtt-plus-util.d.ts +1 -0
  22. package/dst-stage1/mqtt-plus-util.js +25 -4
  23. package/dst-stage1/mqtt-plus.d.ts +5 -1
  24. package/dst-stage1/mqtt-plus.js +10 -1
  25. package/dst-stage2/mqtt-plus.cjs.cjs +169 -123
  26. package/dst-stage2/mqtt-plus.esm.js +168 -122
  27. package/dst-stage2/mqtt-plus.umd.js +11 -11
  28. package/etc/stx.conf +3 -2
  29. package/etc/vite.mts +1 -1
  30. package/package.d/@typescript-eslint+typescript-estree+8.66.0.patch +12 -0
  31. package/package.d/{vite+8.1.4.patch → vite+8.2.1.patch} +2 -2
  32. package/package.json +16 -15
  33. package/src/mqtt-plus-base.ts +15 -12
  34. package/src/mqtt-plus-error.ts +65 -33
  35. package/src/mqtt-plus-event.ts +2 -1
  36. package/src/mqtt-plus-options.ts +8 -0
  37. package/src/mqtt-plus-service.ts +9 -6
  38. package/src/mqtt-plus-sink.ts +28 -54
  39. package/src/mqtt-plus-source.ts +13 -9
  40. package/src/mqtt-plus-util.ts +27 -4
  41. package/src/mqtt-plus.ts +14 -3
  42. package/tst/mqtt-plus-0-fixture.ts +1 -0
  43. package/tst/mqtt-plus-2-event.spec.ts +25 -0
  44. package/tst/mqtt-plus-4-sink.spec.ts +23 -0
  45. package/tst/mqtt-plus-5-source.spec.ts +26 -0
  46. package/tst/mqtt-plus-6-misc.spec.ts +58 -0
  47. package/tst/mqtt-plus-8-run.spec.ts +60 -0
  48. package/dst-stage1/mqtt-plus-api.js.map +0 -1
  49. package/dst-stage1/mqtt-plus-auth.js.map +0 -1
  50. package/dst-stage1/mqtt-plus-base.js.map +0 -1
  51. package/dst-stage1/mqtt-plus-codec.js.map +0 -1
  52. package/dst-stage1/mqtt-plus-encode.js.map +0 -1
  53. package/dst-stage1/mqtt-plus-error.js.map +0 -1
  54. package/dst-stage1/mqtt-plus-event.js.map +0 -1
  55. package/dst-stage1/mqtt-plus-info.js.map +0 -1
  56. package/dst-stage1/mqtt-plus-meta.js.map +0 -1
  57. package/dst-stage1/mqtt-plus-msg.js.map +0 -1
  58. package/dst-stage1/mqtt-plus-options.js.map +0 -1
  59. package/dst-stage1/mqtt-plus-service.js.map +0 -1
  60. package/dst-stage1/mqtt-plus-sink.js.map +0 -1
  61. package/dst-stage1/mqtt-plus-source.js.map +0 -1
  62. package/dst-stage1/mqtt-plus-subscription.js.map +0 -1
  63. package/dst-stage1/mqtt-plus-timer.js.map +0 -1
  64. package/dst-stage1/mqtt-plus-trace.js.map +0 -1
  65. package/dst-stage1/mqtt-plus-util.js.map +0 -1
  66. package/dst-stage1/mqtt-plus-version.js.map +0 -1
  67. package/dst-stage1/mqtt-plus.js.map +0 -1
  68. package/package.d/@typescript-eslint+typescript-estree+8.63.0.patch +0 -12
@@ -0,0 +1 @@
1
+ null
package/.ase/service.log CHANGED
@@ -10433,3 +10433,37 @@ ase: [2026-07-11 12:45:57.471]: [INFO]: mcp: POST /mcp [initialize]
10433
10433
  ase: [2026-07-11 12:45:57.487]: [INFO]: mcp: POST /mcp [tools/list]
10434
10434
  ase: [2026-07-11 12:45:57.494]: [INFO]: mcp: POST /mcp [notifications/initialized]
10435
10435
  ase: [2026-07-11 12:45:57.499]: [INFO]: mcp: GET /mcp
10436
+ ase: [2026-07-11 13:19:57.333]: [INFO]: service: idle timeout reached, stopping
10437
+ ase: [2026-08-04 08:46:25.126]: [INFO]: service: listening on port 42029
10438
+ ase: [2026-08-04 08:47:07.892]: [INFO]: mcp: POST /mcp [tools/call] ase_getopt {"name":"ase-code-lint","spec":"--help|-h --auto|-a --severity|-S=(LOW|MEDIUM|HIGH) --include|-i=(none|A01|A02|A03|A04|A05|A06|A07|A08|A09|A10|A11|A12|A13|A14|A15|A16|A17|A18|A19|A20)... --exclude|-e=…
10439
+ ase: [2026-08-04 08:47:20.549]: [INFO]: mcp: POST /mcp [tools/call] ase_getopt {"name":"ase-code-analyze","spec":"--help|-h --performance|-p --security|-s --severity|-S=(LOW|MEDIUM|HIGH) --prefix|-P=","args":"-S MEDIUM @src"}
10440
+ ase: [2026-08-04 08:49:58.001]: [INFO]: mcp: POST /mcp [tools/call] ase_config_set {"key":"agent.skill","val":"ase-docs-proofread","scope":"session:c4b8a1d3-1262-4f52-a20b-5e3caf67f11b"}
10441
+ ase: [2026-08-04 08:55:13.606]: [INFO]: mcp: POST /mcp [tools/call] ase_config_set {"key":"agent.skill","val":"ase-code-lint","scope":"session:991dfac7-3ec1-45df-b605-b85076f1133f"}
10442
+ ase: [2026-08-04 08:59:33.029]: [INFO]: mcp: POST /mcp [tools/call] ase_kv_batch {"transactional":true,"commands":[{"command":"clear","prefix":"ase-issue-"},{"command":"set","key":"ase-issue-P1","val":"Abort destroys the caller's un-consumed `data` stream with an error, causing an…
10443
+ ase: [2026-08-04 09:34:45.196]: [INFO]: mcp: POST /mcp [tools/call] ase_getopt {"name":"ase-docs-proofread","spec":"--help|-h --auto|-a","args":"-a @."}
10444
+ ase: [2026-08-04 09:36:55.088]: [INFO]: mcp: POST /mcp [tools/call] ase_config_set {"key":"agent.skill","val":"ase-docs-proofread","scope":"session:b97f5e3e-1ebd-424b-836f-c36be1120f90"}
10445
+ ase: [2026-08-04 09:37:42.361]: [INFO]: mcp: POST /mcp [tools/call] ase_config_delete {"key":"agent.skill","scope":"session:b97f5e3e-1ebd-424b-836f-c36be1120f90"}
10446
+ ase: [2026-08-06 16:50:29.391]: [INFO]: service: idle timeout reached, stopping
10447
+ ase: [2026-08-06 18:58:21.557]: [INFO]: service: listening on port 43270
10448
+ ase: [2026-08-06 18:58:38.339]: [INFO]: mcp: POST /mcp [tools/call] ase_getopt {"name":"ase-code-resolve","spec":"--help|-h --auto|-a --dry|-d --direct|-D --quick|-Q --next|-n=(none|DONE|EDIT|GRILL|PREFLIGHT|IMPLEMENT)...","args":"-D P1"}
10449
+ ase: [2026-08-06 18:58:56.412]: [INFO]: mcp: POST /mcp [tools/call] ase_kv_get {"key":"ase-issue-P1"}
10450
+ ase: [2026-08-06 18:59:39.600]: [INFO]: mcp: POST /mcp [tools/call] ase_getopt {"name":"ase-code-resolve","spec":"--help|-h --auto|-a --dry|-d --direct|-D --quick|-Q --next|-n=(none|DONE|EDIT|GRILL|PREFLIGHT|IMPLEMENT)...","args":"-D PROBLEM (Severity: HIGH): P1: Abort destroys …
10451
+ ase: [2026-08-06 18:59:58.741]: [INFO]: mcp: POST /mcp [tools/call] ase_task_id {"id":"stream-abort","session":"0e6fbbe6-4403-4b63-a1ec-9d993421b5a3"}
10452
+ ase: [2026-08-06 19:00:12.928]: [INFO]: mcp: POST /mcp [tools/call] ase_getopt {"name":"ase-code-resolve","spec":"--help|-h --auto|-a --dry|-d --direct|-D --quick|-Q --next|-n=(none|DONE|EDIT|GRILL|PREFLIGHT|IMPLEMENT)...","args":"-D PROBLEM (Severity: MEDIUM): P3: WithInfo appe…
10453
+ ase: [2026-08-06 19:00:13.609]: [INFO]: mcp: POST /mcp [tools/call] ase_getopt {"name":"ase-code-resolve","spec":"--help|-h --auto|-a --dry|-d --direct|-D --quick|-Q --next|-n=(none|DONE|EDIT|GRILL|PREFLIGHT|IMPLEMENT)...","args":"-D PROBLEM (Severity: MEDIUM): P2: Sync-throwing…
10454
+ ase: [2026-08-06 19:00:19.055]: [INFO]: mcp: POST /mcp [tools/call] ase_getopt {"name":"ase-code-resolve","spec":"--help|-h --auto|-a --dry|-d --direct|-D --quick|-Q --next|-n=(none|DONE|EDIT|GRILL|PREFLIGHT|IMPLEMENT)...","args":"-D PROBLEM (Severity: MEDIUM): P4: Abort destroy…
10455
+ ase: [2026-08-06 19:00:29.204]: [INFO]: mcp: POST /mcp [tools/call] ase_task_id {"id":"sync-throw","session":"6a89a55a-84c0-4a5f-9724-368cc7b50b3f"}
10456
+ ase: [2026-08-06 19:00:29.627]: [INFO]: mcp: POST /mcp [tools/call] ase_getopt {"name":"ase-code-resolve","spec":"--help|-h --auto|-a --dry|-d --direct|-D --quick|-Q --next|-n=(none|DONE|EDIT|GRILL|PREFLIGHT|IMPLEMENT)...","args":"-D PROBLEM (Severity: MEDIUM): P5: MIN_VERSION c…
10457
+ ase: [2026-08-06 19:00:30.442]: [INFO]: mcp: POST /mcp [tools/call] ase_task_id {"id":"info-positional","session":"8a3e2d56-af15-48dd-8026-b6d41ee9a33a"}
10458
+ ase: [2026-08-06 19:00:53.301]: [INFO]: mcp: POST /mcp [tools/call] ase_task_id {"id":"version-compat","session":"8369a65d-3b10-4294-8870-4327cc269b79"}
10459
+ ase: [2026-08-06 19:00:55.371]: [INFO]: mcp: POST /mcp [tools/call] ase_getopt {"name":"ase-code-resolve","spec":"--help|-h --auto|-a --dry|-d --direct|-D --quick|-Q --next|-n=(none|DONE|EDIT|GRILL|PREFLIGHT|IMPLEMENT)...","args":"-D PROBLEM (Severity: ACCEPTED): P6: Receiver mi…
10460
+ ase: [2026-08-06 19:00:56.036]: [INFO]: mcp: POST /mcp [tools/call] ase_task_id {"id":"stream-abort","session":"56995021-07c8-4b45-8916-4ab8a78d0a8b"}
10461
+ ase: [2026-08-06 19:01:07.667]: [INFO]: mcp: POST /mcp [tools/call] ase_task_id {"id":"receiver-mismatch","session":"9eb01878-9acf-43dd-9554-00066ecc7300"}
10462
+ ase: [2026-08-06 19:01:16.395]: [INFO]: mcp: POST /mcp [tools/call] ase_getopt {"name":"ase-code-resolve","spec":"--help|-h --auto|-a --dry|-d --direct|-D --quick|-Q --next|-n=(none|DONE|EDIT|GRILL|PREFLIGHT|IMPLEMENT)...","args":"-D PROBLEM (Severity: ACCEPTED): P7: runSettle()…
10463
+ ase: [2026-08-06 19:01:20.616]: [INFO]: mcp: POST /mcp [tools/call] ase_getopt {"name":"ase-code-resolve","spec":"--help|-h --auto|-a --dry|-d --direct|-D --quick|-Q --next|-n=(none|DONE|EDIT|GRILL|PREFLIGHT|IMPLEMENT)...","args":"-D PROBLEM (Severity: ACCEPTED): P8: Fire-and-fo…
10464
+ ase: [2026-08-06 19:01:34.810]: [INFO]: mcp: POST /mcp [tools/call] ase_getopt {"name":"ase-code-resolve","spec":"--help|-h --auto|-a --dry|-d --direct|-D --quick|-Q --next|-n=(none|DONE|EDIT|GRILL|PREFLIGHT|IMPLEMENT)...","args":"-D PROBLEM (Severity: ACCEPTED): P9: Enumerating…
10465
+ ase: [2026-08-06 19:01:34.828]: [INFO]: mcp: POST /mcp [tools/call] ase_task_id {"id":"spool-unroll","session":"3ae9d8dc-2ca4-400f-bd32-633859ec1f8a"}
10466
+ ase: [2026-08-06 19:01:36.333]: [INFO]: mcp: POST /mcp [tools/call] ase_task_id {"id":"onfinally-error","session":"e164c52c-d0df-4cc7-a9bc-972012577cec"}
10467
+ ase: [2026-08-06 19:01:47.858]: [INFO]: mcp: POST /mcp [tools/call] ase_task_id {"id":"exclusive-fields","session":"7bcafa3d-0cb1-4c2b-8fdb-17e7a84dc176"}
10468
+ ase: [2026-08-06 19:23:19.610]: [INFO]: mcp: POST /mcp [tools/call] ase_timestamp {"format":"yyyy-LL-dd"}
10469
+ ase: [2026-08-06 19:47:45.209]: [INFO]: mcp: POST /mcp [tools/call] ase_artifact_list {"kind":["spec","arch","code","docs","infr","othr"]}
package/.ase/service.yaml CHANGED
@@ -1 +1 @@
1
- port: 42424
1
+ port: 43270
@@ -12,7 +12,7 @@
12
12
 
13
13
  - **WHY**: When publishing the push request fails (`src/mqtt-plus-sink.ts:795-796`), the `catch`
14
14
  block aborts (`src/mqtt-plus-sink.ts:866`) and `onAbort` (`src/mqtt-plus-sink.ts:773-783`) calls
15
- `pushInitialReject(error)` before `await pushInitial` (`src/mqtt-plus-sink.ts:797`) ever attached
15
+ `pushInitialReject(error)` before `await pushInitial` (`src/mqtt-plus-sink.ts:797`) ever attaches
16
16
  a handler — an *unhandled promise rejection*, fatal by default in Node.js ≥ 15. The same hazard
17
17
  exists for early rejections from the response/credit handlers while the publish is still pending.
18
18
 
@@ -11,7 +11,7 @@
11
11
 
12
12
  - **WHY**: `destroy()` cannot reach the fetcher-side closure state (`src/mqtt-plus-source.ts:457`),
13
13
  so pending `fetch()` consumers hang forever on `stream`/`buffer`/`meta` (the timeout timer is
14
- cleared and neutralized, the `onResponse` handlers are dropped); aborting settles all three with
14
+ cleared and neutralized and the `onResponse` handlers are dropped); aborting settles all three with
15
15
  *"instance destroyed"*, analogous to the service-caller `pendingCalls` pattern
16
16
  (`src/mqtt-plus-service.ts:51-54`).
17
17
 
@@ -1,7 +1,10 @@
1
1
  {
2
2
  "permissions": {
3
3
  "allow": [
4
- "Bash(npx tsx *)"
4
+ "Bash(npx tsx *)",
5
+ "Bash(npm start *)",
6
+ "Bash(echo \"EXIT=$?\")",
7
+ "Read(//tmp/claude-10000/-Users-rse-Work-node-mqtt-plus/6a89a55a-84c0-4a5f-9724-368cc7b50b3f/scratchpad/**)"
5
8
  ]
6
9
  }
7
10
  }
package/AGENTS.md CHANGED
@@ -50,7 +50,7 @@ Configuration lives in `etc/`: `tsc.json`, `vite.mts`, `eslint.mts`,
50
50
  `knip.jsonc`, `stx.conf`, `d2.mts`, `d2.theme.d2`, `logo.ai`,
51
51
  `logo.svg`.
52
52
 
53
- Tests require an MQTT broker under run-time; the test suite starts/stops
53
+ Tests require an MQTT broker at run-time; the test suite starts/stops
54
54
  one automatically. If Docker is available, a Mosquitto broker is used;
55
55
  otherwise, the Aedes in-process broker serves as the fallback.
56
56
  For regression testing always use the all-in-one command `npm start build test`.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
2
2
  ChangeLog
3
3
  =========
4
4
 
5
+ 1.4.27 (2026-08-05)
6
+ -------------------
7
+
8
+ - BUGFIX [code]: expose the version information as static members of the MQTTp class instead of re-exporting the "mqtt-plus-version" module
9
+
10
+ 1.4.26 (2026-08-06)
11
+ -------------------
12
+
13
+ - IMPROVEMENT [code]: sanity check the numeric option ranges of "timeout", "chunkSize" and "chunkCredit" at construction time and guard the chunk sending helpers against an invalid chunk size
14
+ - IMPROVEMENT [infr]: upgrade the NPM patch generation procedure to use "npm install --package-lock-only" instead of "npm shrinkwrap"
15
+ - BUGFIX [code, othr]: guard the handler-supplied source fetch stream and the caller-supplied sink push stream with a no-op "error" listener before destroying them on abort, as an abort before the chunk sending phase (field validation, nak, early cancel, ack timeout) otherwise raised an unhandled "error" event and crashed the process
16
+ - BUGFIX [code, docs]: drop instead of just warn about a message received on a direct topic whose "receiver" field does not match the peer id of the topic, as such a mislabeled message is addressed to a foreign peer and was still dispatched to the response handlers
17
+ - BUGFIX [code, othr]: settle the epilog of run() adaptively when the action throws synchronously, as an asynchronous onfinally, oncleanup or spool cleanup was previously mistaken for a non-async context and replaced the original error
18
+ - BUGFIX [code, othr]: attach a failing onfinally callback of run() as a "suppressed" error to the propagated pending error instead of discarding it entirely
19
+ - BUGFIX [code, othr]: align received parameters to the handler arity in the event/service/source/sink traits, as an endpoint with trailing optional parameters otherwise shifted the trailing "info" argument into a data parameter slot
20
+ - BUGFIX [code, othr]: define the mutually exclusive "stream"/"buffer" fields as non-enumerable and assignable, as a mere object spread, Object.values() or JSON.stringify() otherwise consumed one field and then threw on the other
21
+ - BUGFIX [code]: surface the cleanup failures of the fire-and-forget spool unrolling in the service call settle path as "error" events, as they were previously silently suppressed
22
+ - BUGFIX [code]: re-export the "mqtt-plus-version" module with "export *" instead of "export type *", as its version utility is a run-time value and was stripped from the public API
23
+ - BUGFIX [code]: settle the sink push race window with setTimeout() instead of setImmediate(), as setImmediate() is unavailable in browser environments
24
+ - UPDATE [infr, othr]: upgrade NPM dependencies
25
+ - CLEANUP [code]: prefix the private members of BaseTrait with an underscore
26
+ - CLEANUP [docs, infr, othr]: fix spelling in the documentation
27
+ - REFACTOR [code]: factor out the repeated push abort and settle logic in the sink trait into a "failPush" helper
28
+
5
29
  1.4.25 (2026-07-11)
6
30
  -------------------
7
31
 
@@ -79,12 +103,12 @@ ChangeLog
79
103
  - IMPROVEMENT: bump minimum Node version to 20 for ES2022
80
104
  - BUGFIX: Spool.unroll() silently skipped remaining cleanups on first async failure
81
105
  - BUGFIX: improve semantics of info.authenticated field for event/service/sink/source in case of optional authentication
82
- - BUGFIX: in the ReadableTee class, do not run read() twice: once ourself and once via the base class
106
+ - BUGFIX: in the ReadableTee class, do not run read() twice: once ourselves and once via the base class
83
107
  - BUGFIX: correctly propagate description in run() also to finally callback
84
108
  - BUGFIX: fix resource handling in source trait
85
109
  - BUGFIX: avoid race conditions and unhandled promise rejections in async processing
86
110
  - BUGFIX: fix cleanup and error handling across sink/source traits
87
- - BUGFIX: fix Mosqitto ACL
111
+ - BUGFIX: fix Mosquitto ACL
88
112
  - UPDATE: upgrade NPM dependencies
89
113
  - CLEANUP: various code cleanups (callback handling, settle code, destroy handling, termination, subscriptions)
90
114
  - CLEANUP: align with ensureError code and fix typos
@@ -286,14 +310,14 @@ ChangeLog
286
310
  - CLEANUP: simplify typing and remove unused code
287
311
  - CLEANUP: rename variables and align code formatting
288
312
  - CLEANUP: various code cleanups
289
- - CLEANUP: splitted documentation from README.md into doc/*.md
313
+ - CLEANUP: split documentation from README.md into doc/*.md
290
314
  - UPDATE: upgrade NPM dependencies
291
315
 
292
316
  1.3.0 (2026-02-07)
293
317
  ------------------
294
318
 
295
319
  - IMPROVEMENT: add credit-based flow control to sink/source facility
296
- - IMPROVEMENT: make "buffer" and "stream" fields always mutual-exlusive
320
+ - IMPROVEMENT: make "buffer" and "stream" fields always mutually exclusive
297
321
  - IMPROVEMENT: provide version field in protocol messages
298
322
 
299
323
  1.2.1 (2026-02-07)
@@ -368,7 +392,7 @@ ChangeLog
368
392
  0.9.17 (2026-01-25)
369
393
  -------------------
370
394
 
371
- - IMPROVEMENT: add Dry-Run mode will null MQTT client
395
+ - IMPROVEMENT: add Dry-Run mode with null MQTT client
372
396
  - IMPROVEMENT: add Dry-Run mode for emit() to generate last-will message
373
397
 
374
398
  0.9.16 (2026-01-24)
package/README.md CHANGED
@@ -55,7 +55,7 @@ The following is a simple but self-contained example usage of
55
55
  and an MQTT infrastructure. It can be found in the file
56
56
  [sample.ts](sample/sample.ts) and can be executed from the **MQTT+**
57
57
  source tree via `npm start sample` (assuming the prerequisite *Docker* is
58
- available for the underlying *Mosquitto* broker based infrastructure):
58
+ available for the underlying *Mosquitto* broker-based infrastructure):
59
59
 
60
60
  ```ts
61
61
  import { Readable } from "node:stream"
@@ -175,7 +175,7 @@ Notice
175
175
  ------
176
176
 
177
177
  > [!Note]
178
- > **MQTT+** and its peer dependency **MQTT.js** provide a powerful
178
+ > **MQTT+** and its peer dependency **MQTT.js** provide powerful
179
179
  > functionality, but are not small in size. **MQTT+** is 4,000 LoC
180
180
  > and 75 KB in size (ESM and CJS format). When bundled with all its
181
181
  > dependencies, it is 220 KB in size (UMD format). Its peer dependency
@@ -5,7 +5,7 @@ MQTT+ Broker Setup
5
5
  **MQTT+** can be used with an arbitrary MQTT broker. One popular
6
6
  MQTT broker is [Mosquitto](https://mosquitto.org/).
7
7
  For establishing your own permanent MQTT environment, you can install the
8
- [Mosquitto](https://mosquitto.org/) MQTT broker yourself and a setup
8
+ [Mosquitto](https://mosquitto.org/) MQTT broker yourself and set up
9
9
  a `mosquitto.conf` file like...
10
10
 
11
11
  ```
@@ -13,7 +13,7 @@ case of a directed event emission) or *all* receivers are called and
13
13
  receive the parameters as extra information.
14
14
 
15
15
  > In contrast to the regular MQTT message publish/subscribe, this
16
- > pattern allows to direct the event to particular receivers,
16
+ > pattern allows directing the event to particular receivers,
17
17
  > provides optional information about the sender and receiver to
18
18
  > receivers, supports authentication and meta-data, etc.
19
19
 
@@ -48,7 +48,7 @@ sink push) or *one* arbitrary sink is called and receives the data
48
48
  chunks as a stream with arguments.
49
49
 
50
50
  > In contrast to the regular MQTT message publish/subscribe, this
51
- > pattern allows to transfer arbitrary amounts of arbitrary data by
51
+ > pattern allows transferring arbitrary amounts of arbitrary data by
52
52
  > chunking the data via a stream. Additionally, it supports authentication
53
53
  > and meta-data, provides an `AbortSignal` to the sink handler for
54
54
  > cooperative cancellation, and allows the receiver to cancel an
@@ -67,7 +67,7 @@ directed source fetch) or *one* arbitrary source is called and sends the
67
67
  data chunks as a stream with arguments.
68
68
 
69
69
  > In contrast to the regular MQTT message publish/subscribe, this
70
- > pattern allows to transfer arbitrary amounts of arbitrary data by
70
+ > pattern allows transferring arbitrary amounts of arbitrary data by
71
71
  > chunking the data via a stream. Additionally, it supports
72
72
  > authentication and meta-data, and provides an `AbortSignal` to the
73
73
  > source handler for cooperative cancellation, etc.
@@ -328,6 +328,12 @@ the configured credential and required roles:
328
328
  Message Dispatching
329
329
  -------------------
330
330
 
331
+ ### Receiver Validation
332
+
333
+ A message received on a direct topic (i.e. a topic carrying a concrete
334
+ peer id instead of `any`) is dropped if its `receiver` field does not
335
+ match that peer id, as it is addressed to a foreign peer.
336
+
331
337
  ### Request Dispatching
332
338
 
333
339
  Incoming request messages are dispatched based on the combination of
@@ -4,8 +4,8 @@ import type { APIOptions } from "./mqtt-plus-options";
4
4
  import { TraceTrait } from "./mqtt-plus-trace";
5
5
  import { Spool } from "./mqtt-plus-error";
6
6
  export declare class BaseTrait<T extends APISchema = APISchema> extends TraceTrait<T> {
7
- private mqtt;
8
- private messageHandler;
7
+ private _mqtt;
8
+ private _messageHandler;
9
9
  protected destroyed: boolean;
10
10
  protected onRequest: Map<string, (message: any, topicName: string) => void | Promise<void>>;
11
11
  protected onResponse: Map<string, (message: any, topicName: string) => void | Promise<void>>;
@@ -55,7 +55,7 @@ export class BaseTrait extends TraceTrait {
55
55
  });
56
56
  }
57
57
  /* store MQTT client */
58
- this.mqtt = mqtt;
58
+ this._mqtt = mqtt;
59
59
  /* resolve the instance "id": if the user did not provide an
60
60
  explicit one, fetch the "clientId" from the underlying MQTT
61
61
  client, and only as a last resort fall back to a generated id
@@ -70,7 +70,7 @@ export class BaseTrait extends TraceTrait {
70
70
  }
71
71
  /* hook into the MQTT message processing */
72
72
  this.log("info", "hooking into MQTT client");
73
- this.messageHandler = (topic, message, packet) => {
73
+ this._messageHandler = (topic, message, packet) => {
74
74
  if (this.destroyed)
75
75
  return;
76
76
  /* convert message to codec-specific input format
@@ -90,13 +90,13 @@ export class BaseTrait extends TraceTrait {
90
90
  }
91
91
  this._onMessage(topic, input, packet);
92
92
  };
93
- this.mqtt.on("message", this.messageHandler);
93
+ this._mqtt.on("message", this._messageHandler);
94
94
  }
95
95
  /* destroy API class */
96
96
  async destroy() {
97
97
  this.destroyed = true;
98
98
  this.log("info", "un-hooking from MQTT client");
99
- this.mqtt.off("message", this.messageHandler);
99
+ this._mqtt.off("message", this._messageHandler);
100
100
  this.onRequest.clear();
101
101
  this.onResponse.clear();
102
102
  }
@@ -120,7 +120,7 @@ export class BaseTrait extends TraceTrait {
120
120
  async subscribeTopic(topic, options = {}) {
121
121
  this.log("info", `subscribing to MQTT topic "${topic}"`);
122
122
  return new Promise((resolve, reject) => {
123
- this.mqtt.subscribe(topic, { qos: 2, ...options }, (err, granted) => {
123
+ this._mqtt.subscribe(topic, { qos: 2, ...options }, (err, granted) => {
124
124
  if (err) {
125
125
  this.error(err, `subscribing to MQTT topic "${topic}" failed`);
126
126
  reject(err);
@@ -142,7 +142,7 @@ export class BaseTrait extends TraceTrait {
142
142
  async unsubscribeTopic(topic) {
143
143
  this.log("info", `unsubscribing from MQTT topic "${topic}"`);
144
144
  return new Promise((resolve, reject) => {
145
- this.mqtt.unsubscribe(topic, (err, _packet) => {
145
+ this._mqtt.unsubscribe(topic, (err, _packet) => {
146
146
  if (err) {
147
147
  this.error(err, `unsubscribing from MQTT topic "${topic}" failed`);
148
148
  reject(err);
@@ -178,7 +178,7 @@ export class BaseTrait extends TraceTrait {
178
178
  const messageData = typeof message === "string"
179
179
  ? message
180
180
  : Buffer.from(message.buffer, message.byteOffset, message.byteLength);
181
- this.mqtt.publish(topic, messageData, options, (err) => {
181
+ this._mqtt.publish(topic, messageData, options, (err) => {
182
182
  if (err) {
183
183
  this.error(err, `publishing to MQTT topic "${topic}" failed`);
184
184
  reject(err);
@@ -226,12 +226,15 @@ export class BaseTrait extends TraceTrait {
226
226
  return;
227
227
  }
228
228
  this.log("debug", `received from MQTT topic "${topic}"`, { message });
229
- /* warn on receiver mismatch on direct topics */
229
+ /* drop message on receiver mismatch on direct topics, as it is
230
+ addressed to a foreign peer and hence must not be processed here */
230
231
  if (topicMatch.peerId !== undefined
231
232
  && message.receiver !== undefined
232
- && message.receiver !== topicMatch.peerId)
233
- this.log("warning", `receiver mismatch on direct topic "${topic}"` +
233
+ && message.receiver !== topicMatch.peerId) {
234
+ this.log("warning", `receiver mismatch -- dropped message from direct topic "${topic}"` +
234
235
  ` (expected "${topicMatch.peerId}", got "${message.receiver}")`);
236
+ return;
237
+ }
235
238
  /* dispatch MQTT+ message */
236
239
  if (this.msg.isRequest(message)) {
237
240
  /* dispatch request message */
@@ -188,10 +188,14 @@ function runSettle(isAsync, pending, spool, onfinally, description) {
188
188
  catch (arg) {
189
189
  failure = ensureError(arg, description);
190
190
  }
191
- if (pending || failure !== undefined) {
191
+ if (pending !== undefined || failure !== undefined) {
192
192
  runUnroll(false, spool);
193
- if (!pending && failure !== undefined)
194
- throw failure;
193
+ if (failure !== undefined) {
194
+ if (pending !== undefined)
195
+ pending.suppressed = failure;
196
+ else
197
+ throw failure;
198
+ }
195
199
  }
196
200
  }
197
201
  else {
@@ -203,14 +207,39 @@ function runSettle(isAsync, pending, spool, onfinally, description) {
203
207
  catch (arg) {
204
208
  failure = ensureError(arg, description);
205
209
  }
206
- if (pending || failure !== undefined) {
210
+ if (pending !== undefined || failure !== undefined) {
207
211
  await runUnroll(true, spool);
208
- if (!pending && failure !== undefined)
209
- throw failure;
212
+ if (failure !== undefined) {
213
+ if (pending !== undefined)
214
+ pending.suppressed = failure;
215
+ else
216
+ throw failure;
217
+ }
210
218
  }
211
219
  })();
212
220
  }
213
221
  }
222
+ /* helper function for settling the epilog of "run" and then failing with
223
+ a pending error in a still unknown synchronous/asynchronous context:
224
+ operate synchronously as long as possible and continue asynchronously as
225
+ soon as a callback returns a Promise. As the pending error always takes
226
+ precedence, a failing finally code is attached to it as a suppressed error. */
227
+ function runFail(error, spool, onfinally, description) {
228
+ const attach = (arg) => { error.suppressed = ensureError(arg, description); };
229
+ let result = undefined;
230
+ try {
231
+ result = onfinally?.();
232
+ }
233
+ catch (arg) {
234
+ attach(arg);
235
+ }
236
+ if (result instanceof Promise)
237
+ return result.catch(attach).then(() => spool?.unroll()).then(() => { throw error; });
238
+ const unrolled = spool?.unroll();
239
+ if (unrolled instanceof Promise)
240
+ return unrolled.then(() => { throw error; });
241
+ throw error;
242
+ }
214
243
  export function run(...args) {
215
244
  /* support overloaded signatures */
216
245
  let description;
@@ -267,31 +296,28 @@ export function run(...args) {
267
296
  result = action();
268
297
  }
269
298
  catch (arg) {
270
- /* synchronous case (error branch) */
271
- let error = ensureError(arg, description);
272
- if (oncatch) {
273
- try {
274
- result = oncatch(error);
275
- }
276
- catch (arg) {
277
- error = ensureError(arg, description);
278
- runSettle(false, true, spool, onfinally, description);
279
- throw error;
280
- }
281
- if (spool && oncleanup)
282
- spool.roll(result, oncleanup);
283
- runSettle(false, false, spool, onfinally, description);
284
- return result;
299
+ /* synchronous case (error branch): as the action failed before
300
+ returning its potentially asynchronous result, the context is
301
+ still unknown here and hence has to be probed */
302
+ const error = ensureError(arg, description);
303
+ if (!oncatch)
304
+ return runFail(error, spool, onfinally, description);
305
+ try {
306
+ result = oncatch(error);
307
+ }
308
+ catch (arg) {
309
+ return runFail(ensureError(arg, description), spool, onfinally, description);
285
310
  }
286
- runSettle(false, true, spool, onfinally, description);
287
- throw error;
311
+ /* the recovery already ran, so let the common epilog below settle
312
+ its potentially asynchronous result without re-entering it */
313
+ oncatch = undefined;
288
314
  }
289
315
  if (result instanceof Promise) {
290
316
  /* asynchronous case (result or error branch) */
291
317
  return result.then(async (result) => {
292
318
  if (spool && oncleanup)
293
319
  spool.roll(result, oncleanup);
294
- await runSettle(true, false, spool, onfinally, description);
320
+ await runSettle(true, undefined, spool, onfinally, description);
295
321
  return result;
296
322
  }, async (arg) => {
297
323
  /* asynchronous case (error branch) */
@@ -303,15 +329,15 @@ export function run(...args) {
303
329
  }
304
330
  catch (arg) {
305
331
  error = ensureError(arg, description);
306
- await runSettle(true, true, spool, onfinally, description);
332
+ await runSettle(true, error, spool, onfinally, description);
307
333
  throw error;
308
334
  }
309
335
  if (spool && oncleanup)
310
336
  spool.roll(result, oncleanup);
311
- await runSettle(true, false, spool, onfinally, description);
337
+ await runSettle(true, undefined, spool, onfinally, description);
312
338
  return result;
313
339
  }
314
- await runSettle(true, true, spool, onfinally, description);
340
+ await runSettle(true, error, spool, onfinally, description);
315
341
  throw error;
316
342
  });
317
343
  }
@@ -319,7 +345,7 @@ export function run(...args) {
319
345
  /* synchronous case (result branch) */
320
346
  if (spool && oncleanup)
321
347
  spool.roll(result, oncleanup);
322
- runSettle(false, false, spool, onfinally, description);
348
+ runSettle(false, undefined, spool, onfinally, description);
323
349
  return result;
324
350
  }
325
351
  }
@@ -22,6 +22,8 @@
22
22
  ** SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
23
23
  */
24
24
  import { nanoid } from "nanoid";
25
+ /* internal requirements */
26
+ import { alignHandlerParams } from "./mqtt-plus-util";
25
27
  import { AuthTrait } from "./mqtt-plus-auth";
26
28
  import { Spool, ensureError, run } from "./mqtt-plus-error";
27
29
  /* Event Emission Trait */
@@ -100,7 +102,7 @@ export class EventTrait extends AuthTrait {
100
102
  ` (topic: "${topicName}", payload: "${request.name}")`, { requestId });
101
103
  return;
102
104
  }
103
- const params = request.params ?? [];
105
+ const params = alignHandlerParams(request.params ?? [], callback);
104
106
  /* sanity check request id */
105
107
  if (this.eventControllers.has(requestId)) {
106
108
  this.log("warning", `duplicate event request id -- dropped request for event "${name}"`, { requestId });
@@ -46,5 +46,12 @@ export class OptionsTrait {
46
46
  },
47
47
  ...options
48
48
  };
49
+ /* sanity check numeric option ranges */
50
+ if (!(Number.isFinite(this.options.timeout) && this.options.timeout > 0))
51
+ throw new Error("options: \"timeout\" must be a positive number");
52
+ if (!(Number.isInteger(this.options.chunkSize) && this.options.chunkSize > 0))
53
+ throw new Error("options: \"chunkSize\" must be a positive integer");
54
+ if (!(Number.isInteger(this.options.chunkCredit) && this.options.chunkCredit >= 0))
55
+ throw new Error("options: \"chunkCredit\" must be a non-negative integer");
49
56
  }
50
57
  }
@@ -23,6 +23,7 @@
23
23
  */
24
24
  import { nanoid } from "nanoid";
25
25
  /* internal requirements */
26
+ import { alignHandlerParams } from "./mqtt-plus-util";
26
27
  import { run, Spool, ensureError } from "./mqtt-plus-error";
27
28
  import { EventTrait } from "./mqtt-plus-event";
28
29
  /* Service Call Trait */
@@ -96,7 +97,7 @@ export class ServiceTrait extends EventTrait {
96
97
  /* determine request information */
97
98
  const requestId = request.id;
98
99
  const senderId = request.sender;
99
- const params = request.params ?? [];
100
+ const params = alignHandlerParams(request.params ?? [], callback);
100
101
  if (senderId === undefined || senderId === "") {
101
102
  this.error(new Error("invalid request: missing sender"));
102
103
  return;
@@ -237,11 +238,13 @@ export class ServiceTrait extends EventTrait {
237
238
  if (settled)
238
239
  return false;
239
240
  settled = true;
240
- /* unroll fire-and-forget: the "settled" boolean guard is the
241
- primary protection against concurrent/duplicate responses,
242
- while cleanup errors are suppressed and the async MQTT
243
- unsubscription is handled by reference-counting/linger */
244
- spool.unroll();
241
+ /* unroll fire-and-forget: the "settled" boolean guard protects
242
+ against concurrent/duplicate responses and the async MQTT
243
+ unsubscription is handled by reference-counting/linger, while
244
+ cleanup failures are surfaced as "error" events */
245
+ spool.unroll(false)?.catch((err) => {
246
+ this.error(ensureError(err, `cleanup after call of service "${name}" failed`));
247
+ });
245
248
  return true;
246
249
  };
247
250
  const promise = new Promise((resolve, reject) => {