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
package/src/mqtt-plus-sink.ts
CHANGED
|
@@ -31,7 +31,7 @@ import type { IClientPublishOptions,
|
|
|
31
31
|
import { nanoid } from "nanoid"
|
|
32
32
|
|
|
33
33
|
/* internal requirements */
|
|
34
|
-
import { CreditGate,
|
|
34
|
+
import { CreditGate,
|
|
35
35
|
streamToBuffer, sendBufferAsChunks,
|
|
36
36
|
sendStreamAsChunks, makeMutuallyExclusiveFields } from "./mqtt-plus-util"
|
|
37
37
|
import { run, Spool } from "./mqtt-plus-error"
|
|
@@ -45,47 +45,8 @@ import type { AuthOption } from "./mqtt-plus-auth
|
|
|
45
45
|
/* Sink Push Trait */
|
|
46
46
|
export class SinkTrait<T extends APISchema = APISchema> extends SourceTrait<T> {
|
|
47
47
|
/* sink state */
|
|
48
|
-
private
|
|
49
|
-
private
|
|
50
|
-
private pushSpools = new Map<string, Spool>()
|
|
51
|
-
private pushTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
52
|
-
private pushChunkCallbacks = new Map<string, (response: SinkPushChunk, topicName: string) => void>()
|
|
53
|
-
private pushResponseCallbacks = new Map<string, (response: SinkPushResponse) => void>()
|
|
54
|
-
private pushCreditCallbacks = new Map<string, (response: SinkPushCredit) => void>()
|
|
55
|
-
private pushSubscriptions = new RefCountedSubscription(
|
|
56
|
-
(topic, options) => this._subscribeTopic(topic, options),
|
|
57
|
-
(topic) => this._unsubscribeTopic(topic)
|
|
58
|
-
)
|
|
59
|
-
|
|
60
|
-
/* destroy sink trait */
|
|
61
|
-
override destroy () {
|
|
62
|
-
super.destroy()
|
|
63
|
-
this.pushSubscriptions.flush()
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/* refresh push timer for a specific request */
|
|
67
|
-
private _refreshPushTimer (requestId: string) {
|
|
68
|
-
const timer = this.pushTimers.get(requestId)
|
|
69
|
-
if (timer !== undefined)
|
|
70
|
-
clearTimeout(timer)
|
|
71
|
-
this.pushTimers.set(requestId, setTimeout(() => {
|
|
72
|
-
this.pushTimers.delete(requestId)
|
|
73
|
-
const stream = this.pushStreams.get(requestId)
|
|
74
|
-
if (stream !== undefined)
|
|
75
|
-
stream.destroy(new Error("push stream timeout"))
|
|
76
|
-
const spool = this.pushSpools.get(requestId)
|
|
77
|
-
spool?.unroll()
|
|
78
|
-
}, this.options.timeout))
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/* clear push timer for a specific request */
|
|
82
|
-
private _clearPushTimer (requestId: string) {
|
|
83
|
-
const timer = this.pushTimers.get(requestId)
|
|
84
|
-
if (timer !== undefined) {
|
|
85
|
-
clearTimeout(timer)
|
|
86
|
-
this.pushTimers.delete(requestId)
|
|
87
|
-
}
|
|
88
|
-
}
|
|
48
|
+
private pushStreams = new Map<string, Readable>()
|
|
49
|
+
private pushSpools = new Map<string, Spool>()
|
|
89
50
|
|
|
90
51
|
/* register a sink */
|
|
91
52
|
async sink<K extends SinkKeys<T> & string> (
|
|
@@ -135,7 +96,7 @@ export class SinkTrait<T extends APISchema = APISchema> extends SourceTrait<T> {
|
|
|
135
96
|
const spool = new Spool()
|
|
136
97
|
|
|
137
98
|
/* sanity check situation */
|
|
138
|
-
if (this.
|
|
99
|
+
if (this.onRequest.has(`sink-push-request:${name}`))
|
|
139
100
|
throw new Error(`sink: sink "${name}" already established`)
|
|
140
101
|
|
|
141
102
|
/* generate the corresponding MQTT topics for broadcast and direct use */
|
|
@@ -145,7 +106,7 @@ export class SinkTrait<T extends APISchema = APISchema> extends SourceTrait<T> {
|
|
|
145
106
|
const topicChunkD = this.options.topicMake(name, "sink-push-chunk", this.options.id)
|
|
146
107
|
|
|
147
108
|
/* remember the registration */
|
|
148
|
-
this.
|
|
109
|
+
this.onRequest.set(`sink-push-request:${name}`, (request: SinkPushRequest, topicName: string) => {
|
|
149
110
|
/* determine information */
|
|
150
111
|
const requestId = request.id
|
|
151
112
|
const params = request.params ?? []
|
|
@@ -195,8 +156,14 @@ export class SinkTrait<T extends APISchema = APISchema> extends SourceTrait<T> {
|
|
|
195
156
|
} : undefined
|
|
196
157
|
|
|
197
158
|
/* utility functions for timeout management */
|
|
198
|
-
const refreshPushTimeout = () => this.
|
|
199
|
-
|
|
159
|
+
const refreshPushTimeout = () => this.timerRefresh(requestId, () => {
|
|
160
|
+
const stream = this.pushStreams.get(requestId)
|
|
161
|
+
if (stream !== undefined)
|
|
162
|
+
stream.destroy(new Error("push stream timeout"))
|
|
163
|
+
const spool = this.pushSpools.get(requestId)
|
|
164
|
+
spool?.unroll()
|
|
165
|
+
})
|
|
166
|
+
const clearPushTimeout = () => this.timerClear(requestId)
|
|
200
167
|
|
|
201
168
|
/* create a readable for buffering received chunks */
|
|
202
169
|
const readable = new Readable({
|
|
@@ -226,7 +193,7 @@ export class SinkTrait<T extends APISchema = APISchema> extends SourceTrait<T> {
|
|
|
226
193
|
readable.once("error", () => reqSpool.unroll())
|
|
227
194
|
|
|
228
195
|
/* register chunk dispatch callback */
|
|
229
|
-
this.
|
|
196
|
+
this.onResponse.set(`sink-push-chunk:${requestId}`, (chunkParsed: SinkPushChunk, chunkTopicName: string) => {
|
|
230
197
|
if (chunkTopicName !== chunkParsed.name)
|
|
231
198
|
throw new Error(`sink name mismatch between topic "${chunkTopicName}" ` +
|
|
232
199
|
`and payload "${chunkParsed.name}"`)
|
|
@@ -247,7 +214,7 @@ export class SinkTrait<T extends APISchema = APISchema> extends SourceTrait<T> {
|
|
|
247
214
|
}
|
|
248
215
|
}
|
|
249
216
|
})
|
|
250
|
-
reqSpool.roll(() => { this.
|
|
217
|
+
reqSpool.roll(() => { this.onResponse.delete(`sink-push-chunk:${requestId}`) })
|
|
251
218
|
|
|
252
219
|
/* start timeout for push stream cleanup */
|
|
253
220
|
refreshPushTimeout()
|
|
@@ -276,7 +243,7 @@ export class SinkTrait<T extends APISchema = APISchema> extends SourceTrait<T> {
|
|
|
276
243
|
await sendResponse(err.message).catch(() => {})
|
|
277
244
|
})
|
|
278
245
|
})
|
|
279
|
-
spool.roll(() => { this.
|
|
246
|
+
spool.roll(() => { this.onRequest.delete(`sink-push-request:${name}`) })
|
|
280
247
|
|
|
281
248
|
/* subscribe to MQTT topics */
|
|
282
249
|
await run(`subscribe to MQTT topic "${topicReqB}"`, spool, () =>
|
|
@@ -292,7 +259,7 @@ export class SinkTrait<T extends APISchema = APISchema> extends SourceTrait<T> {
|
|
|
292
259
|
/* provide a registration for subsequent destruction */
|
|
293
260
|
return {
|
|
294
261
|
destroy: async (): Promise<void> => {
|
|
295
|
-
if (!this.
|
|
262
|
+
if (!this.onRequest.has(`sink-push-request:${name}`))
|
|
296
263
|
throw new Error(`destroy: sink "${name}" not established`)
|
|
297
264
|
await spool.unroll(false)?.catch((err: Error) => {
|
|
298
265
|
this.error(err, `destroy: failed to cleanup: ${err.message}`)
|
|
@@ -360,8 +327,8 @@ export class SinkTrait<T extends APISchema = APISchema> extends SourceTrait<T> {
|
|
|
360
327
|
/* subscribe to response topic (for ack/nak) */
|
|
361
328
|
const responseTopic = this.options.topicMake(name, "sink-push-response", this.options.id)
|
|
362
329
|
await run(`subscribe to MQTT topic "${responseTopic}"`, spool, () =>
|
|
363
|
-
this.
|
|
364
|
-
spool.roll(() => this.
|
|
330
|
+
this.subscriptions.subscribe(responseTopic, { qos: options.qos ?? 2 }))
|
|
331
|
+
spool.roll(() => this.subscriptions.unsubscribe(responseTopic))
|
|
365
332
|
|
|
366
333
|
/* define abort controller and signal */
|
|
367
334
|
const abortController = new AbortController()
|
|
@@ -400,7 +367,7 @@ export class SinkTrait<T extends APISchema = APISchema> extends SourceTrait<T> {
|
|
|
400
367
|
spool.roll(() => { abortSignal.removeEventListener("abort", onAbort) })
|
|
401
368
|
|
|
402
369
|
/* register handlers for initial response */
|
|
403
|
-
this.
|
|
370
|
+
this.onResponse.set(`sink-push-response:${requestId}`, (response: SinkPushResponse) => {
|
|
404
371
|
if (response.error)
|
|
405
372
|
reject(new Error(response.error))
|
|
406
373
|
else {
|
|
@@ -410,11 +377,11 @@ export class SinkTrait<T extends APISchema = APISchema> extends SourceTrait<T> {
|
|
|
410
377
|
resolve()
|
|
411
378
|
}
|
|
412
379
|
})
|
|
413
|
-
spool.roll(() => { this.
|
|
414
|
-
this.
|
|
380
|
+
spool.roll(() => { this.onResponse.delete(`sink-push-response:${requestId}`) })
|
|
381
|
+
this.onResponse.set(`sink-push-credit:${requestId}`, (_response: SinkPushCredit) => {
|
|
415
382
|
refreshTimeout()
|
|
416
383
|
})
|
|
417
|
-
spool.roll(() => { this.
|
|
384
|
+
spool.roll(() => { this.onResponse.delete(`sink-push-credit:${requestId}`) })
|
|
418
385
|
|
|
419
386
|
/* generate and send request message */
|
|
420
387
|
const auth = this.authenticate()
|
|
@@ -430,7 +397,7 @@ export class SinkTrait<T extends APISchema = APISchema> extends SourceTrait<T> {
|
|
|
430
397
|
})
|
|
431
398
|
|
|
432
399
|
/* override handler for mid-stream (error) responses */
|
|
433
|
-
this.
|
|
400
|
+
this.onResponse.set(`sink-push-response:${requestId}`, (response: SinkPushResponse) => {
|
|
434
401
|
if (response.error)
|
|
435
402
|
abortController.abort(new Error(response.error))
|
|
436
403
|
})
|
|
@@ -443,13 +410,13 @@ export class SinkTrait<T extends APISchema = APISchema> extends SourceTrait<T> {
|
|
|
443
410
|
if (creditGate) {
|
|
444
411
|
const creditTopic = this.options.topicMake(name, "sink-push-credit", this.options.id)
|
|
445
412
|
await run(`subscribe to MQTT topic "${creditTopic}"`, spool, () =>
|
|
446
|
-
this.
|
|
447
|
-
spool.roll(() => this.
|
|
413
|
+
this.subscriptions.subscribe(creditTopic, { qos: 2 }))
|
|
414
|
+
spool.roll(() => this.subscriptions.unsubscribe(creditTopic))
|
|
448
415
|
const gate = creditGate
|
|
449
416
|
spool.roll(() => { gate.abort() })
|
|
450
417
|
|
|
451
418
|
/* update credit callback to include gate replenish */
|
|
452
|
-
this.
|
|
419
|
+
this.onResponse.set(`sink-push-credit:${requestId}`, (response: SinkPushCredit) => {
|
|
453
420
|
gate.replenish(response.credit)
|
|
454
421
|
refreshTimeout()
|
|
455
422
|
})
|
|
@@ -459,7 +426,11 @@ export class SinkTrait<T extends APISchema = APISchema> extends SourceTrait<T> {
|
|
|
459
426
|
const chunkTopic = this.options.topicMake(name, "sink-push-chunk", receiver)
|
|
460
427
|
|
|
461
428
|
/* callback for creating and sending a chunk message */
|
|
462
|
-
const sendChunk = async (
|
|
429
|
+
const sendChunk = async (
|
|
430
|
+
chunk: Uint8Array | undefined,
|
|
431
|
+
error: string | undefined,
|
|
432
|
+
final: boolean
|
|
433
|
+
): Promise<void> => {
|
|
463
434
|
refreshTimeout()
|
|
464
435
|
const chunkMsg = this.msg.makeSinkPushChunk(requestId,
|
|
465
436
|
name, chunk, error, final, this.options.id, receiver)
|
|
@@ -489,48 +460,4 @@ export class SinkTrait<T extends APISchema = APISchema> extends SourceTrait<T> {
|
|
|
489
460
|
}
|
|
490
461
|
}
|
|
491
462
|
|
|
492
|
-
/* dispatch incoming MQTT message */
|
|
493
|
-
protected override async _dispatchMessage (topic: string, message: any) {
|
|
494
|
-
/* forward dispatching to other traits */
|
|
495
|
-
await super._dispatchMessage(topic, message)
|
|
496
|
-
|
|
497
|
-
/* match the MQTT topic */
|
|
498
|
-
const topicMatch = this.options.topicMatch(topic)
|
|
499
|
-
|
|
500
|
-
/* handle sink push request (on server-side) */
|
|
501
|
-
if (topicMatch !== null
|
|
502
|
-
&& topicMatch.operation === "sink-push-request"
|
|
503
|
-
&& message instanceof SinkPushRequest) {
|
|
504
|
-
const handler = this.sinks.get(message.name)
|
|
505
|
-
if (handler !== undefined)
|
|
506
|
-
handler(message, topicMatch.name)
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
/* handle sink push response (on client-side) */
|
|
510
|
-
else if (topicMatch !== null
|
|
511
|
-
&& topicMatch.operation === "sink-push-response"
|
|
512
|
-
&& message instanceof SinkPushResponse) {
|
|
513
|
-
const handler = this.pushResponseCallbacks.get(message.id)
|
|
514
|
-
if (handler !== undefined)
|
|
515
|
-
handler(message)
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
/* handle sink push chunk (on server-side) */
|
|
519
|
-
else if (topicMatch !== null
|
|
520
|
-
&& topicMatch.operation === "sink-push-chunk"
|
|
521
|
-
&& message instanceof SinkPushChunk) {
|
|
522
|
-
const handler = this.pushChunkCallbacks.get(message.id)
|
|
523
|
-
if (handler !== undefined)
|
|
524
|
-
handler(message, topicMatch.name)
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
/* handle sink push credit (on client-side) */
|
|
528
|
-
else if (topicMatch !== null
|
|
529
|
-
&& topicMatch.operation === "sink-push-credit"
|
|
530
|
-
&& message instanceof SinkPushCredit) {
|
|
531
|
-
const handler = this.pushCreditCallbacks.get(message.id)
|
|
532
|
-
if (handler !== undefined)
|
|
533
|
-
handler(message)
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
463
|
}
|
package/src/mqtt-plus-source.ts
CHANGED
|
@@ -31,7 +31,7 @@ import type { IClientPublishOptions,
|
|
|
31
31
|
import { nanoid } from "nanoid"
|
|
32
32
|
|
|
33
33
|
/* internal requirements */
|
|
34
|
-
import { CreditGate,
|
|
34
|
+
import { CreditGate,
|
|
35
35
|
streamToBuffer, sendBufferAsChunks,
|
|
36
36
|
sendStreamAsChunks, makeMutuallyExclusiveFields } from "./mqtt-plus-util"
|
|
37
37
|
import { run, Spool, ensureError } from "./mqtt-plus-error"
|
|
@@ -45,44 +45,7 @@ import type { AuthOption } from "./mqtt-plus-auth
|
|
|
45
45
|
/* Source Fetch Trait */
|
|
46
46
|
export class SourceTrait<T extends APISchema = APISchema> extends ServiceTrait<T> {
|
|
47
47
|
/* source state */
|
|
48
|
-
private
|
|
49
|
-
private fetchResponseCallbacks = new Map<string, (response: SourceFetchResponse) => void>()
|
|
50
|
-
private fetchChunkCallbacks = new Map<string, (response: SourceFetchChunk) => void>()
|
|
51
|
-
private sourceCreditCallbacks = new Map<string, (response: SourceFetchCredit) => void>()
|
|
52
|
-
private sourceCreditGates = new Map<string, CreditGate>()
|
|
53
|
-
private sourceTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
54
|
-
private fetchSubscriptions = new RefCountedSubscription(
|
|
55
|
-
(topic, options) => this._subscribeTopic(topic, options),
|
|
56
|
-
(topic) => this._unsubscribeTopic(topic)
|
|
57
|
-
)
|
|
58
|
-
|
|
59
|
-
/* refresh source timer for a specific request */
|
|
60
|
-
private _refreshSourceTimer (requestId: string) {
|
|
61
|
-
const timer = this.sourceTimers.get(requestId)
|
|
62
|
-
if (timer !== undefined)
|
|
63
|
-
clearTimeout(timer)
|
|
64
|
-
this.sourceTimers.set(requestId, setTimeout(() => {
|
|
65
|
-
this.sourceTimers.delete(requestId)
|
|
66
|
-
const gate = this.sourceCreditGates.get(requestId)
|
|
67
|
-
if (gate !== undefined)
|
|
68
|
-
gate.abort()
|
|
69
|
-
}, this.options.timeout))
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/* clear source timer for a specific request */
|
|
73
|
-
private _clearSourceTimer (requestId: string) {
|
|
74
|
-
const timer = this.sourceTimers.get(requestId)
|
|
75
|
-
if (timer !== undefined) {
|
|
76
|
-
clearTimeout(timer)
|
|
77
|
-
this.sourceTimers.delete(requestId)
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/* destroy source trait */
|
|
82
|
-
override destroy () {
|
|
83
|
-
super.destroy()
|
|
84
|
-
this.fetchSubscriptions.flush()
|
|
85
|
-
}
|
|
48
|
+
private sourceCreditGates = new Map<string, CreditGate>()
|
|
86
49
|
|
|
87
50
|
/* establish a source (for fetch requests) */
|
|
88
51
|
async source<K extends SourceKeys<T> & string> (
|
|
@@ -132,7 +95,7 @@ export class SourceTrait<T extends APISchema = APISchema> extends ServiceTrait<T
|
|
|
132
95
|
const spool = new Spool()
|
|
133
96
|
|
|
134
97
|
/* sanity check situation */
|
|
135
|
-
if (this.
|
|
98
|
+
if (this.onRequest.has(`source-fetch-request:${name}`))
|
|
136
99
|
throw new Error(`source: source "${name}" already established`)
|
|
137
100
|
|
|
138
101
|
/* generate the corresponding MQTT topics for broadcast and direct use */
|
|
@@ -142,7 +105,7 @@ export class SourceTrait<T extends APISchema = APISchema> extends ServiceTrait<T
|
|
|
142
105
|
const topicCreditD = this.options.topicMake(name, "source-fetch-credit", this.options.id)
|
|
143
106
|
|
|
144
107
|
/* remember the registration */
|
|
145
|
-
this.
|
|
108
|
+
this.onRequest.set(`source-fetch-request:${name}`, (request: SourceFetchRequest, topicName: string) => {
|
|
146
109
|
/* determine information */
|
|
147
110
|
const requestId = request.id
|
|
148
111
|
const params = request.params ?? []
|
|
@@ -171,12 +134,20 @@ export class SourceTrait<T extends APISchema = APISchema> extends ServiceTrait<T
|
|
|
171
134
|
}
|
|
172
135
|
|
|
173
136
|
/* utility functions for timeout management */
|
|
174
|
-
const refreshSourceTimeout = () => this.
|
|
175
|
-
|
|
137
|
+
const refreshSourceTimeout = () => this.timerRefresh(requestId, () => {
|
|
138
|
+
const gate = this.sourceCreditGates.get(requestId)
|
|
139
|
+
if (gate !== undefined)
|
|
140
|
+
gate.abort()
|
|
141
|
+
})
|
|
142
|
+
const clearSourceTimeout = () => this.timerClear(requestId)
|
|
176
143
|
refreshSourceTimeout()
|
|
177
144
|
|
|
178
145
|
/* callback for creating and sending a chunk message */
|
|
179
|
-
const sendChunk = async (
|
|
146
|
+
const sendChunk = async (
|
|
147
|
+
chunk: Uint8Array | undefined,
|
|
148
|
+
error: string | undefined,
|
|
149
|
+
final: boolean
|
|
150
|
+
): Promise<void> => {
|
|
180
151
|
refreshSourceTimeout()
|
|
181
152
|
const chunkMsg = this.msg.makeSourceFetchChunk(requestId,
|
|
182
153
|
name, chunk, error, final, this.options.id, sender)
|
|
@@ -190,9 +161,9 @@ export class SourceTrait<T extends APISchema = APISchema> extends ServiceTrait<T
|
|
|
190
161
|
? new CreditGate(initialCredit) : undefined
|
|
191
162
|
if (creditGate) {
|
|
192
163
|
this.sourceCreditGates.set(requestId, creditGate)
|
|
193
|
-
this.
|
|
164
|
+
this.onResponse.set(`source-fetch-credit:${requestId}`, (creditParsed: SourceFetchCredit) => {
|
|
194
165
|
creditGate.replenish(creditParsed.credit)
|
|
195
|
-
|
|
166
|
+
refreshSourceTimeout()
|
|
196
167
|
})
|
|
197
168
|
}
|
|
198
169
|
|
|
@@ -239,10 +210,10 @@ export class SourceTrait<T extends APISchema = APISchema> extends ServiceTrait<T
|
|
|
239
210
|
creditGate.abort()
|
|
240
211
|
this.sourceCreditGates.delete(requestId)
|
|
241
212
|
}
|
|
242
|
-
this.
|
|
213
|
+
this.onResponse.delete(`source-fetch-credit:${requestId}`)
|
|
243
214
|
})
|
|
244
215
|
})
|
|
245
|
-
spool.roll(() => { this.
|
|
216
|
+
spool.roll(() => { this.onRequest.delete(`source-fetch-request:${name}`) })
|
|
246
217
|
|
|
247
218
|
/* subscribe to MQTT topics */
|
|
248
219
|
await run(`subscribe to MQTT topic "${topicReqB}"`, spool, () =>
|
|
@@ -258,7 +229,7 @@ export class SourceTrait<T extends APISchema = APISchema> extends ServiceTrait<T
|
|
|
258
229
|
/* provide a registration for subsequent destruction */
|
|
259
230
|
return {
|
|
260
231
|
destroy: async (): Promise<void> => {
|
|
261
|
-
if (!this.
|
|
232
|
+
if (!this.onRequest.has(`source-fetch-request:${name}`))
|
|
262
233
|
throw new Error(`destroy: source "${name}" not established`)
|
|
263
234
|
await spool.unroll(false)?.catch((err: Error) => {
|
|
264
235
|
this.error(err, `destroy: failed to cleanup: ${err.message}`)
|
|
@@ -333,11 +304,11 @@ export class SourceTrait<T extends APISchema = APISchema> extends ServiceTrait<T
|
|
|
333
304
|
const responseTopic = this.options.topicMake(name, "source-fetch-response", this.options.id)
|
|
334
305
|
const chunkTopic = this.options.topicMake(name, "source-fetch-chunk", this.options.id)
|
|
335
306
|
await run(`subscribe to MQTT topic "${responseTopic}"`, spool, () =>
|
|
336
|
-
this.
|
|
337
|
-
spool.roll(() => this.
|
|
307
|
+
this.subscriptions.subscribe(responseTopic, { qos: options.qos ?? 2 }))
|
|
308
|
+
spool.roll(() => this.subscriptions.unsubscribe(responseTopic))
|
|
338
309
|
await run(`subscribe to MQTT topic "${chunkTopic}"`, spool, () =>
|
|
339
|
-
this.
|
|
340
|
-
spool.roll(() => this.
|
|
310
|
+
this.subscriptions.subscribe(chunkTopic, { qos: options.qos ?? 2 }))
|
|
311
|
+
spool.roll(() => this.subscriptions.unsubscribe(chunkTopic))
|
|
341
312
|
|
|
342
313
|
/* credit-based flow control state */
|
|
343
314
|
const chunkCredit = this.options.chunkCredit
|
|
@@ -349,7 +320,7 @@ export class SourceTrait<T extends APISchema = APISchema> extends ServiceTrait<T
|
|
|
349
320
|
const stream = new Readable({
|
|
350
321
|
highWaterMark: chunkCredit > 0 ? chunkCredit * this.options.chunkSize : 16 * 1024,
|
|
351
322
|
read: (_size) => {
|
|
352
|
-
if (chunkCredit <= 0 || !this.
|
|
323
|
+
if (chunkCredit <= 0 || !this.onResponse.has(`source-fetch-chunk:${requestId}`))
|
|
353
324
|
return
|
|
354
325
|
const targetId = serverId ?? receiver
|
|
355
326
|
if (!targetId)
|
|
@@ -405,7 +376,7 @@ export class SourceTrait<T extends APISchema = APISchema> extends ServiceTrait<T
|
|
|
405
376
|
stream.once("error", () => spool.unroll())
|
|
406
377
|
|
|
407
378
|
/* register response dispatch callback */
|
|
408
|
-
this.
|
|
379
|
+
this.onResponse.set(`source-fetch-response:${requestId}`, (response: SourceFetchResponse) => {
|
|
409
380
|
if (response.sender)
|
|
410
381
|
serverId = response.sender
|
|
411
382
|
metaResolve?.(response.meta)
|
|
@@ -418,7 +389,7 @@ export class SourceTrait<T extends APISchema = APISchema> extends ServiceTrait<T
|
|
|
418
389
|
})
|
|
419
390
|
|
|
420
391
|
/* register chunk dispatch callback */
|
|
421
|
-
this.
|
|
392
|
+
this.onResponse.set(`source-fetch-chunk:${requestId}`, (response: SourceFetchChunk) => {
|
|
422
393
|
if (response.sender)
|
|
423
394
|
serverId = response.sender
|
|
424
395
|
if (response.error) {
|
|
@@ -438,8 +409,8 @@ export class SourceTrait<T extends APISchema = APISchema> extends ServiceTrait<T
|
|
|
438
409
|
}
|
|
439
410
|
})
|
|
440
411
|
spool.roll(() => {
|
|
441
|
-
this.
|
|
442
|
-
this.
|
|
412
|
+
this.onResponse.delete(`source-fetch-response:${requestId}`)
|
|
413
|
+
this.onResponse.delete(`source-fetch-chunk:${requestId}`)
|
|
443
414
|
})
|
|
444
415
|
|
|
445
416
|
/* generate encoded message */
|
|
@@ -466,45 +437,4 @@ export class SourceTrait<T extends APISchema = APISchema> extends ServiceTrait<T
|
|
|
466
437
|
return result
|
|
467
438
|
}
|
|
468
439
|
|
|
469
|
-
/* dispatch message (Source Fetch pattern handling) */
|
|
470
|
-
protected override async _dispatchMessage (topic: string, message: any) {
|
|
471
|
-
await super._dispatchMessage(topic, message)
|
|
472
|
-
const topicMatch = this.options.topicMatch(topic)
|
|
473
|
-
|
|
474
|
-
/* handle source fetch request (on server-side) */
|
|
475
|
-
if (topicMatch !== null
|
|
476
|
-
&& topicMatch.operation === "source-fetch-request"
|
|
477
|
-
&& message instanceof SourceFetchRequest) {
|
|
478
|
-
const handler = this.sources.get(message.name)
|
|
479
|
-
if (handler !== undefined)
|
|
480
|
-
handler(message, topicMatch.name)
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
/* handle source fetch response (on client-side) */
|
|
484
|
-
else if (topicMatch !== null
|
|
485
|
-
&& topicMatch.operation === "source-fetch-response"
|
|
486
|
-
&& message instanceof SourceFetchResponse) {
|
|
487
|
-
const handler = this.fetchResponseCallbacks.get(message.id)
|
|
488
|
-
if (handler !== undefined)
|
|
489
|
-
handler(message)
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
/* handle source fetch chunk (on client-side) */
|
|
493
|
-
else if (topicMatch !== null
|
|
494
|
-
&& topicMatch.operation === "source-fetch-chunk"
|
|
495
|
-
&& message instanceof SourceFetchChunk) {
|
|
496
|
-
const handler = this.fetchChunkCallbacks.get(message.id)
|
|
497
|
-
if (handler !== undefined)
|
|
498
|
-
handler(message)
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
/* handle source fetch credit (on server-side) */
|
|
502
|
-
else if (topicMatch !== null
|
|
503
|
-
&& topicMatch.operation === "source-fetch-credit"
|
|
504
|
-
&& message instanceof SourceFetchCredit) {
|
|
505
|
-
const handler = this.sourceCreditCallbacks.get(message.id)
|
|
506
|
-
if (handler !== undefined)
|
|
507
|
-
handler(message)
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
440
|
}
|
|
@@ -0,0 +1,141 @@
|
|
|
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
|
+
|
|
25
|
+
/* external requirements */
|
|
26
|
+
import type { IClientSubscribeOptions } from "mqtt"
|
|
27
|
+
|
|
28
|
+
/* internal requirements */
|
|
29
|
+
import type { APISchema } from "./mqtt-plus-api"
|
|
30
|
+
import { BaseTrait } from "./mqtt-plus-base"
|
|
31
|
+
|
|
32
|
+
/* reference-counted subscription helper */
|
|
33
|
+
class RefCountedSubscription {
|
|
34
|
+
private counts = new Map<string, number>()
|
|
35
|
+
private pending = new Map<string, Promise<void>>()
|
|
36
|
+
private lingers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
37
|
+
constructor (
|
|
38
|
+
private subscribeFn: (topic: string, options: IClientSubscribeOptions) => Promise<void>,
|
|
39
|
+
private unsubscribeFn: (topic: string) => Promise<void>,
|
|
40
|
+
private lingerMs: number = 30 * 1000
|
|
41
|
+
) {}
|
|
42
|
+
|
|
43
|
+
/* subscribe to a topic (reference-counted) */
|
|
44
|
+
async subscribe (topic: string, options: IClientSubscribeOptions = { qos: 2 }): Promise<void> {
|
|
45
|
+
/* increment count first to reserve our interest */
|
|
46
|
+
const count = this.counts.get(topic) ?? 0
|
|
47
|
+
this.counts.set(topic, count + 1)
|
|
48
|
+
|
|
49
|
+
/* optionally just cancel a pending linger unsubscription
|
|
50
|
+
(subscription is still kept active on the broker) */
|
|
51
|
+
const linger = this.lingers.get(topic)
|
|
52
|
+
if (linger) {
|
|
53
|
+
clearTimeout(linger)
|
|
54
|
+
this.lingers.delete(topic)
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* if we are the first, we must perform the actual subscription */
|
|
59
|
+
if (count === 0) {
|
|
60
|
+
const promise = this.subscribeFn(topic, options).finally(() => {
|
|
61
|
+
this.pending.delete(topic)
|
|
62
|
+
}).catch((err: Error) => {
|
|
63
|
+
const count = this.counts.get(topic)
|
|
64
|
+
if (count) {
|
|
65
|
+
if (count <= 1)
|
|
66
|
+
this.counts.delete(topic)
|
|
67
|
+
else
|
|
68
|
+
this.counts.set(topic, count - 1)
|
|
69
|
+
}
|
|
70
|
+
throw err
|
|
71
|
+
})
|
|
72
|
+
this.pending.set(topic, promise)
|
|
73
|
+
return promise
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
/* perhaps still need to wait for a pending subscription */
|
|
77
|
+
const pending = this.pending.get(topic)
|
|
78
|
+
if (pending)
|
|
79
|
+
return pending.catch((err: Error) => {
|
|
80
|
+
const count = this.counts.get(topic)
|
|
81
|
+
if (count) {
|
|
82
|
+
if (count <= 1)
|
|
83
|
+
this.counts.delete(topic)
|
|
84
|
+
else
|
|
85
|
+
this.counts.set(topic, count - 1)
|
|
86
|
+
}
|
|
87
|
+
throw err
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* unsubscribe from a topic (reference-counted) */
|
|
93
|
+
async unsubscribe (topic: string): Promise<void> {
|
|
94
|
+
const count = this.counts.get(topic)
|
|
95
|
+
if (count) {
|
|
96
|
+
if (count <= 1) {
|
|
97
|
+
this.counts.delete(topic)
|
|
98
|
+
if (this.lingerMs > 0) {
|
|
99
|
+
/* defer the actual broker unsubscription */
|
|
100
|
+
const timer = setTimeout(() => {
|
|
101
|
+
this.lingers.delete(topic)
|
|
102
|
+
this.unsubscribeFn(topic).catch(() => {})
|
|
103
|
+
}, this.lingerMs)
|
|
104
|
+
this.lingers.set(topic, timer)
|
|
105
|
+
}
|
|
106
|
+
else
|
|
107
|
+
await this.unsubscribeFn(topic).catch(() => {})
|
|
108
|
+
}
|
|
109
|
+
else
|
|
110
|
+
this.counts.set(topic, count - 1)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/* flush all pending linger timers and unsubscribe */
|
|
115
|
+
async flush (): Promise<void> {
|
|
116
|
+
/* cancel all pending linger timers first (synchronously) */
|
|
117
|
+
const topics = [ ...this.lingers.keys() ]
|
|
118
|
+
for (const topic of topics) {
|
|
119
|
+
clearTimeout(this.lingers.get(topic))
|
|
120
|
+
this.lingers.delete(topic)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* then unsubscribe from all lingered topics */
|
|
124
|
+
for (const topic of topics)
|
|
125
|
+
await this.unsubscribeFn(topic).catch(() => {})
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/* Subscription trait with shared MQTT subscription management */
|
|
130
|
+
export class SubscriptionTrait<T extends APISchema = APISchema> extends BaseTrait<T> {
|
|
131
|
+
protected subscriptions = new RefCountedSubscription(
|
|
132
|
+
(topic, options) => this._subscribeTopic(topic, options),
|
|
133
|
+
(topic) => this._unsubscribeTopic(topic)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
/* destroy topic trait */
|
|
137
|
+
override async destroy () {
|
|
138
|
+
await this.subscriptions.flush()
|
|
139
|
+
await super.destroy()
|
|
140
|
+
}
|
|
141
|
+
}
|