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.
- package/AGENTS.md +55 -44
- package/CHANGELOG.md +14 -0
- package/README.md +4 -3
- package/doc/mqtt-plus-api.md +693 -680
- package/doc/mqtt-plus-architecture.d2 +139 -0
- package/doc/mqtt-plus-architecture.md +10 -0
- package/doc/mqtt-plus-architecture.svg +95 -0
- package/doc/mqtt-plus-broker-setup.md +9 -3
- package/doc/mqtt-plus-comm.md +73 -0
- package/doc/mqtt-plus-internals.md +3 -3
- package/dst-stage1/mqtt-plus-base.d.ts +3 -2
- package/dst-stage1/mqtt-plus-base.js +53 -22
- package/dst-stage1/mqtt-plus-event.d.ts +0 -2
- package/dst-stage1/mqtt-plus-event.js +6 -26
- package/dst-stage1/mqtt-plus-meta.d.ts +2 -2
- package/dst-stage1/mqtt-plus-meta.js +2 -2
- package/dst-stage1/mqtt-plus-msg.d.ts +2 -0
- package/dst-stage1/mqtt-plus-msg.js +17 -0
- package/dst-stage1/mqtt-plus-service.d.ts +0 -5
- package/dst-stage1/mqtt-plus-service.js +12 -48
- package/dst-stage1/mqtt-plus-sink.d.ts +0 -10
- package/dst-stage1/mqtt-plus-sink.js +25 -92
- package/dst-stage1/mqtt-plus-source.d.ts +0 -10
- package/dst-stage1/mqtt-plus-source.js +23 -88
- package/dst-stage1/mqtt-plus-subscription.d.ts +20 -0
- package/dst-stage1/mqtt-plus-subscription.js +126 -0
- package/dst-stage1/mqtt-plus-timer.d.ts +8 -0
- package/dst-stage1/mqtt-plus-timer.js +57 -0
- package/dst-stage1/mqtt-plus-topic.d.ts +20 -0
- package/dst-stage1/mqtt-plus-topic.js +112 -0
- package/dst-stage1/mqtt-plus-trace.js +2 -0
- package/dst-stage1/mqtt-plus-util.d.ts +0 -13
- package/dst-stage1/mqtt-plus-util.js +1 -77
- package/dst-stage1/mqtt-plus-version.d.ts +0 -1
- package/dst-stage1/mqtt-plus-version.js +0 -6
- package/dst-stage1/tsc.tsbuildinfo +1 -1
- package/dst-stage2/mqtt-plus.cjs.js +242 -292
- package/dst-stage2/mqtt-plus.esm.js +240 -290
- package/dst-stage2/mqtt-plus.umd.js +12 -12
- package/etc/knip.jsonc +1 -1
- package/etc/stx.conf +6 -4
- package/package.json +1 -1
- package/src/mqtt-plus-base.ts +56 -26
- package/src/mqtt-plus-event.ts +8 -24
- package/src/mqtt-plus-meta.ts +3 -3
- package/src/mqtt-plus-msg.ts +28 -0
- package/src/mqtt-plus-service.ts +12 -50
- package/src/mqtt-plus-sink.ts +32 -105
- package/src/mqtt-plus-source.ts +29 -99
- package/src/mqtt-plus-subscription.ts +141 -0
- package/src/mqtt-plus-timer.ts +61 -0
- package/src/mqtt-plus-trace.ts +4 -0
- package/src/mqtt-plus-util.ts +1 -81
- package/src/mqtt-plus-version.ts +0 -7
- package/tst/mqtt-plus-0-fixture.ts +2 -2
- package/tst/mqtt-plus-0-mosquitto.ts +5 -0
- package/tst/mqtt-plus-1-api.spec.ts +1 -1
- package/tst/mqtt-plus-2-event.spec.ts +0 -6
- package/tst/mqtt-plus-3-service.spec.ts +3 -7
- package/tst/mqtt-plus-4-sink.spec.ts +14 -9
- package/tst/mqtt-plus-5-source.spec.ts +11 -5
- package/tst/mqtt-plus-6-misc.spec.ts +23 -23
- package/tst/tsc.json +1 -1
- package/doc/mqtt-plus-communication.md +0 -68
- /package/doc/{mqtt-plus-1-event-emission.d2 → mqtt-plus-comm-event-emission.d2} +0 -0
- /package/doc/{mqtt-plus-1-event-emission.svg → mqtt-plus-comm-event-emission.svg} +0 -0
- /package/doc/{mqtt-plus-2-service-call.d2 → mqtt-plus-comm-service-call.d2} +0 -0
- /package/doc/{mqtt-plus-2-service-call.svg → mqtt-plus-comm-service-call.svg} +0 -0
- /package/doc/{mqtt-plus-3-sink-push.d2 → mqtt-plus-comm-sink-push.d2} +0 -0
- /package/doc/{mqtt-plus-3-sink-push.svg → mqtt-plus-comm-sink-push.svg} +0 -0
- /package/doc/{mqtt-plus-4-source-fetch.d2 → mqtt-plus-comm-source-fetch.d2} +0 -0
- /package/doc/{mqtt-plus-4-source-fetch.svg → mqtt-plus-comm-source-fetch.svg} +0 -0
- /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,
|
|
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.
|
|
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.
|
|
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.
|
|
127
|
-
|
|
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.
|
|
114
|
+
this.onResponse.set(`source-fetch-credit:${requestId}`, (creditParsed) => {
|
|
143
115
|
creditGate.replenish(creditParsed.credit);
|
|
144
|
-
|
|
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.
|
|
160
|
+
this.onResponse.delete(`source-fetch-credit:${requestId}`);
|
|
189
161
|
});
|
|
190
162
|
});
|
|
191
|
-
spool.roll(() => { this.
|
|
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.
|
|
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.
|
|
238
|
-
spool.roll(() => this.
|
|
239
|
-
await run(`subscribe to MQTT topic "${chunkTopic}"`, spool, () => this.
|
|
240
|
-
spool.roll(() => this.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
331
|
-
this.
|
|
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;
|