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
@@ -31,7 +31,7 @@ import type { IClientPublishOptions,
31
31
  import { nanoid } from "nanoid"
32
32
 
33
33
  /* internal requirements */
34
- import { CreditGate, RefCountedSubscription,
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 sinks = new Map<string, (request: SinkPushRequest, topicName: string) => void>()
49
- private pushStreams = new Map<string, Readable>()
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.sinks.has(name))
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.sinks.set(name, (request: SinkPushRequest, topicName: string) => {
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._refreshPushTimer(requestId)
199
- const clearPushTimeout = () => this._clearPushTimer(requestId)
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.pushChunkCallbacks.set(requestId, (chunkParsed: SinkPushChunk, chunkTopicName: string) => {
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.pushChunkCallbacks.delete(requestId) })
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.sinks.delete(name) })
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.sinks.has(name))
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.pushSubscriptions.subscribe(responseTopic, { qos: options.qos ?? 2 }))
364
- spool.roll(() => this.pushSubscriptions.unsubscribe(responseTopic))
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.pushResponseCallbacks.set(requestId, (response: SinkPushResponse) => {
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.pushResponseCallbacks.delete(requestId) })
414
- this.pushCreditCallbacks.set(requestId, (_response: SinkPushCredit) => {
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.pushCreditCallbacks.delete(requestId) })
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.pushResponseCallbacks.set(requestId, (response: SinkPushResponse) => {
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.pushSubscriptions.subscribe(creditTopic, { qos: 2 }))
447
- spool.roll(() => this.pushSubscriptions.unsubscribe(creditTopic))
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.pushCreditCallbacks.set(requestId, (response: SinkPushCredit) => {
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 (chunk: Uint8Array | undefined, error: string | undefined, final: boolean): Promise<void> => {
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
  }
@@ -31,7 +31,7 @@ import type { IClientPublishOptions,
31
31
  import { nanoid } from "nanoid"
32
32
 
33
33
  /* internal requirements */
34
- import { CreditGate, RefCountedSubscription,
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 sources = new Map<string, (request: SourceFetchRequest, topicName: string) => void>()
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.sources.has(name))
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.sources.set(name, (request: SourceFetchRequest, topicName: string) => {
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._refreshSourceTimer(requestId)
175
- const clearSourceTimeout = () => this._clearSourceTimer(requestId)
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 (chunk: Uint8Array | undefined, error: string | undefined, final: boolean): Promise<void> => {
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.sourceCreditCallbacks.set(requestId, (creditParsed: SourceFetchCredit) => {
164
+ this.onResponse.set(`source-fetch-credit:${requestId}`, (creditParsed: SourceFetchCredit) => {
194
165
  creditGate.replenish(creditParsed.credit)
195
- this._refreshSourceTimer(requestId)
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.sourceCreditCallbacks.delete(requestId)
213
+ this.onResponse.delete(`source-fetch-credit:${requestId}`)
243
214
  })
244
215
  })
245
- spool.roll(() => { this.sources.delete(name) })
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.sources.has(name))
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.fetchSubscriptions.subscribe(responseTopic, { qos: options.qos ?? 2 }))
337
- spool.roll(() => this.fetchSubscriptions.unsubscribe(responseTopic))
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.fetchSubscriptions.subscribe(chunkTopic, { qos: options.qos ?? 2 }))
340
- spool.roll(() => this.fetchSubscriptions.unsubscribe(chunkTopic))
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.fetchChunkCallbacks.has(requestId))
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.fetchResponseCallbacks.set(requestId, (response: SourceFetchResponse) => {
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.fetchChunkCallbacks.set(requestId, (response: SourceFetchChunk) => {
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.fetchResponseCallbacks.delete(requestId)
442
- this.fetchChunkCallbacks.delete(requestId)
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
+ }