mqtt-plus 1.4.0 → 1.4.2

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 (73) hide show
  1. package/AGENTS.md +55 -44
  2. package/CHANGELOG.md +14 -0
  3. package/README.md +4 -3
  4. package/doc/mqtt-plus-api.md +693 -680
  5. package/doc/mqtt-plus-architecture.d2 +139 -0
  6. package/doc/mqtt-plus-architecture.md +10 -0
  7. package/doc/mqtt-plus-architecture.svg +95 -0
  8. package/doc/mqtt-plus-broker-setup.md +9 -3
  9. package/doc/mqtt-plus-comm.md +73 -0
  10. package/doc/mqtt-plus-internals.md +3 -3
  11. package/dst-stage1/mqtt-plus-base.d.ts +3 -2
  12. package/dst-stage1/mqtt-plus-base.js +53 -22
  13. package/dst-stage1/mqtt-plus-event.d.ts +0 -2
  14. package/dst-stage1/mqtt-plus-event.js +6 -26
  15. package/dst-stage1/mqtt-plus-meta.d.ts +2 -2
  16. package/dst-stage1/mqtt-plus-meta.js +2 -2
  17. package/dst-stage1/mqtt-plus-msg.d.ts +2 -0
  18. package/dst-stage1/mqtt-plus-msg.js +17 -0
  19. package/dst-stage1/mqtt-plus-service.d.ts +0 -5
  20. package/dst-stage1/mqtt-plus-service.js +12 -48
  21. package/dst-stage1/mqtt-plus-sink.d.ts +0 -10
  22. package/dst-stage1/mqtt-plus-sink.js +25 -92
  23. package/dst-stage1/mqtt-plus-source.d.ts +0 -10
  24. package/dst-stage1/mqtt-plus-source.js +23 -88
  25. package/dst-stage1/mqtt-plus-subscription.d.ts +20 -0
  26. package/dst-stage1/mqtt-plus-subscription.js +126 -0
  27. package/dst-stage1/mqtt-plus-timer.d.ts +8 -0
  28. package/dst-stage1/mqtt-plus-timer.js +57 -0
  29. package/dst-stage1/mqtt-plus-topic.d.ts +20 -0
  30. package/dst-stage1/mqtt-plus-topic.js +112 -0
  31. package/dst-stage1/mqtt-plus-trace.js +2 -0
  32. package/dst-stage1/mqtt-plus-util.d.ts +0 -13
  33. package/dst-stage1/mqtt-plus-util.js +1 -77
  34. package/dst-stage1/mqtt-plus-version.d.ts +0 -1
  35. package/dst-stage1/mqtt-plus-version.js +0 -6
  36. package/dst-stage1/tsc.tsbuildinfo +1 -1
  37. package/dst-stage2/mqtt-plus.cjs.js +242 -292
  38. package/dst-stage2/mqtt-plus.esm.js +240 -290
  39. package/dst-stage2/mqtt-plus.umd.js +12 -12
  40. package/etc/knip.jsonc +1 -1
  41. package/etc/stx.conf +6 -4
  42. package/package.json +1 -1
  43. package/src/mqtt-plus-base.ts +56 -26
  44. package/src/mqtt-plus-event.ts +8 -24
  45. package/src/mqtt-plus-meta.ts +3 -3
  46. package/src/mqtt-plus-msg.ts +28 -0
  47. package/src/mqtt-plus-service.ts +12 -50
  48. package/src/mqtt-plus-sink.ts +32 -105
  49. package/src/mqtt-plus-source.ts +29 -99
  50. package/src/mqtt-plus-subscription.ts +141 -0
  51. package/src/mqtt-plus-timer.ts +61 -0
  52. package/src/mqtt-plus-trace.ts +4 -0
  53. package/src/mqtt-plus-util.ts +1 -81
  54. package/src/mqtt-plus-version.ts +0 -7
  55. package/tst/mqtt-plus-0-fixture.ts +2 -2
  56. package/tst/mqtt-plus-0-mosquitto.ts +5 -0
  57. package/tst/mqtt-plus-1-api.spec.ts +1 -1
  58. package/tst/mqtt-plus-2-event.spec.ts +0 -6
  59. package/tst/mqtt-plus-3-service.spec.ts +3 -7
  60. package/tst/mqtt-plus-4-sink.spec.ts +14 -9
  61. package/tst/mqtt-plus-5-source.spec.ts +11 -5
  62. package/tst/mqtt-plus-6-misc.spec.ts +23 -23
  63. package/tst/tsc.json +1 -1
  64. package/doc/mqtt-plus-communication.md +0 -68
  65. /package/doc/{mqtt-plus-1-event-emission.d2 → mqtt-plus-comm-event-emission.d2} +0 -0
  66. /package/doc/{mqtt-plus-1-event-emission.svg → mqtt-plus-comm-event-emission.svg} +0 -0
  67. /package/doc/{mqtt-plus-2-service-call.d2 → mqtt-plus-comm-service-call.d2} +0 -0
  68. /package/doc/{mqtt-plus-2-service-call.svg → mqtt-plus-comm-service-call.svg} +0 -0
  69. /package/doc/{mqtt-plus-3-sink-push.d2 → mqtt-plus-comm-sink-push.d2} +0 -0
  70. /package/doc/{mqtt-plus-3-sink-push.svg → mqtt-plus-comm-sink-push.svg} +0 -0
  71. /package/doc/{mqtt-plus-4-source-fetch.d2 → mqtt-plus-comm-source-fetch.d2} +0 -0
  72. /package/doc/{mqtt-plus-4-source-fetch.svg → mqtt-plus-comm-source-fetch.svg} +0 -0
  73. /package/{doc/theme.d2 → etc/d2.theme.d2} +0 -0
