mqtt-plus 1.4.22 → 1.4.24

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 (39) hide show
  1. package/.ase/service.log +10402 -0
  2. package/.ase/task/TASK-P18.md +47 -0
  3. package/.ase/task/TASK-P2.md +76 -0
  4. package/.ase/task/TASK-P21.md +75 -0
  5. package/.claude/settings.local.json +7 -0
  6. package/CHANGELOG.md +21 -1
  7. package/README.md +4 -4
  8. package/dst-stage1/mqtt-plus-api.d.ts +6 -6
  9. package/dst-stage1/mqtt-plus-base.js +34 -4
  10. package/dst-stage1/mqtt-plus-codec.js +15 -6
  11. package/dst-stage1/mqtt-plus-error.js +47 -14
  12. package/dst-stage1/mqtt-plus-event.js +1 -1
  13. package/dst-stage1/mqtt-plus-options.js +1 -3
  14. package/dst-stage1/mqtt-plus-service.js +1 -1
  15. package/dst-stage1/mqtt-plus-sink.js +10 -2
  16. package/dst-stage1/mqtt-plus-source.d.ts +1 -0
  17. package/dst-stage1/mqtt-plus-source.js +11 -3
  18. package/dst-stage1/mqtt-plus-subscription.js +7 -2
  19. package/dst-stage1/mqtt-plus-timer.js +4 -2
  20. package/dst-stage2/mqtt-plus.cjs.cjs +94 -36
  21. package/dst-stage2/mqtt-plus.esm.js +94 -36
  22. package/dst-stage2/mqtt-plus.umd.js +11 -11
  23. package/package.json +1 -1
  24. package/src/mqtt-plus-api.ts +5 -5
  25. package/src/mqtt-plus-base.ts +35 -4
  26. package/src/mqtt-plus-codec.ts +15 -8
  27. package/src/mqtt-plus-error.ts +52 -14
  28. package/src/mqtt-plus-event.ts +1 -1
  29. package/src/mqtt-plus-options.ts +1 -4
  30. package/src/mqtt-plus-service.ts +1 -1
  31. package/src/mqtt-plus-sink.ts +12 -2
  32. package/src/mqtt-plus-source.ts +13 -3
  33. package/src/mqtt-plus-subscription.ts +7 -2
  34. package/src/mqtt-plus-timer.ts +4 -2
  35. package/tst/mqtt-plus-4-sink.spec.ts +43 -0
  36. package/tst/mqtt-plus-5-source.spec.ts +35 -0
  37. package/tst/mqtt-plus-6-misc.spec.ts +82 -5
  38. package/tst/mqtt-plus-8-run.spec.ts +107 -0
  39. package/.ase/service.yaml +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mqtt-plus",
3
- "version": "1.4.22",
3
+ "version": "1.4.24",
4
4
  "description": "MQTT Communication Patterns",