@@ -25,47 +25,15 @@
25
25
  import { Readable } from "node:stream";
26
26
  import { nanoid } from "nanoid";
27
27
  /* internal requirements */
28
- import { CreditGate, RefCountedSubscription, streamToBuffer, sendBufferAsChunks, sendStreamAsChunks, makeMutuallyExclusiveFields } from "./mqtt-plus-util";
28
+ import { CreditGate, streamToBuffer, sendBufferAsChunks, sendStreamAsChunks, makeMutuallyExclusiveFields } from "./mqtt-plus-util";
29
29
  import { run, Spool, ensureError } from "./mqtt-plus-error";
30
- import { SourceFetchRequest, SourceFetchResponse, SourceFetchChunk, SourceFetchCredit } from "./mqtt-plus-msg";
31
30
  import { ServiceTrait } from "./mqtt-plus-service";
32
31
  /* Source Fetch Trait */
33
32
  export class SourceTrait extends ServiceTrait {
34
33
  constructor() {
35
34
  super(...arguments);
36
35
  /* source state */
37
- this.sources = new Map();
38
- this.fetchResponseCallbacks = new Map();
39
- this.fetchChunkCallbacks = new Map();
40
- this.sourceCreditCallbacks = new Map();
41
36
  this.sourceCreditGates = new Map();
42
- this.sourceTimers = new Map();
43
- this.fetchSubscriptions = new RefCountedSubscription((topic, options) => this._subscribeTopic(topic, options), (topic) => this._unsubscribeTopic(topic));
44
- }
45
- /* refresh source timer for a specific request */
46
- _refreshSourceTimer(requestId) {
47
- const timer = this.sourceTimers.get(requestId);
48
- if (timer !== undefined)
49
- clearTimeout(timer);
50
- this.sourceTimers.set(requestId, setTimeout(() => {
51
- this.sourceTimers.delete(requestId);
52
- const gate = this.sourceCreditGates.get(requestId);
53
- if (gate !== undefined)
54
- gate.abort();
55
- }, this.options.timeout));
56
- }
57
- /* clear source timer for a specific request */
58
- _clearSourceTimer(requestId) {
59
- const timer = this.sourceTimers.get(requestId);
60
- if (timer !== undefined) {
61
- clearTimeout(timer);
62
- this.sourceTimers.delete(requestId);
63
- }
64
- }
65
- /* destroy source trait */
66
- destroy() {
67
- super.destroy();
68
- this.fetchSubscriptions.flush();
69
37
  }
70
38
  async source(nameOrConfig, ...args) {
71
39
  /* determine actual parameters */
@@ -90,7 +58,7 @@ export class SourceTrait extends ServiceTrait {
90
58
  /* create a resource spool */
91
59
  const spool = new Spool();
92
60
  /* sanity check situation */
93
- if (this.sources.has(name))
61
+ if (this.onRequest.has(`source-fetch-request:${name}`))
94
62
  throw new Error(`source: source "${name}" already established`);
95
63
  /* generate the corresponding MQTT topics for broadcast and direct use */
96
64
  const topicS = `$share/${share}/${name}`;
@@ -98,7 +66,7 @@ export class SourceTrait extends ServiceTrait {
98
66
  const topicReqD = this.options.topicMake(name, "source-fetch-request", this.options.id);
99
67
  const topicCreditD = this.options.topicMake(name, "source-fetch-credit", this.options.id);
100
68
  /* remember the registration */
101
- this.sources.set(name, (request, topicName) => {
69
+ this.onRequest.set(`source-fetch-request:${name}`, (request, topicName) => {
102
70
  /* determine information */
103
71
  const requestId = request.id;
104
72
  const params = request.params ?? [];
@@ -123,8 +91,12 @@ export class SourceTrait extends ServiceTrait {
123
91
  await this._publishToTopic(responseTopic, message, { qos: 2 });
124
92
  };
125
93
  /* utility functions for timeout management */
126
- const refreshSourceTimeout = () => this._refreshSourceTimer(requestId);
127
- const clearSourceTimeout = () => this._clearSourceTimer(requestId);
94
+ const refreshSourceTimeout = () => this.timerRefresh(requestId, () => {
95
+ const gate = this.sourceCreditGates.get(requestId);
96
+ if (gate !== undefined)
97
+ gate.abort();
98
+ });
99
+ const clearSourceTimeout = () => this.timerClear(requestId);
128
100
  refreshSourceTimeout();
129
101
  /* callback for creating and sending a chunk message */
130
102
  const sendChunk = async (chunk, error, final) => {
@@ -139,9 +111,9 @@ export class SourceTrait extends ServiceTrait {
139
111
  ? new CreditGate(initialCredit) : undefined;
140
112
  if (creditGate) {
141
113
  this.sourceCreditGates.set(requestId, creditGate);
142
- this.sourceCreditCallbacks.set(requestId, (creditParsed) => {
114
+ this.onResponse.set(`source-fetch-credit:${requestId}`, (creditParsed) => {
143
115
  creditGate.replenish(creditParsed.credit);
144
- this._refreshSourceTimer(requestId);
116
+ refreshSourceTimeout();
145
117
  });
146
118
  }
147
119
  /* call the handler callback */
@@ -185,10 +157,10 @@ export class SourceTrait extends ServiceTrait {
185
157
  creditGate.abort();
186
158
  this.sourceCreditGates.delete(requestId);
187
159
  }
188
- this.sourceCreditCallbacks.delete(requestId);
160
+ this.onResponse.delete(`source-fetch-credit:${requestId}`);
189
161
  });
190
162
  });
191
- spool.roll(() => { this.sources.delete(name); });
163
+ spool.roll(() => { this.onRequest.delete(`source-fetch-request:${name}`); });
192
164
  /* subscribe to MQTT topics */
193
165
  await run(`subscribe to MQTT topic "${topicReqB}"`, spool, () => this._subscribeTopic(topicReqB, { qos: 2, ...options }));
194
166
  spool.roll(() => this._unsubscribeTopic(topicReqB).catch(() => { }));
@@ -199,7 +171,7 @@ export class SourceTrait extends ServiceTrait {
199
171
  /* provide a registration for subsequent destruction */
200
172
  return {
201
173
  destroy: async () => {
202
- if (!this.sources.has(name))
174
+ if (!this.onRequest.has(`source-fetch-request:${name}`))
203
175
  throw new Error(`destroy: source "${name}" not established`);
204
176
  await spool.unroll(false)?.catch((err) => {
205
177
  this.error(err, `destroy: failed to cleanup: ${err.message}`);
@@ -234,10 +206,10 @@ export class SourceTrait extends ServiceTrait {
234
206
  /* subscribe to response topic (for ack/nak) and chunk topic (for data) */
235
207
  const responseTopic = this.options.topicMake(name, "source-fetch-response", this.options.id);
236
208
  const chunkTopic = this.options.topicMake(name, "source-fetch-chunk", this.options.id);
237
- await run(`subscribe to MQTT topic "${responseTopic}"`, spool, () => this.fetchSubscriptions.subscribe(responseTopic, { qos: options.qos ?? 2 }));
238
- spool.roll(() => this.fetchSubscriptions.unsubscribe(responseTopic));
239
- await run(`subscribe to MQTT topic "${chunkTopic}"`, spool, () => this.fetchSubscriptions.subscribe(chunkTopic, { qos: options.qos ?? 2 }));
240
- spool.roll(() => this.fetchSubscriptions.unsubscribe(chunkTopic));
209
+ await run(`subscribe to MQTT topic "${responseTopic}"`, spool, () => this.subscriptions.subscribe(responseTopic, { qos: options.qos ?? 2 }));
210
+ spool.roll(() => this.subscriptions.unsubscribe(responseTopic));
211
+ await run(`subscribe to MQTT topic "${chunkTopic}"`, spool, () => this.subscriptions.subscribe(chunkTopic, { qos: options.qos ?? 2 }));
212
+ spool.roll(() => this.subscriptions.unsubscribe(chunkTopic));
241
213
  /* credit-based flow control state */
242
214
  const chunkCredit = this.options.chunkCredit;
243
215
  let chunksReceived = 0;
@@ -247,7 +219,7 @@ export class SourceTrait extends ServiceTrait {
247
219
  const stream = new Readable({
248
220
  highWaterMark: chunkCredit > 0 ? chunkCredit * this.options.chunkSize : 16 * 1024,
249
221
  read: (_size) => {
250
- if (chunkCredit <= 0 || !this.fetchChunkCallbacks.has(requestId))
222
+ if (chunkCredit <= 0 || !this.onResponse.has(`source-fetch-chunk:${requestId}`))
251
223
  return;
252
224
  const targetId = serverId ?? receiver;
253
225
  if (!targetId)
@@ -295,7 +267,7 @@ export class SourceTrait extends ServiceTrait {
295
267
  stream.once("close", () => spool.unroll());
296
268
  stream.once("error", () => spool.unroll());
297
269
  /* register response dispatch callback */
298
- this.fetchResponseCallbacks.set(requestId, (response) => {
270
+ this.onResponse.set(`source-fetch-response:${requestId}`, (response) => {
299
271
  if (response.sender)
300
272
  serverId = response.sender;
301
273
  metaResolve?.(response.meta);
@@ -307,7 +279,7 @@ export class SourceTrait extends ServiceTrait {
307
279
  refreshTimeout();
308
280
  });
309
281
  /* register chunk dispatch callback */
310
- this.fetchChunkCallbacks.set(requestId, (response) => {
282
+ this.onResponse.set(`source-fetch-chunk:${requestId}`, (response) => {
311
283
  if (response.sender)
312
284
  serverId = response.sender;
313
285
  if (response.error) {
@@ -327,8 +299,8 @@ export class SourceTrait extends ServiceTrait {
327
299
  }
328
300
  });
329
301
  spool.roll(() => {
330
- this.fetchResponseCallbacks.delete(requestId);
331
- this.fetchChunkCallbacks.delete(requestId);
302
+ this.onResponse.delete(`source-fetch-response:${requestId}`);
303
+ this.onResponse.delete(`source-fetch-chunk:${requestId}`);
332
304
  });
333
305
  /* generate encoded message */
334
306
  const auth = this.authenticate();
@@ -348,41 +320,4 @@ export class SourceTrait extends ServiceTrait {
348
320
  makeMutuallyExclusiveFields(result, "stream", "buffer");
349
321
  return result;
350
322
  }
351
- /* dispatch message (Source Fetch pattern handling) */
352
- async _dispatchMessage(topic, message) {
353
- await super._dispatchMessage(topic, message);
354
- const topicMatch = this.options.topicMatch(topic);
355
- /* handle source fetch request (on server-side) */
356
- if (topicMatch !== null
357
- && topicMatch.operation === "source-fetch-request"
358
- && message instanceof SourceFetchRequest) {
359
- const handler = this.sources.get(message.name);
360
- if (handler !== undefined)
361
- handler(message, topicMatch.name);
362
- }
363
- /* handle source fetch response (on client-side) */
364
- else if (topicMatch !== null
365
- && topicMatch.operation === "source-fetch-response"
366
- && message instanceof SourceFetchResponse) {
367
- const handler = this.fetchResponseCallbacks.get(message.id);
368
- if (handler !== undefined)
369
- handler(message);
370
- }
371
- /* handle source fetch chunk (on client-side) */
372
- else if (topicMatch !== null
373
- && topicMatch.operation === "source-fetch-chunk"
374
- && message instanceof SourceFetchChunk) {
375
- const handler = this.fetchChunkCallbacks.get(message.id);
376
- if (handler !== undefined)
377
- handler(message);
378
- }
379
- /* handle source fetch credit (on server-side) */
380
- else if (topicMatch !== null
381
- && topicMatch.operation === "source-fetch-credit"
382
- && message instanceof SourceFetchCredit) {
383
- const handler = this.sourceCreditCallbacks.get(message.id);
384
- if (handler !== undefined)
385
- handler(message);
386
- }
387
- }
388
323
  }
@@ -0,0 +1,20 @@
1
+ import type { IClientSubscribeOptions } from "mqtt";
2
+ import type { APISchema } from "./mqtt-plus-api";
3
+ import { BaseTrait } from "./mqtt-plus-base";
4
+ declare class RefCountedSubscription {
5
+ private subscribeFn;
6
+ private unsubscribeFn;
7
+ private lingerMs;
8
+ private counts;
9
+ private pending;
10
+ private lingers;
11
+ constructor(subscribeFn: (topic: string, options: IClientSubscribeOptions) => Promise<void>, unsubscribeFn: (topic: string) => Promise<void>, lingerMs?: number);
12
+ subscribe(topic: string, options?: IClientSubscribeOptions): Promise<void>;
13
+ unsubscribe(topic: string): Promise<void>;
14
+ flush(): Promise<void>;
15
+ }
16
+ export declare class SubscriptionTrait<T extends APISchema = APISchema> extends BaseTrait<T> {
17
+ protected subscriptions: RefCountedSubscription;
18
+ destroy(): Promise<void>;
19
+ }
20
+ export {};
@@ -0,0 +1,126 @@
1
+ /*
2
+ ** MQTT+ -- MQTT Communication Patterns
3
+ ** Copyright (c) 2018-2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
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
+ import { BaseTrait } from "./mqtt-plus-base";
25
+ /* reference-counted subscription helper */
26
+ class RefCountedSubscription {
27
+ constructor(subscribeFn, unsubscribeFn, lingerMs = 30 * 1000) {
28
+ this.subscribeFn = subscribeFn;
29
+ this.unsubscribeFn = unsubscribeFn;
30
+ this.lingerMs = lingerMs;
31
+ this.counts = new Map();
32
+ this.pending = new Map();
33
+ this.lingers = new Map();
34
+ }
35
+ /* subscribe to a topic (reference-counted) */
36
+ async subscribe(topic, options = { qos: 2 }) {
37
+ /* increment count first to reserve our interest */
38
+ const count = this.counts.get(topic) ?? 0;
39
+ this.counts.set(topic, count + 1);
40
+ /* optionally just cancel a pending linger unsubscription
41
+ (subscription is still kept active on the broker) */
42
+ const linger = this.lingers.get(topic);
43
+ if (linger) {
44
+ clearTimeout(linger);
45
+ this.lingers.delete(topic);
46
+ return;
47
+ }
48
+ /* if we are the first, we must perform the actual subscription */
49
+ if (count === 0) {
50
+ const promise = this.subscribeFn(topic, options).finally(() => {
51
+ this.pending.delete(topic);
52
+ }).catch((err) => {
53
+ const count = this.counts.get(topic);
54
+ if (count) {
55
+ if (count <= 1)
56
+ this.counts.delete(topic);
57
+ else
58
+ this.counts.set(topic, count - 1);
59
+ }
60
+ throw err;
61
+ });
62
+ this.pending.set(topic, promise);
63
+ return promise;
64
+ }
65
+ else {
66
+ /* perhaps still need to wait for a pending subscription */
67
+ const pending = this.pending.get(topic);
68
+ if (pending)
69
+ return pending.catch((err) => {
70
+ const count = this.counts.get(topic);
71
+ if (count) {
72
+ if (count <= 1)
73
+ this.counts.delete(topic);
74
+ else
75
+ this.counts.set(topic, count - 1);
76
+ }
77
+ throw err;
78
+ });
79
+ }
80
+ }
81
+ /* unsubscribe from a topic (reference-counted) */
82
+ async unsubscribe(topic) {
83
+ const count = this.counts.get(topic);
84
+ if (count) {
85
+ if (count <= 1) {
86
+ this.counts.delete(topic);
87
+ if (this.lingerMs > 0) {
88
+ /* defer the actual broker unsubscription */
89
+ const timer = setTimeout(() => {
90
+ this.lingers.delete(topic);
91
+ this.unsubscribeFn(topic).catch(() => { });
92
+ }, this.lingerMs);
93
+ this.lingers.set(topic, timer);
94
+ }
95
+ else
96
+ await this.unsubscribeFn(topic).catch(() => { });
97
+ }
98
+ else
99
+ this.counts.set(topic, count - 1);
100
+ }
101
+ }
102
+ /* flush all pending linger timers and unsubscribe */
103
+ async flush() {
104
+ /* cancel all pending linger timers first (synchronously) */
105
+ const topics = [...this.lingers.keys()];
106
+ for (const topic of topics) {
107
+ clearTimeout(this.lingers.get(topic));
108
+ this.lingers.delete(topic);
109
+ }
110
+ /* then unsubscribe from all lingered topics */
111
+ for (const topic of topics)
112
+ await this.unsubscribeFn(topic).catch(() => { });
113
+ }
114
+ }
115
+ /* Subscription trait with shared MQTT subscription management */
116
+ export class SubscriptionTrait extends BaseTrait {
117
+ constructor() {
118
+ super(...arguments);
119
+ this.subscriptions = new RefCountedSubscription((topic, options) => this._subscribeTopic(topic, options), (topic) => this._unsubscribeTopic(topic));
120
+ }
121
+ /* destroy topic trait */
122
+ async destroy() {
123
+ await this.subscriptions.flush();
124
+ await super.destroy();
125
+ }
126
+ }
@@ -0,0 +1,8 @@
1
+ import type { APISchema } from "./mqtt-plus-api";
2
+ import { SubscriptionTrait } from "./mqtt-plus-subscription";
3
+ export declare class TimerTrait<T extends APISchema = APISchema> extends SubscriptionTrait<T> {
4
+ private timers;
5
+ destroy(): Promise<void>;
6
+ protected timerRefresh(id: string, onTimeout: () => void): void;
7
+ protected timerClear(id: string): void;
8
+ }
@@ -0,0 +1,57 @@
1
+ /*
2
+ ** MQTT+ -- MQTT Communication Patterns
3
+ ** Copyright (c) 2018-2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
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
+ import { SubscriptionTrait } from "./mqtt-plus-subscription";
25
+ /* Timer trait with reusable timer management */
26
+ export class TimerTrait extends SubscriptionTrait {
27
+ constructor() {
28
+ super(...arguments);
29
+ /* internal state */
30
+ this.timers = new Map();
31
+ }
32
+ /* destroy timer trait */
33
+ async destroy() {
34
+ for (const timer of this.timers.values())
35
+ clearTimeout(timer);
36
+ this.timers.clear();
37
+ await super.destroy();
38
+ }
39
+ /* refresh (or start) a named timer */
40
+ timerRefresh(id, onTimeout) {
41
+ const timer = this.timers.get(id);
42
+ if (timer !== undefined)
43
+ clearTimeout(timer);
44
+ this.timers.set(id, setTimeout(() => {
45
+ this.timers.delete(id);
46
+ onTimeout();
47
+ }, this.options.timeout));
48
+ }
49
+ /* clear a named timer */
50
+ timerClear(id) {
51
+ const timer = this.timers.get(id);
52
+ if (timer !== undefined) {
53
+ clearTimeout(timer);
54
+ this.timers.delete(id);
55
+ }
56
+ }
57
+ }
@@ -0,0 +1,20 @@
1
+ import type { IClientSubscribeOptions } from "mqtt";
2
+ import type { APISchema } from "./mqtt-plus-api";
3
+ import { BaseTrait } from "./mqtt-plus-base";
4
+ declare class RefCountedSubscription {
5
+ private subscribeFn;
6
+ private unsubscribeFn;
7
+ private lingerMs;
8
+ private counts;
9
+ private pending;
10
+ private lingers;
11
+ constructor(subscribeFn: (topic: string, options: IClientSubscribeOptions) => Promise<void>, unsubscribeFn: (topic: string) => Promise<void>, lingerMs?: number);
12
+ subscribe(topic: string, options?: IClientSubscribeOptions): Promise<void>;
13
+ unsubscribe(topic: string): Promise<void>;
14
+ flush(): Promise<void>;
15
+ }
16
+ export declare class TopicTrait<T extends APISchema = APISchema> extends BaseTrait<T> {
17
+ protected subscriptions: RefCountedSubscription;
18
+ destroy(): void;
19
+ }
20
+ export {};
@@ -0,0 +1,112 @@
1
+ /*
2
+ ** MQTT+ -- MQTT Communication Patterns
3
+ ** Copyright (c) 2018-2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
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
+ import { BaseTrait } from "./mqtt-plus-base";
25
+ /* reference-counted subscription helper */
26
+ class RefCountedSubscription {
27
+ constructor(subscribeFn, unsubscribeFn, lingerMs = 30 * 1000) {
28
+ this.subscribeFn = subscribeFn;
29
+ this.unsubscribeFn = unsubscribeFn;
30
+ this.lingerMs = lingerMs;
31
+ this.counts = new Map();
32
+ this.pending = new Map();
33
+ this.lingers = new Map();
34
+ }
35
+ async subscribe(topic, options = { qos: 2 }) {
36
+ /* increment count first to reserve our interest */
37
+ const count = this.counts.get(topic) ?? 0;
38
+ this.counts.set(topic, count + 1);
39
+ /* optionally just cancel a pending linger unsubscription
40
+ (subscription is still kept active on the broker) */
41
+ const linger = this.lingers.get(topic);
42
+ if (linger) {
43
+ clearTimeout(linger);
44
+ this.lingers.delete(topic);
45
+ return;
46
+ }
47
+ /* if we are the first, we must perform the actual subscription */
48
+ if (count === 0) {
49
+ const promise = this.subscribeFn(topic, options).finally(() => {
50
+ this.pending.delete(topic);
51
+ }).catch((err) => {
52
+ const count = this.counts.get(topic);
53
+ if (count) {
54
+ if (count <= 1)
55
+ this.counts.delete(topic);
56
+ else
57
+ this.counts.set(topic, count - 1);
58
+ }
59
+ throw err;
60
+ });
61
+ this.pending.set(topic, promise);
62
+ return promise;
63
+ }
64
+ else {
65
+ /* perhaps still need to wait for a pending subscription */
66
+ const pending = this.pending.get(topic);
67
+ if (pending)
68
+ return pending;
69
+ }
70
+ }
71
+ async unsubscribe(topic) {
72
+ const count = this.counts.get(topic);
73
+ if (count) {
74
+ if (count <= 1) {
75
+ this.counts.delete(topic);
76
+ if (this.lingerMs > 0) {
77
+ /* defer the actual broker unsubscription */
78
+ const timer = setTimeout(() => {
79
+ this.lingers.delete(topic);
80
+ this.unsubscribeFn(topic).catch(() => { });
81
+ }, this.lingerMs);
82
+ this.lingers.set(topic, timer);
83
+ }
84
+ else
85
+ await this.unsubscribeFn(topic).catch(() => { });
86
+ }
87
+ else
88
+ this.counts.set(topic, count - 1);
89
+ }
90
+ }
91
+ async flush() {
92
+ /* flush all pending linger timers and unsubscribe immediately */
93
+ const topics = [...this.lingers.keys()];
94
+ for (const topic of topics) {
95
+ clearTimeout(this.lingers.get(topic));
96
+ this.lingers.delete(topic);
97
+ await this.unsubscribeFn(topic).catch(() => { });
98
+ }
99
+ }
100
+ }
101
+ /* Topic trait with shared subscription management */
102
+ export class TopicTrait extends BaseTrait {
103
+ constructor() {
104
+ super(...arguments);
105
+ this.subscriptions = new RefCountedSubscription((topic, options) => this._subscribeTopic(topic, options), (topic) => this._unsubscribeTopic(topic));
106
+ }
107
+ /* destroy topic trait */
108
+ destroy() {
109
+ this.subscriptions.flush();
110
+ super.destroy();
111
+ }
112
+ }
@@ -33,6 +33,7 @@ class LogEvent {
33
33
  this.msg = msg;
34
34
  this.data = data;
35
35
  }
36
+ /* resolve all pending promises in the log event */
36
37
  async resolve() {
37
38
  if (this.msg instanceof Promise)
38
39
  this.msg = await this.msg.catch(() => "<resolve-failed>");
@@ -41,6 +42,7 @@ class LogEvent {
41
42
  if (this.data[field] instanceof Promise)
42
43
  this.data[field] = await this.data[field].catch(() => "<resolve-failed>");
43
44
  }
45
+ /* render log event as string */
44
46
  toString() {
45
47
  /* render time */
46
48
  const timestamp = new Date(this.timestamp);
@@ -1,17 +1,4 @@
1
1
  import { Readable } from "node:stream";
2
- import type { IClientSubscribeOptions } from "mqtt";
3
- export declare class RefCountedSubscription {
4
- private subscribeFn;
5
- private unsubscribeFn;
6
- private lingerMs;
7
- private counts;
8
- private pending;
9
- private lingers;
10
- constructor(subscribeFn: (topic: string, options: IClientSubscribeOptions) => Promise<void>, unsubscribeFn: (topic: string) => Promise<void>, lingerMs?: number);
11
- subscribe(topic: string, options?: IClientSubscribeOptions): Promise<void>;
12
- unsubscribe(topic: string): Promise<void>;
13
- flush(): Promise<void>;
14
- }
15
2
  export declare class CreditGate {
16
3
  private remaining;
17
4
  private waiters;