5
5
  "keywords": [ "mqtt",
6
6
  "event", "emit",
@@ -23,7 +23,7 @@
23
23
  */
24
24
 
25
25
  /* utility type for branding */
26
- type Brand<T> = T & { readonly __brand: unique symbol }
26
+ type Brand<T, K extends string> = T & { readonly __brand: K }
27
27
 
28
28
  /* endpoint types */
29
29
  export type APIEndpoint = APIEndpointEvent | APIEndpointService | APIEndpointSource | APIEndpointSink
@@ -33,10 +33,10 @@ export type APIEndpointSource = (...args: any[]) => void | Promise<void>
33
33
  export type APIEndpointSink = (...args: any[]) => void | Promise<void>
34
34
 
35
35
  /* API marker types */
36
- export type Event<T extends APIEndpointEvent> = Brand<T>
37
- export type Service<T extends APIEndpointService> = Brand<T>
38
- export type Source<T extends APIEndpointSource> = Brand<T>
39
- export type Sink<T extends APIEndpointSink> = Brand<T>
36
+ export type Event<T extends APIEndpointEvent> = Brand<T, "event">
37
+ export type Service<T extends APIEndpointService> = Brand<T, "service">
38
+ export type Source<T extends APIEndpointSource> = Brand<T, "source">
39
+ export type Sink<T extends APIEndpointSink> = Brand<T, "sink">
40
40
 
41
41
  /* type utilities for generic API */
42
42
  export type APISchema = Record<string, APIEndpoint>
@@ -28,6 +28,7 @@ import { type MqttClient,
28
28
  type IClientSubscribeOptions,
29
29
  type IClientPublishOptions,
30
30
  type IPublishPacket } from "mqtt"
31
+ import { nanoid } from "nanoid"
31
32
 
32
33
  /* internal requirements */
33
34
  import type { APISchema, Registration } from "./mqtt-plus-api"
@@ -79,6 +80,19 @@ export class BaseTrait<T extends APISchema = APISchema> extends TraceTrait<T> {
79
80
  /* store MQTT client */
80
81
  this.mqtt = mqtt
81
82
 
83
+ /* resolve the instance "id": if the user did not provide an
84
+ explicit one, fetch the "clientId" from the underlying MQTT
85
+ client, and only as a last resort fall back to a generated id
86
+ (e.g. for the fake proxy MQTT client without a real clientId) */
87
+ if (this.options.id === "") {
88
+ const clientId = (mqtt as any).isFakeProxy
89
+ ? undefined
90
+ : mqtt.options?.clientId
91
+ this.options.id = (typeof clientId === "string" && clientId !== "")
92
+ ? clientId
93
+ : nanoid()
94
+ }
95
+
82
96
  /* hook into the MQTT message processing */
83
97
  this.log("info", "hooking into MQTT client")
84
98
  this.messageHandler = (topic, message, packet) => {
@@ -96,8 +110,10 @@ export class BaseTrait<T extends APISchema = APISchema> extends TraceTrait<T> {
96
110
  input = Buffer.isBuffer(message)
97
111
  ? new Uint8Array(message.buffer, message.byteOffset, message.byteLength)
98
112
  : message
99
- else
100
- throw new Error("invalid codec configured")
113
+ else {
114
+ this.error(new Error("invalid codec configured"))
115
+ return
116
+ }
101
117
  this._onMessage(topic, input, packet)
102
118
  }
103
119
  this.mqtt.on("message", this.messageHandler)
@@ -133,11 +149,19 @@ export class BaseTrait<T extends APISchema = APISchema> extends TraceTrait<T> {
133
149
  protected async subscribeTopic (topic: string, options: Partial<IClientSubscribeOptions> = {}): Promise<void> {
134
150
  this.log("info", `subscribing to MQTT topic "${topic}"`)
135
151
  return new Promise<void>((resolve, reject) => {
136
- this.mqtt.subscribe(topic, { qos: 2, ...options }, (err: Error | null, _granted: any) => {
152
+ this.mqtt.subscribe(topic, { qos: 2, ...options }, (err: Error | null, granted: any) => {
137
153
  if (err) {
138
154
  this.error(err, `subscribing to MQTT topic "${topic}" failed`)
139
155
  reject(err)
140
156
  }
157
+ else if (Array.isArray(granted)
158
+ && granted.some((grant: any) => typeof grant?.qos === "number" && grant.qos >= 0x80)) {
159
+ /* the broker rejected the subscription via SUBACK grant/reason code */
160
+ const error = new Error(`broker rejected subscription to MQTT topic "${topic}" ` +
161
+ `(grant codes: ${granted.map((grant: any) => grant?.qos).join(", ")})`)
162
+ this.error(error, `subscribing to MQTT topic "${topic}" failed`)
163
+ reject(error)
164
+ }
141
165
  else
142
166
  resolve()
143
167
  })
@@ -208,7 +232,14 @@ export class BaseTrait<T extends APISchema = APISchema> extends TraceTrait<T> {
208
232
  return
209
233
 
210
234
  /* parse MQTT topic */
211
- const topicMatch = this.options.topicMatch(topic)
235
+ let topicMatch
236
+ try {
237
+ topicMatch = this.options.topicMatch(topic)
238
+ }
239
+ catch (err: unknown) {
240
+ this.error(ensureError(err, "failed to match MQTT topic"))
241
+ return
242
+ }
212
243
  if (topicMatch === null)
213
244
  return
214
245
 
@@ -39,17 +39,24 @@ export class JSONX {
39
39
  return new Uint8Array(Buffer.from(base64, "base64"))
40
40
  }
41
41
  static stringify (obj: unknown): string {
42
- return JSON.stringify(obj, (_, value) =>
43
- value instanceof Uint8Array
44
- ? { __Uint8Array: this.uint8ArrayToBase64(value) }
45
- : value
46
- )
42
+ return JSON.stringify(obj, function (key, value) {
43
+ /* re-read the original value from the holder, as JSON.stringify
44
+ applies Buffer.prototype.toJSON before calling the replacer */
45
+ const orig: unknown = this[key]
46
+ return orig instanceof Buffer
47
+ ? { __Buffer: JSONX.uint8ArrayToBase64(orig) }
48
+ : orig instanceof Uint8Array
49
+ ? { __Uint8Array: JSONX.uint8ArrayToBase64(orig) }
50
+ : value
51
+ })
47
52
  }
48
53
  static parse (json: string): unknown {
49
54
  return JSON.parse(json, (_, value) =>
50
- typeof value?.__Uint8Array === "string"
51
- ? this.base64ToUint8Array(value.__Uint8Array)
52
- : value
55
+ typeof value?.__Buffer === "string"
56
+ ? Buffer.from(value.__Buffer, "base64")
57
+ : typeof value?.__Uint8Array === "string"
58
+ ? this.base64ToUint8Array(value.__Uint8Array)
59
+ : value
53
60
  )
54
61
  }
55
62
  }
@@ -201,6 +201,43 @@ function runUnroll (isAsync: boolean, spool?: Spool): Promise<void> | void {
201
201
  return result
202
202
  }
203
203
 
204
+ /* helper function for settling the epilog of "run": run the finally
205
+ code isolated, unroll the spool, and preserve a pending error */
206
+ function runSettle (isAsync: false, pending: boolean, spool?: Spool, onfinally?: () => void, description?: string): void
207
+ function runSettle (isAsync: true, pending: boolean, spool?: Spool, onfinally?: () => Promise<void> | void, description?: string): Promise<void>
208
+ function runSettle (isAsync: boolean, pending: boolean, spool?: Spool, onfinally?: () => Promise<void> | void, description?: string): Promise<void> | void {
209
+ if (!isAsync) {
210
+ let failure: Error | undefined
211
+ try {
212
+ runFinally(false, onfinally as (() => void) | undefined, description)
213
+ }
214
+ catch (arg: unknown) {
215
+ failure = ensureError(arg, description)
216
+ }
217
+ if (pending || failure !== undefined) {
218
+ runUnroll(false, spool)
219
+ if (!pending && failure !== undefined)
220
+ throw failure
221
+ }
222
+ }
223
+ else {
224
+ return (async () => {
225
+ let failure: Error | undefined
226
+ try {
227
+ await runFinally(true, onfinally, description)
228
+ }
229
+ catch (arg: unknown) {
230
+ failure = ensureError(arg, description)
231
+ }
232
+ if (pending || failure !== undefined) {
233
+ await runUnroll(true, spool)
234
+ if (!pending && failure !== undefined)
235
+ throw failure
236
+ }
237
+ })()
238
+ }
239
+ }
240
+
204
241
  /* helper type for ensuring T contains no Promise */
205
242
  type RunNoPromise<T> =
206
243
  [ T ] extends [ Promise<any> ] ? never : T
@@ -350,50 +387,51 @@ export function run<T> (
350
387
  }
351
388
  catch (arg: unknown) {
352
389
  error = ensureError(arg, description)
353
- runFinally(false, onfinally, description)
354
- runUnroll(false, spool)
390
+ runSettle(false, true, spool, onfinally, description)
355
391
  throw error
356
392
  }
357
- runFinally(false, onfinally, description)
393
+ if (spool && oncleanup)
394
+ spool.roll(result, oncleanup as SpoolCleanup<unknown>)
395
+ runSettle(false, false, spool, onfinally, description)
358
396
  return result
359
397
  }
360
- runFinally(false, onfinally, description)
361
- runUnroll(false, spool)
398
+ runSettle(false, true, spool, onfinally, description)
362
399
  throw error
363
400
  }
364
401
  if (result instanceof Promise) {
365
402
  /* asynchronous case (result or error branch) */
366
403
  return result.then(async (result) => {
367
- await runFinally(true, onfinally, description)
368
404
  if (spool && oncleanup)
369
405
  spool.roll(result, oncleanup as SpoolCleanup<unknown>)
406
+ await runSettle(true, false, spool, onfinally, description)
370
407
  return result
371
408
  }, async (arg: unknown) => {
372
409
  /* asynchronous case (error branch) */
373
410
  let error = ensureError(arg, description)
374
411
  if (oncatch) {
412
+ let result: T
375
413
  try {
376
- const result = await oncatch(error)
377
- await runFinally(true, onfinally, description)
378
- return result
414
+ result = await oncatch(error)
379
415
  }
380
416
  catch (arg: unknown) {
381
417
  error = ensureError(arg, description)
382
- await runFinally(true, onfinally, description)
383
- await runUnroll(true, spool)
418
+ await runSettle(true, true, spool, onfinally, description)
384
419
  throw error
385
420
  }
421
+ if (spool && oncleanup)
422
+ spool.roll(result, oncleanup as SpoolCleanup<unknown>)
423
+ await runSettle(true, false, spool, onfinally, description)
424
+ return result
386
425
  }
387
- await runFinally(true, onfinally, description)
388
- await runUnroll(true, spool)
426
+ await runSettle(true, true, spool, onfinally, description)
389
427
  throw error
390
428
  })
391
429
  }
392
430
  else {
393
431
  /* synchronous case (result branch) */
394
- runFinally(false, onfinally, description)
395
432
  if (spool && oncleanup)
396
433
  spool.roll(result, oncleanup as SpoolCleanup<unknown>)
434
+ runSettle(false, false, spool, onfinally, description)
397
435
  return result
398
436
  }
399
437
  }
@@ -168,7 +168,7 @@ export class EventTrait<T extends APISchema = APISchema> extends AuthTrait<T> {
168
168
 
169
169
  /* asynchronously execute handler */
170
170
  try {
171
- if (auth)
171
+ if (auth !== undefined)
172
172
  info.authenticated = await this.authenticated(senderId, request.auth, auth, `event "${name}"`)
173
173
  await callback(...params, info)
174
174
  }
@@ -22,9 +22,6 @@
22
22
  ** SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
23
23
  */
24
24
 
25
- /* external requirements */
26
- import { nanoid } from "nanoid"
27
-
28
25
  /* internal requirements */
29
26
  import type { APISchema } from "./mqtt-plus-api"
30
27
 
@@ -55,7 +52,7 @@ export class OptionsTrait<_T extends APISchema = APISchema> {
55
52
  ) {
56
53
  /* determine options and provide defaults */
57
54
  this.options = {
58
- id: nanoid(),
55
+ id: "", /* intentionally resolved later in BaseTrait */
59
56
  codec: "cbor",
60
57
  timeout: 10 * 1000,
61
58
  share: "",
@@ -186,7 +186,7 @@ export class ServiceTrait<T extends APISchema = APISchema> extends EventTrait<T>
186
186
  /* execute handler and send response */
187
187
  let removeAbortListener: (() => void) | undefined
188
188
  try {
189
- if (auth)
189
+ if (auth !== undefined)
190
190
  info.authenticated = await this.authenticated(senderId, request.auth, auth, `service "${name}"`)
191
191
  const abortPromise = new Promise<never>((_resolve, reject) => {
192
192
  const onAbort = () => {
@@ -229,7 +229,7 @@ export class SinkTrait<T extends APISchema = APISchema> extends SourceTrait<T> {
229
229
  if (topicName !== request.name)
230
230
  throw new Error(`sink name mismatch (topic: "${topicName}", payload: "${request.name}")`)
231
231
  let authenticated: boolean | undefined = undefined
232
- if (auth)
232
+ if (auth !== undefined)
233
233
  authenticated = await this.authenticated(sender, request.auth, auth, `sink "${name}"`)
234
234
 
235
235
  /* initialize credit-based flow control state */
@@ -247,7 +247,16 @@ export class SinkTrait<T extends APISchema = APISchema> extends SourceTrait<T> {
247
247
  const refreshPushTimeout = () => this.timerRefresh(pushTimerId, () => {
248
248
  if (streamEnded || this.destroying)
249
249
  return
250
- abortController.abort(new Error("push stream timeout"))
250
+ const error = new Error("push stream timeout")
251
+ abortController.abort(error)
252
+
253
+ /* destroy the push stream so that streamDone settles even when
254
+ the callback already completed before the timeout fired */
255
+ streamEnded = true
256
+ const stream = this.pushStreams.get(requestId)
257
+ if (stream !== undefined && !stream.destroyed)
258
+ stream.destroy(error)
259
+
251
260
  /* eagerly notify sender so it stops publishing chunks
252
261
  (suppress the spool-rollback cancel via errorResponseSent) */
253
262
  if (sender && !errorResponseSent) {
@@ -666,6 +675,7 @@ export class SinkTrait<T extends APISchema = APISchema> extends SourceTrait<T> {
666
675
  pushInitialResolve = resolve
667
676
  pushInitialReject = reject
668
677
  })
678
+ pushInitial.catch(() => {}) /* avoid unhandled promise rejection */
669
679
  this.onResponse.set(`sink-push-response:${requestId}`, (response: SinkPushResponse) => {
670
680
  if (response.name !== name) {
671
681
  const error = new Error(`sink response name mismatch (expected "${name}", got "${response.name}")`)
@@ -50,8 +50,14 @@ export class SourceTrait<T extends APISchema = APISchema> extends ServiceTrait<T
50
50
  private sourceSpools = new Map<string, Spool>()
51
51
  private sourceRequests = new Map<string, Set<string>>()
52
52
 
53
+ /* fetch state */
54
+ private fetchAborts = new Map<string, (error: Error) => void>()
55
+
53
56
  /* destroy source trait */
54
57
  override async destroy () {
58
+ for (const abort of this.fetchAborts.values())
59
+ abort(new Error("instance destroyed"))
60
+ this.fetchAborts.clear()
55
61
  for (const controller of this.sourceControllers.values())
56
62
  controller.abort(new Error("source destroyed"))
57
63
  for (const gate of this.sourceCreditGates.values())
@@ -143,8 +149,6 @@ export class SourceTrait<T extends APISchema = APISchema> extends ServiceTrait<T
143
149
 
144
150
  /* create a resource spool for request cleanup */
145
151
  const reqSpool = new Spool()
146
- this.sourceSpools.set(requestId, reqSpool)
147
- reqSpool.roll(() => { this.sourceSpools.delete(requestId) })
148
152
 
149
153
  /* sanity check sender */
150
154
  if (sender === undefined || sender === "") {
@@ -180,6 +184,8 @@ export class SourceTrait<T extends APISchema = APISchema> extends ServiceTrait<T
180
184
  }
181
185
  this.sourceControllers.set(requestId, abortController)
182
186
  reqSpool.roll(() => { this.sourceControllers.delete(requestId) })
187
+ this.sourceSpools.set(requestId, reqSpool)
188
+ reqSpool.roll(() => { this.sourceSpools.delete(requestId) })
183
189
 
184
190
  /* provide info object */
185
191
  const info: InfoSource = { sender, signal: abortSignal }
@@ -295,7 +301,7 @@ export class SourceTrait<T extends APISchema = APISchema> extends ServiceTrait<T
295
301
  })
296
302
 
297
303
  /* check for authentication */
298
- if (auth)
304
+ if (auth !== undefined)
299
305
  info.authenticated = await this.authenticated(sender, request.auth, auth, `source "${name}"`)
300
306
 
301
307
  /* finally call the handler callback */
@@ -566,6 +572,10 @@ export class SourceTrait<T extends APISchema = APISchema> extends ServiceTrait<T
566
572
  stream.destroy(error)
567
573
  }
568
574
 
575
+ /* register abort handler at instance level (for destroy) */
576
+ this.fetchAborts.set(requestId, endWithError)
577
+ spool.roll(() => { this.fetchAborts.delete(requestId) })
578
+
569
579
  /* ensure resources are released if consumer aborts stream early */
570
580
  let cancelled = false
571
581
  const cancelAndUnroll = (reason?: unknown) => {
@@ -127,11 +127,16 @@ class RefCountedSubscription {
127
127
  }
128
128
  }
129
129
 
130
- /* unsubscribe from a topic (reference-counted) */
130
+ /* unsubscribe from a topic (reference-counted)
131
+ NOTICE: shared subscriptions ("$share/...") are never lingered, as a
132
+ lingering group member would still receive its round-robin share of
133
+ the group's messages and silently drop them, and would additionally
134
+ cause duplicate deliveries once an overlapping non-shared
135
+ subscription is established on the same connection */
131
136
  async unsubscribe (topic: string): Promise<void> {
132
137
  const count = this.decrementCount(topic)
133
138
  if (count === 0) {
134
- if (this.lingerMs > 0) {
139
+ if (this.lingerMs > 0 && !topic.startsWith("$share/")) {
135
140
  /* defer the actual broker unsubscription */
136
141
  const timer = setTimeout(() => {
137
142
  this.lingers.delete(topic)
@@ -82,7 +82,8 @@ export class TimerTrait<T extends APISchema = APISchema> extends SubscriptionTra
82
82
  ac.abort()
83
83
  resolve()
84
84
  }, durationMs)
85
- timer.unref()
85
+ if (typeof timer.unref === "function")
86
+ timer.unref()
86
87
  if (signal !== undefined) {
87
88
  if (ac !== undefined)
88
89
  signal.addEventListener("abort", () => {
@@ -109,7 +110,8 @@ export class TimerTrait<T extends APISchema = APISchema> extends SubscriptionTra
109
110
  ac.abort()
110
111
  reject(new Error(info))
111
112
  }, durationMs)
112
- timer.unref()
113
+ if (typeof timer.unref === "function")
114
+ timer.unref()
113
115
  if (signal !== undefined) {
114
116
  if (ac !== undefined)
115
117
  signal.addEventListener("abort", () => {
@@ -319,5 +319,48 @@ describe("MQTT+ Sink Push", function () {
319
319
  /* cleanup */
320
320
  await sinking.destroy()
321
321
  })
322
+
323
+ /* test case: Sink Push (Stalled After Early Callback) */
324
+ it("MQTT+ Sink Push (Stalled After Early Callback)", async function () {
325
+ /* setup */
326
+ this.slow(4000)
327
+ this.timeout(4000)
328
+ const spy = sinon.spy()
329
+
330
+ /* establish sink whose callback resolves immediately, ignoring stream/buffer */
331
+ const sinking = await ctx.apiS.sink("example/server/upload", (name: string, _info) => {
332
+ spy("sink")
333
+ expect(name).to.be.equal("foo")
334
+ /* intentionally do not consume info.stream or info.buffer */
335
+ })
336
+
337
+ /* create a readable that emits one chunk and then stalls forever */
338
+ const chunkSize = 16 * 1024
339
+ let emitted = false
340
+ const readable = new stream.Readable({
341
+ read () {
342
+ if (!emitted) {
343
+ emitted = true
344
+ this.push(Buffer.from(crypto.randomBytes(chunkSize)))
345
+ }
346
+ /* never push more and never end -- simulate a stalled sender */
347
+ }
348
+ })
349
+
350
+ /* the push must fail (via receiver push timeout) instead of hanging */
351
+ await ctx.apiC.push("example/server/upload", readable, "foo").then(() => {
352
+ spy("push-success")
353
+ }).catch(() => {
354
+ spy("push-error")
355
+ })
356
+
357
+ /* wait for receiver-side cleanup to settle */
358
+ await new Promise((resolve) => { setTimeout(resolve, 200) })
359
+ expect(spy.getCalls().map((call) => call.firstArg))
360
+ .to.be.same.deep.members([ "sink", "push-error" ])
361
+
362
+ /* cleanup */
363
+ await sinking.destroy()
364
+ })
322
365
  })
323
366
 
@@ -31,8 +31,13 @@ import { Buffer } from "node:buffer"
31
31
  import { describe, it } from "mocha"
32
32
  import * as chai from "chai"
33
33
 
34
+ /* external dependencies (application) */
35
+ import MQTT from "mqtt"
36
+
34
37
  /* internal dependencies */
38
+ import MQTTp from "mqtt-plus"
35
39
  import { ctx } from "./mqtt-plus-0-fixture"
40
+ import type { API } from "./mqtt-plus-0-fixture"
36
41
 
37
42
  /* setup test suite infrastructure */
38
43
  chai.config.includeStack = true
@@ -263,5 +268,35 @@ describe("MQTT+ Source Fetch", function () {
263
268
  /* cleanup */
264
269
  await sourcing.destroy()
265
270
  })
271
+
272
+ /* test case: Source Fetch (Destroy In-Flight) */
273
+ it("MQTT+ Source Fetch (Destroy In-Flight)", async function () {
274
+ /* setup */
275
+ this.slow(2000)
276
+ this.timeout(2000)
277
+
278
+ /* connect to broker as a dedicated client with a long communication timeout */
279
+ const mqttClient = MQTT.connect("mqtt://127.0.0.1:1883")
280
+ await new Promise<void>((resolve, reject) => {
281
+ mqttClient.once("connect", () => { resolve() })
282
+ mqttClient.once("error", (err: Error) => { reject(err) })
283
+ })
284
+ const apiClient = new MQTTp<API>(mqttClient, { timeout: 60 * 1000 })
285
+
286
+ /* start fetching a source nobody responds to */
287
+ const result = await apiClient.fetch("example/server/download", "foo")
288
+
289
+ /* destroy the instance while the fetch is still in-flight */
290
+ await apiClient.destroy()
291
+
292
+ /* expect meta and buffer to settle well below the communication timeout */
293
+ const errorMeta = await result.meta.catch((err: Error) => err.message)
294
+ expect(errorMeta).to.be.equal("instance destroyed")
295
+ const errorBuffer = await result.buffer.catch((err: Error) => err.message)
296
+ expect(errorBuffer).to.be.equal("instance destroyed")
297
+
298
+ /* cleanup */
299
+ await mqttClient.endAsync()
300
+ })
266
301
  })
267
302
 
@@ -36,6 +36,7 @@ import MQTT from "mqtt"
36
36
 
37
37
  /* internal dependencies */
38
38
  import MQTTp from "mqtt-plus"
39
+ import type { Event } from "mqtt-plus"
39
40
  import { ctx } from "./mqtt-plus-0-fixture"
40
41
  import type { API } from "./mqtt-plus-0-fixture"
41
42
  import { makeMutuallyExclusiveFields } from "../src/mqtt-plus-util"
@@ -113,31 +114,76 @@ describe("MQTT+ Miscellaneous", function () {
113
114
  /* setup */
114
115
  this.slow(2000)
115
116
  this.timeout(2000)
116
- const spy = sinon.spy()
117
+ const spy = sinon.spy()
118
+ const spyB = sinon.spy()
117
119
 
118
120
  /* create JSON codec API instances on existing MQTT connections */
119
- const apiJsonS = new MQTTp<API>(ctx.mqttS, { id: "json-server", codec: "json", timeout: 500 })
120
- const apiJsonC = new MQTTp<API>(ctx.mqttC, { id: "json-client", codec: "json", timeout: 500 })
121
+ type APIX = API & { "example/server/binary": Event<(data: Buffer) => void> }
122
+ const apiJsonS = new MQTTp<APIX>(ctx.mqttS, { id: "json-server", codec: "json", timeout: 500 })
123
+ const apiJsonC = new MQTTp<APIX>(ctx.mqttC, { id: "json-client", codec: "json", timeout: 500 })
121
124
 
122
- /* register event handler */
125
+ /* register event handlers */
123
126
  const registration = await apiJsonS.event("example/server/sample", (str: string, num: number) => {
124
127
  spy("event", str, num)
125
128
  })
129
+ const registrationB = await apiJsonS.event("example/server/binary", (data: Buffer) => {
130
+ spyB(data)
131
+ })
126
132
 
127
- /* emit event via JSON codec */
133
+ /* emit events via JSON codec */
128
134
  apiJsonC.emit("example/server/sample", "hello", 99)
135
+ apiJsonC.emit("example/server/binary", Buffer.from([ 0x01, 0x02, 0x03, 0xff ]))
129
136
  await new Promise((resolve) => { setTimeout(resolve, 100) })
130
137
 
131
138
  /* verify round-trip */
132
139
  expect(spy.getCalls().length).to.equal(1)
133
140
  expect(spy.getCalls()[0].args).to.deep.equal([ "event", "hello", 99 ])
134
141
 
142
+ /* verify Buffer round-trip */
143
+ expect(spyB.getCalls().length).to.equal(1)
144
+ const data: Buffer = spyB.getCalls()[0].args[0]
145
+ expect(data).to.be.instanceOf(Buffer)
146
+ expect(data.equals(Buffer.from([ 0x01, 0x02, 0x03, 0xff ]))).to.equal(true)
147
+
135
148
  /* cleanup */
136
149
  await registration.destroy()
150
+ await registrationB.destroy()
137
151
  apiJsonS.destroy()
138
152
  apiJsonC.destroy()
139
153
  })
140
154
 
155
+ /* test case: Error Event on throwing topicMatch */
156
+ it("MQTT+ Error Event on Throwing topicMatch", async function () {
157
+ /* setup */
158
+ this.slow(2000)
159
+ this.timeout(2000)
160
+ const spy = sinon.spy()
161
+
162
+ /* create API instances with a throwing user-supplied topicMatch on the receiver side */
163
+ const apiThrowS = new MQTTp<API>(ctx.mqttS, { id: "throw-server", timeout: 500,
164
+ topicMatch: () => { throw new Error("intentionally failing topicMatch") } })
165
+ const apiThrowC = new MQTTp<API>(ctx.mqttC, { id: "throw-client", timeout: 500 })
166
+
167
+ /* observe MQTT+ error events */
168
+ apiThrowS.on("error", (err: Error) => { spy(err.message) })
169
+
170
+ /* register event handler to subscribe the topic */
171
+ const registration = await apiThrowS.event("example/server/sample", () => {})
172
+
173
+ /* emit event to trigger the inbound message processing */
174
+ apiThrowC.emit("example/server/sample", "hello", 42)
175
+ await new Promise((resolve) => { setTimeout(resolve, 100) })
176
+
177
+ /* ensure the exception surfaced as MQTT+ "error" event */
178
+ expect(spy.getCalls().length).to.be.at.least(1)
179
+ expect(spy.getCalls()[0].args[0]).to.match(/failed to match MQTT topic/)
180
+
181
+ /* cleanup */
182
+ await registration.destroy()
183
+ apiThrowS.destroy()
184
+ apiThrowC.destroy()
185
+ })
186
+
141
187
  /* test case: Authentication */
142
188
  it("MQTT+ Authentication", async function () {
143
189
  /* setup */
@@ -222,6 +268,37 @@ describe("MQTT+ Miscellaneous", function () {
222
268
  await registration2.destroy()
223
269
  })
224
270
 
271
+ /* test case: Authentication with Empty Role */
272
+ it("MQTT+ Authentication with Empty Role", async function () {
273
+ /* setup */
274
+ this.slow(2000)
275
+ this.timeout(2000)
276
+ const spy = sinon.spy()
277
+
278
+ /* server-side: provide hello service with falsy-but-valid empty role */
279
+ const registration = await ctx.apiS.service({
280
+ name: "example/server/hello",
281
+ auth: "",
282
+ callback: (str: string, num: number) => {
283
+ spy("hello")
284
+ return `${str}:${num}`
285
+ }
286
+ })
287
+
288
+ /* call service and expect fail-closed rejection */
289
+ await ctx.apiC.call("example/server/hello", "world", 42).then(async (_result) => {
290
+ spy("call-success")
291
+ }).catch((err: Error) => {
292
+ spy("call-error")
293
+ expect(err.message).to.match(/failed authentication/)
294
+ })
295
+ expect(spy.getCalls().map((call) => call.firstArg))
296
+ .to.be.deep.equal([ "call-error" ])
297
+
298
+ /* cleanup */
299
+ await registration.destroy()
300
+ })
301
+
225
302
  /* test case: Unit: arr2buf/buf2arr */
226
303
  it("MQTT+ Unit: arr2buf/buf2arr", function () {
227
304
  /* create a dry-run MQTTp instance for accessing encode methods */