mqtt-plus 1.4.24 → 1.4.26

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 (67) hide show
  1. package/.ase/config.yaml +1 -0
  2. package/.ase/service.log +39 -0
  3. package/.ase/service.yaml +1 -0
  4. package/.ase/task/TASK-P18.md +1 -1
  5. package/.ase/task/TASK-P21.md +1 -1
  6. package/.claude/settings.local.json +4 -1
  7. package/AGENTS.md +1 -1
  8. package/CHANGELOG.md +29 -5
  9. package/README.md +2 -2
  10. package/doc/mqtt-plus-broker-setup.md +1 -1
  11. package/doc/mqtt-plus-comm.md +3 -3
  12. package/doc/mqtt-plus-internals.md +6 -0
  13. package/dst-stage1/mqtt-plus-base.d.ts +2 -2
  14. package/dst-stage1/mqtt-plus-base.js +13 -10
  15. package/dst-stage1/mqtt-plus-error.js +54 -28
  16. package/dst-stage1/mqtt-plus-event.js +3 -1
  17. package/dst-stage1/mqtt-plus-options.js +7 -0
  18. package/dst-stage1/mqtt-plus-service.js +9 -6
  19. package/dst-stage1/mqtt-plus-sink.js +27 -54
  20. package/dst-stage1/mqtt-plus-source.js +13 -9
  21. package/dst-stage1/mqtt-plus-util.d.ts +1 -0
  22. package/dst-stage1/mqtt-plus-util.js +25 -4
  23. package/dst-stage1/mqtt-plus.d.ts +1 -1
  24. package/dst-stage1/mqtt-plus.js +2 -0
  25. package/dst-stage2/mqtt-plus.cjs.cjs +165 -124
  26. package/dst-stage2/mqtt-plus.esm.js +155 -123
  27. package/dst-stage2/mqtt-plus.umd.js +11 -11
  28. package/etc/stx.conf +3 -2
  29. package/package.d/@typescript-eslint+typescript-estree+8.66.0.patch +12 -0
  30. package/package.d/{vite+8.0.13.patch → vite+8.2.1.patch} +2 -2
  31. package/package.json +27 -28
  32. package/src/mqtt-plus-base.ts +15 -12
  33. package/src/mqtt-plus-error.ts +65 -33
  34. package/src/mqtt-plus-event.ts +2 -1
  35. package/src/mqtt-plus-options.ts +8 -0
  36. package/src/mqtt-plus-service.ts +9 -6
  37. package/src/mqtt-plus-sink.ts +28 -54
  38. package/src/mqtt-plus-source.ts +13 -9
  39. package/src/mqtt-plus-util.ts +27 -4
  40. package/src/mqtt-plus.ts +3 -1
  41. package/tst/mqtt-plus-0-fixture.ts +1 -0
  42. package/tst/mqtt-plus-2-event.spec.ts +25 -0
  43. package/tst/mqtt-plus-4-sink.spec.ts +23 -0
  44. package/tst/mqtt-plus-5-source.spec.ts +26 -0
  45. package/tst/mqtt-plus-6-misc.spec.ts +58 -0
  46. package/tst/mqtt-plus-8-run.spec.ts +60 -0
  47. package/dst-stage1/mqtt-plus-api.js.map +0 -1
  48. package/dst-stage1/mqtt-plus-auth.js.map +0 -1
  49. package/dst-stage1/mqtt-plus-base.js.map +0 -1
  50. package/dst-stage1/mqtt-plus-codec.js.map +0 -1
  51. package/dst-stage1/mqtt-plus-encode.js.map +0 -1
  52. package/dst-stage1/mqtt-plus-error.js.map +0 -1
  53. package/dst-stage1/mqtt-plus-event.js.map +0 -1
  54. package/dst-stage1/mqtt-plus-info.js.map +0 -1
  55. package/dst-stage1/mqtt-plus-meta.js.map +0 -1
  56. package/dst-stage1/mqtt-plus-msg.js.map +0 -1
  57. package/dst-stage1/mqtt-plus-options.js.map +0 -1
  58. package/dst-stage1/mqtt-plus-service.js.map +0 -1
  59. package/dst-stage1/mqtt-plus-sink.js.map +0 -1
  60. package/dst-stage1/mqtt-plus-source.js.map +0 -1
  61. package/dst-stage1/mqtt-plus-subscription.js.map +0 -1
  62. package/dst-stage1/mqtt-plus-timer.js.map +0 -1
  63. package/dst-stage1/mqtt-plus-trace.js.map +0 -1
  64. package/dst-stage1/mqtt-plus-util.js.map +0 -1
  65. package/dst-stage1/mqtt-plus-version.js.map +0 -1
  66. package/dst-stage1/mqtt-plus.js.map +0 -1
  67. package/package.d/@typescript-eslint+typescript-estree+8.59.3.patch +0 -12
package/etc/stx.conf CHANGED
@@ -24,10 +24,11 @@
24
24
 
25
25
  # make patches
26
26
  patch-make
27
- npm shrinkwrap && \
27
+ npm install --package-lock-only --save && \
28
+ cross-env npm_config_allow_remote=all \
28
29
  patch-package --patch-dir package.d \
29
30
  "vite" "@typescript-eslint/typescript-estree" && \
30
- shx rm -f npm-shrinkwrap.json
31
+ shx rm -f package-lock.json
31
32
 
32
33
  # (internal) apply patches
33
34
  patch-apply
@@ -0,0 +1,12 @@
1
+ diff --git a/node_modules/@typescript-eslint/typescript-estree/dist/parseSettings/warnAboutTSVersion.js b/node_modules/@typescript-eslint/typescript-estree/dist/parseSettings/warnAboutTSVersion.js
2
+ index 0961c74..b76d468 100644
3
+ --- a/node_modules/@typescript-eslint/typescript-estree/dist/parseSettings/warnAboutTSVersion.js
4
+ +++ b/node_modules/@typescript-eslint/typescript-estree/dist/parseSettings/warnAboutTSVersion.js
5
+ @@ -84,7 +84,6 @@ function handleUnsupportedTSVersion(parseSettings, behavior, passedLoggerFn) {
6
+ // See https://github.com/typescript-eslint/typescript-eslint/issues/7896
7
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
8
+ (typeof process === 'undefined' ? false : process.stdout?.isTTY)) {
9
+ - parseSettings.log(buildUnsupportedTSVersionMessage('warn'));
10
+ }
11
+ warnedAboutTSVersion = true;
12
+ }
@@ -1,8 +1,8 @@
1
1
  diff --git a/node_modules/vite/dist/node/chunks/node.js b/node_modules/vite/dist/node/chunks/node.js
2
- index 07bab15..3e9d0d0 100644
2
+ index 6aae7ee..0d52252 100644
3
3
  --- a/node_modules/vite/dist/node/chunks/node.js
4
4
  +++ b/node_modules/vite/dist/node/chunks/node.js
5
- @@ -34918,7 +34918,6 @@ async function runConfigHook(config, plugins, configEnv) {
5
+ @@ -37094,7 +37094,6 @@ async function runConfigHook(config, plugins, configEnv) {
6
6
  const res = await getHookHandler(hook).call(context, conf, configEnv);
7
7
  if (res && res !== conf) {
8
8
  if (hasBothRollupOptionsAndRolldownOptions(res)) context.warn(`Both \`rollupOptions\` and \`rolldownOptions\` were specified by ${JSON.stringify(p.name)} plugin. \`rollupOptions\` specified by that plugin will be ignored.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mqtt-plus",
3
- "version": "1.4.24",
3
+ "version": "1.4.26",
4
4
  "description": "MQTT Communication Patterns",
5
5
  "keywords": [ "mqtt",
6
6
  "event", "emit",
@@ -31,61 +31,60 @@
31
31
  },
32
32
  "devDependencies": {
33
33
  "patch-package": "8.0.1",
34
- "@rse/stx": "1.1.5",
34
+ "@rse/stx": "1.1.6",
35
35
  "shx": "0.4.0",
36
- "eslint": "10.3.0",
37
- "globals": "17.6.0",
36
+ "eslint": "10.8.0",
37
+ "globals": "17.9.0",
38
38
  "@eslint/js": "10.0.1",
39
- "@eslint/eslintrc": "3.3.5",
40
- "knip": "6.13.1",
39
+ "@eslint/eslintrc": "3.3.6",
40
+ "knip": "6.32.0",
41
41
  "jiti": "2.7.0",
42
- "tsx": "4.22.0",
43
- "mocha": "11.7.5",
42
+ "tsx": "4.23.9",
43
+ "mocha": "11.8.0",
44
44
  "chai": "6.2.2",
45
45
  "sinon-chai": "4.0.1",
46
- "sinon": "22.0.0",
47
- "c8": "11.0.0",
46
+ "sinon": "22.1.0",
47
+ "c8": "12.0.0",
48
48
  "source-map-support": "0.5.21",
49
49
  "typescript": "6.0.3",
50
- "typescript-eslint": "8.59.3",
51
- "@typescript-eslint/parser": "8.59.3",
52
- "mqtt": "5.15.1",
53
- "aedes": "1.0.2",
50
+ "typescript-eslint": "8.66.0",
51
+ "@typescript-eslint/parser": "8.66.0",
52
+ "mqtt": "5.15.2",
53
+ "aedes": "1.1.1",
54
54
  "mosquitto": "1.1.0",
55
55
  "textframe": "1.2.1",
56
- "vite": "8.0.13",
57
- "vite-plugin-node-polyfills": "0.26.0",
56
+ "vite": "8.2.1",
57
+ "vite-plugin-node-polyfills": "0.28.0",
58
58
  "@rollup/plugin-replace": "6.0.3",
59
59
  "@wroud/vite-plugin-tsc": "0.12.3",
60
60
  "@terrastruct/d2": "0.1.33",
61
61
  "chokidar-cli": "3.0.0",
62
- "chalk": "5.6.2",
62
+ "chalk": "6.0.0",
63
63
  "open-cli": "9.0.0",
64
64
  "cross-env": "10.1.0",
65
65
 
66
- "@types/node": "25.7.0",
66
+ "@types/node": "26.1.2",
67
67
  "@types/mocha": "10.0.10",
68
68
  "@types/chai": "5.2.3",
69
69
  "@types/sinon-chai": "4.0.0",
70
- "@types/sinon": "21.0.1"
70
+ "@types/sinon": "22.0.0"
71
71
  },
72
72
  "peerDependencies": {
73
73
  "mqtt": ">=4.0.0"
74
74
  },
75
75
  "overrides": {
76
- "vite-plugin-node-polyfills": { "vite": ">=7.0.0" },
77
- "typescript-eslint": { "typescript": ">=5.9.0" },
78
- "@typescript-eslint/parser": { "typescript": ">=5.9.0" },
79
- "@typescript-eslint/project-service": { "typescript": ">=5.9.0" },
80
- "@typescript-eslint/typescript-estree": { "typescript": ">=5.9.0" },
81
- "@typescript-eslint/tsconfig-utils": { "typescript": ">=5.9.0" }
76
+ "vite-plugin-node-polyfills": { "vite": ">=7.0.0" }
77
+ },
78
+ "allowScripts": {
79
+ "fsevents": true,
80
+ "esbuild": true
82
81
  },
83
82
  "dependencies": {
84
- "nanoid": "5.1.11",
83
+ "nanoid": "6.0.1",
85
84
  "cbor2": "2.3.0",
86
85
  "p-lazy": "5.0.0",
87
- "valibot": "1.4.0",
88
- "jose": "6.2.3",
86
+ "valibot": "1.4.2",
87
+ "jose": "6.2.8",
89
88
  "@stablelib/pbkdf2": "2.0.1",
90
89
  "@stablelib/sha256": "2.0.1"
91
90
  },
@@ -40,8 +40,8 @@ import { PLazy } from "./mqtt-plus-util"
40
40
 
41
41
  /* MQTTp Base class with shared infrastructure */
42
42
  export class BaseTrait<T extends APISchema = APISchema> extends TraceTrait<T> {
43
- private mqtt: MqttClient
44
- private messageHandler: OnMessageCallback
43
+ private _mqtt: MqttClient
44
+ private _messageHandler: OnMessageCallback
45
45
 
46
46
  /* lifecycle state */
47
47
  protected destroyed = false
@@ -78,7 +78,7 @@ export class BaseTrait<T extends APISchema = APISchema> extends TraceTrait<T> {
78
78
  }
79
79
 
80
80
  /* store MQTT client */
81
- this.mqtt = mqtt
81
+ this._mqtt = mqtt
82
82
 
83
83
  /* resolve the instance "id": if the user did not provide an
84
84
  explicit one, fetch the "clientId" from the underlying MQTT
@@ -95,7 +95,7 @@ export class BaseTrait<T extends APISchema = APISchema> extends TraceTrait<T> {
95
95
 
96
96
  /* hook into the MQTT message processing */
97
97
  this.log("info", "hooking into MQTT client")
98
- this.messageHandler = (topic, message, packet) => {
98
+ this._messageHandler = (topic, message, packet) => {
99
99
  if (this.destroyed)
100
100
  return
101
101
 
@@ -116,14 +116,14 @@ export class BaseTrait<T extends APISchema = APISchema> extends TraceTrait<T> {
116
116
  }
117
117
  this._onMessage(topic, input, packet)
118
118
  }
119
- this.mqtt.on("message", this.messageHandler)
119
+ this._mqtt.on("message", this._messageHandler)
120
120
  }
121
121
 
122
122
  /* destroy API class */
123
123
  async destroy () {
124
124
  this.destroyed = true
125
125
  this.log("info", "un-hooking from MQTT client")
126
- this.mqtt.off("message", this.messageHandler)
126
+ this._mqtt.off("message", this._messageHandler)
127
127
  this.onRequest.clear()
128
128
  this.onResponse.clear()
129
129
  }
@@ -149,7 +149,7 @@ export class BaseTrait<T extends APISchema = APISchema> extends TraceTrait<T> {
149
149
  protected async subscribeTopic (topic: string, options: Partial<IClientSubscribeOptions> = {}): Promise<void> {
150
150
  this.log("info", `subscribing to MQTT topic "${topic}"`)
151
151
  return new Promise<void>((resolve, reject) => {
152
- 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) => {
153
153
  if (err) {
154
154
  this.error(err, `subscribing to MQTT topic "${topic}" failed`)
155
155
  reject(err)
@@ -172,7 +172,7 @@ export class BaseTrait<T extends APISchema = APISchema> extends TraceTrait<T> {
172
172
  protected async unsubscribeTopic (topic: string): Promise<void> {
173
173
  this.log("info", `unsubscribing from MQTT topic "${topic}"`)
174
174
  return new Promise<void>((resolve, reject) => {
175
- this.mqtt.unsubscribe(topic, (err?: Error, _packet?: any) => {
175
+ this._mqtt.unsubscribe(topic, (err?: Error, _packet?: any) => {
176
176
  if (err) {
177
177
  this.error(err, `unsubscribing from MQTT topic "${topic}" failed`)
178
178
  reject(err)
@@ -215,7 +215,7 @@ export class BaseTrait<T extends APISchema = APISchema> extends TraceTrait<T> {
215
215
  const messageData = typeof message === "string"
216
216
  ? message
217
217
  : Buffer.from(message.buffer, message.byteOffset, message.byteLength)
218
- this.mqtt.publish(topic, messageData, options, (err?: Error) => {
218
+ this._mqtt.publish(topic, messageData, options, (err?: Error) => {
219
219
  if (err) {
220
220
  this.error(err, `publishing to MQTT topic "${topic}" failed`)
221
221
  reject(err)
@@ -268,12 +268,15 @@ export class BaseTrait<T extends APISchema = APISchema> extends TraceTrait<T> {
268
268
  }
269
269
  this.log("debug", `received from MQTT topic "${topic}"`, { message })
270
270
 
271
- /* warn on receiver mismatch on direct topics */
271
+ /* drop message on receiver mismatch on direct topics, as it is
272
+ addressed to a foreign peer and hence must not be processed here */
272
273
  if (topicMatch.peerId !== undefined
273
274
  && message.receiver !== undefined
274
- && message.receiver !== topicMatch.peerId)
275
- this.log("warning", `receiver mismatch on direct topic "${topic}"` +
275
+ && message.receiver !== topicMatch.peerId) {
276
+ this.log("warning", `receiver mismatch -- dropped message from direct topic "${topic}"` +
276
277
  ` (expected "${topicMatch.peerId}", got "${message.receiver}")`)
278
+ return
279
+ }
277
280
 
278
281
  /* dispatch MQTT+ message */
279
282
  if (this.msg.isRequest(message)) {
@@ -201,11 +201,15 @@ 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 {
204
+ /* helper type for an error carrying an attached, secondary error */
205
+ type ErrorSuppressing = Error & { suppressed?: Error }
206
+
207
+ /* helper function for settling the epilog of "run": run the finally code
208
+ isolated, unroll the spool, and preserve a pending error by attaching a
209
+ failing finally code to it as a suppressed error instead of discarding it */
210
+ function runSettle (isAsync: false, pending: ErrorSuppressing | undefined, spool?: Spool, onfinally?: () => void, description?: string): void
211
+ function runSettle (isAsync: true, pending: ErrorSuppressing | undefined, spool?: Spool, onfinally?: () => Promise<void> | void, description?: string): Promise<void>
212
+ function runSettle (isAsync: boolean, pending: ErrorSuppressing | undefined, spool?: Spool, onfinally?: () => Promise<void> | void, description?: string): Promise<void> | void {
209
213
  if (!isAsync) {
210
214
  let failure: Error | undefined
211
215
  try {
@@ -214,10 +218,14 @@ function runSettle (isAsync: boolean, pending: boolean, spool?: Spool, onfinally
214
218
  catch (arg: unknown) {
215
219
  failure = ensureError(arg, description)
216
220
  }
217
- if (pending || failure !== undefined) {
221
+ if (pending !== undefined || failure !== undefined) {
218
222
  runUnroll(false, spool)
219
- if (!pending && failure !== undefined)
220
- throw failure
223
+ if (failure !== undefined) {
224
+ if (pending !== undefined)
225
+ pending.suppressed = failure
226
+ else
227
+ throw failure
228
+ }
221
229
  }
222
230
  }
223
231
  else {
@@ -229,15 +237,41 @@ function runSettle (isAsync: boolean, pending: boolean, spool?: Spool, onfinally
229
237
  catch (arg: unknown) {
230
238
  failure = ensureError(arg, description)
231
239
  }
232
- if (pending || failure !== undefined) {
240
+ if (pending !== undefined || failure !== undefined) {
233
241
  await runUnroll(true, spool)
234
- if (!pending && failure !== undefined)
235
- throw failure
242
+ if (failure !== undefined) {
243
+ if (pending !== undefined)
244
+ pending.suppressed = failure
245
+ else
246
+ throw failure
247
+ }
236
248
  }
237
249
  })()
238
250
  }
239
251
  }
240
252
 
253
+ /* helper function for settling the epilog of "run" and then failing with
254
+ a pending error in a still unknown synchronous/asynchronous context:
255
+ operate synchronously as long as possible and continue asynchronously as
256
+ soon as a callback returns a Promise. As the pending error always takes
257
+ precedence, a failing finally code is attached to it as a suppressed error. */
258
+ function runFail (error: ErrorSuppressing, spool?: Spool, onfinally?: () => Promise<void> | void, description?: string): Promise<never> {
259
+ const attach = (arg: unknown) => { error.suppressed = ensureError(arg, description) }
260
+ let result: Promise<void> | void = undefined
261
+ try {
262
+ result = onfinally?.()
263
+ }
264
+ catch (arg: unknown) {
265
+ attach(arg)
266
+ }
267
+ if (result instanceof Promise)
268
+ return result.catch(attach).then(() => spool?.unroll()).then(() => { throw error })
269
+ const unrolled = spool?.unroll()
270
+ if (unrolled instanceof Promise)
271
+ return unrolled.then(() => { throw error })
272
+ throw error
273
+ }
274
+
241
275
  /* helper type for ensuring T contains no Promise */
242
276
  type RunNoPromise<T> =
243
277
  [ T ] extends [ Promise<any> ] ? never : T
@@ -379,31 +413,29 @@ export function run<T> (
379
413
  result = action()
380
414
  }
381
415
  catch (arg: unknown) {
382
- /* synchronous case (error branch) */
383
- let error = ensureError(arg, description)
384
- if (oncatch) {
385
- try {
386
- result = oncatch(error)
387
- }
388
- catch (arg: unknown) {
389
- error = ensureError(arg, description)
390
- runSettle(false, true, spool, onfinally, description)
391
- throw error
392
- }
393
- if (spool && oncleanup)
394
- spool.roll(result, oncleanup as SpoolCleanup<unknown>)
395
- runSettle(false, false, spool, onfinally, description)
396
- return result
416
+ /* synchronous case (error branch): as the action failed before
417
+ returning its potentially asynchronous result, the context is
418
+ still unknown here and hence has to be probed */
419
+ const error = ensureError(arg, description)
420
+ if (!oncatch)
421
+ return runFail(error, spool, onfinally, description)
422
+ try {
423
+ result = oncatch(error)
397
424
  }
398
- runSettle(false, true, spool, onfinally, description)
399
- throw error
425
+ catch (arg: unknown) {
426
+ return runFail(ensureError(arg, description), spool, onfinally, description)
427
+ }
428
+
429
+ /* the recovery already ran, so let the common epilog below settle
430
+ its potentially asynchronous result without re-entering it */
431
+ oncatch = undefined
400
432
  }
401
433
  if (result instanceof Promise) {
402
434
  /* asynchronous case (result or error branch) */
403
435
  return result.then(async (result) => {
404
436
  if (spool && oncleanup)
405
437
  spool.roll(result, oncleanup as SpoolCleanup<unknown>)
406
- await runSettle(true, false, spool, onfinally, description)
438
+ await runSettle(true, undefined, spool, onfinally, description)
407
439
  return result
408
440
  }, async (arg: unknown) => {
409
441
  /* asynchronous case (error branch) */
@@ -415,15 +447,15 @@ export function run<T> (
415
447
  }
416
448
  catch (arg: unknown) {
417
449
  error = ensureError(arg, description)
418
- await runSettle(true, true, spool, onfinally, description)
450
+ await runSettle(true, error, spool, onfinally, description)
419
451
  throw error
420
452
  }
421
453
  if (spool && oncleanup)
422
454
  spool.roll(result, oncleanup as SpoolCleanup<unknown>)
423
- await runSettle(true, false, spool, onfinally, description)
455
+ await runSettle(true, undefined, spool, onfinally, description)
424
456
  return result
425
457
  }
426
- await runSettle(true, true, spool, onfinally, description)
458
+ await runSettle(true, error, spool, onfinally, description)
427
459
  throw error
428
460
  })
429
461
  }
@@ -431,7 +463,7 @@ export function run<T> (
431
463
  /* synchronous case (result branch) */
432
464
  if (spool && oncleanup)
433
465
  spool.roll(result, oncleanup as SpoolCleanup<unknown>)
434
- runSettle(false, false, spool, onfinally, description)
466
+ runSettle(false, undefined, spool, onfinally, description)
435
467
  return result
436
468
  }
437
469
  }
@@ -28,6 +28,7 @@ import type { IClientPublishOptions,
28
28
  import { nanoid } from "nanoid"
29
29
 
30
30
  /* internal requirements */
31
+ import { alignHandlerParams } from "./mqtt-plus-util"
31
32
  import type { EventEmission } from "./mqtt-plus-msg"
32
33
  import type { APISchema, EventKeys,
33
34
  Registration } from "./mqtt-plus-api"
@@ -142,7 +143,7 @@ export class EventTrait<T extends APISchema = APISchema> extends AuthTrait<T> {
142
143
  ` (topic: "${topicName}", payload: "${request.name}")`, { requestId })
143
144
  return
144
145
  }
145
- const params = request.params ?? []
146
+ const params = alignHandlerParams(request.params ?? [], callback)
146
147
 
147
148
  /* sanity check request id */
148
149
  if (this.eventControllers.has(requestId)) {
@@ -71,5 +71,13 @@ export class OptionsTrait<_T extends APISchema = APISchema> {
71
71
  },
72
72
  ...options
73
73
  }
74
+
75
+ /* sanity check numeric option ranges */
76
+ if (!(Number.isFinite(this.options.timeout) && this.options.timeout > 0))
77
+ throw new Error("options: \"timeout\" must be a positive number")
78
+ if (!(Number.isInteger(this.options.chunkSize) && this.options.chunkSize > 0))
79
+ throw new Error("options: \"chunkSize\" must be a positive integer")
80
+ if (!(Number.isInteger(this.options.chunkCredit) && this.options.chunkCredit >= 0))
81
+ throw new Error("options: \"chunkCredit\" must be a non-negative integer")
74
82
  }
75
83
  }
@@ -28,6 +28,7 @@ import type { IClientPublishOptions,
28
28
  import { nanoid } from "nanoid"
29
29
 
30
30
  /* internal requirements */
31
+ import { alignHandlerParams } from "./mqtt-plus-util"
31
32
  import { run, Spool, ensureError } from "./mqtt-plus-error"
32
33
  import type { ServiceCallRequest,
33
34
  ServiceCallResponse } from "./mqtt-plus-msg"
@@ -138,7 +139,7 @@ export class ServiceTrait<T extends APISchema = APISchema> extends EventTrait<T>
138
139
  /* determine request information */
139
140
  const requestId = request.id
140
141
  const senderId = request.sender
141
- const params = request.params ?? []
142
+ const params = alignHandlerParams(request.params ?? [], callback)
142
143
  if (senderId === undefined || senderId === "") {
143
144
  this.error(new Error("invalid request: missing sender"))
144
145
  return
@@ -325,11 +326,13 @@ export class ServiceTrait<T extends APISchema = APISchema> extends EventTrait<T>
325
326
  return false
326
327
  settled = true
327
328
 
328
- /* unroll fire-and-forget: the "settled" boolean guard is the
329
- primary protection against concurrent/duplicate responses,
330
- while cleanup errors are suppressed and the async MQTT
331
- unsubscription is handled by reference-counting/linger */
332
- spool.unroll()
329
+ /* unroll fire-and-forget: the "settled" boolean guard protects
330
+ against concurrent/duplicate responses and the async MQTT
331
+ unsubscription is handled by reference-counting/linger, while
332
+ cleanup failures are surfaced as "error" events */
333
+ spool.unroll(false)?.catch((err: unknown) => {
334
+ this.error(ensureError(err, `cleanup after call of service "${name}" failed`))
335
+ })
333
336
  return true
334
337
  }
335
338
  const promise: Promise<ReturnType<T[K]>> = new Promise((resolve, reject) => {
@@ -33,7 +33,7 @@ import { nanoid } from "nanoid"
33
33
  /* internal requirements */
34
34
  import { CreditGate, ReadableTee,
35
35
  sendBufferAsChunks, sendStreamAsChunks,
36
- makeMutuallyExclusiveFields } from "./mqtt-plus-util"
36
+ makeMutuallyExclusiveFields, alignHandlerParams } from "./mqtt-plus-util"
37
37
  import { run, Spool, ensureError } from "./mqtt-plus-error"
38
38
  import type { SinkPushRequest, SinkPushResponse,
39
39
  SinkPushChunk, SinkPushCredit } from "./mqtt-plus-msg"
@@ -163,7 +163,7 @@ export class SinkTrait<T extends APISchema = APISchema> extends SourceTrait<T> {
163
163
 
164
164
  /* determine information */
165
165
  const requestId = request.id
166
- const params = request.params ?? []
166
+ const params = alignHandlerParams(request.params ?? [], callback)
167
167
  const sender = request.sender
168
168
  const receiver = request.receiver
169
169
 
@@ -603,9 +603,14 @@ export class SinkTrait<T extends APISchema = APISchema> extends SourceTrait<T> {
603
603
  /* ensure stream gets destroyed on abort */
604
604
  if (data instanceof Readable) {
605
605
  const stream = data
606
+ const noopError = () => {} /* prevent unhandled error exception */
606
607
  abortSignal.addEventListener("abort", () => {
607
- if (!stream.destroyed)
608
+ if (!stream.destroyed) {
609
+ /* guard the caller's stream, as it still carries no "error"
610
+ listener when the abort precedes the chunk sending phase */
611
+ stream.on("error", noopError)
608
612
  stream.destroy(ensureError(abortSignal.reason))
613
+ }
609
614
  }, { once: true })
610
615
  }
611
616
 
@@ -645,20 +650,25 @@ export class SinkTrait<T extends APISchema = APISchema> extends SourceTrait<T> {
645
650
  })
646
651
  pushFinalize.catch(() => {}) /* avoid unhandled promise rejection */
647
652
 
653
+ /* abort the push and settle the pending initial/finalize promises */
654
+ const failPush = (error: Error) => {
655
+ abortController.abort(error)
656
+ if (!pushAcked) {
657
+ pushInitialSettled = true
658
+ pushInitialReject(error)
659
+ }
660
+ else if (!pushFinalized) {
661
+ pushFinalized = true
662
+ pushFinalizeReject(error)
663
+ }
664
+ }
665
+
648
666
  /* lock the responder for this communication */
649
667
  const lockResponder = (kind: string, sender?: string): boolean => {
650
668
  if (sender === undefined || sender === "") {
651
669
  const error = new Error(`received ${kind} without sender`)
652
670
  remoteErrorObject = error
653
- abortController.abort(error)
654
- if (!pushAcked) {
655
- pushInitialSettled = true
656
- pushInitialReject(error)
657
- }
658
- else if (!pushFinalized) {
659
- pushFinalized = true
660
- pushFinalizeReject(error)
661
- }
671
+ failPush(error)
662
672
  return false
663
673
  }
664
674
  if (responderId === undefined)
@@ -680,15 +690,7 @@ export class SinkTrait<T extends APISchema = APISchema> extends SourceTrait<T> {
680
690
  if (response.name !== name) {
681
691
  const error = new Error(`sink response name mismatch (expected "${name}", got "${response.name}")`)
682
692
  remoteErrorObject = error
683
- abortController.abort(error)
684
- if (!pushAcked) {
685
- pushInitialSettled = true
686
- pushInitialReject(error)
687
- }
688
- else if (!pushFinalized) {
689
- pushFinalized = true
690
- pushFinalizeReject(error)
691
- }
693
+ failPush(error)
692
694
  return
693
695
  }
694
696
  if (!lockResponder("sink response", response.sender))
@@ -696,15 +698,7 @@ export class SinkTrait<T extends APISchema = APISchema> extends SourceTrait<T> {
696
698
  if (response.error) {
697
699
  const error = new Error(response.error)
698
700
  remoteErrorObject = error
699
- abortController.abort(error)
700
- if (!pushAcked) {
701
- pushInitialSettled = true
702
- pushInitialReject(error)
703
- }
704
- else if (!pushFinalized) {
705
- pushFinalized = true
706
- pushFinalizeReject(error)
707
- }
701
+ failPush(error)
708
702
  }
709
703
  else if (!pushAcked) {
710
704
  initialCredit = response.credit
@@ -730,15 +724,7 @@ export class SinkTrait<T extends APISchema = APISchema> extends SourceTrait<T> {
730
724
  if (response.name !== name) {
731
725
  const error = new Error(`sink credit name mismatch (expected "${name}", got "${response.name}")`)
732
726
  remoteErrorObject = error
733
- abortController.abort(error)
734
- if (!pushAcked) {
735
- pushInitialSettled = true
736
- pushInitialReject(error)
737
- }
738
- else if (!pushFinalized) {
739
- pushFinalized = true
740
- pushFinalizeReject(error)
741
- }
727
+ failPush(error)
742
728
  return
743
729
  }
744
730
  if (!lockResponder("sink credit", response.sender))
@@ -747,15 +733,7 @@ export class SinkTrait<T extends APISchema = APISchema> extends SourceTrait<T> {
747
733
  /* cancel signal from receiver */
748
734
  cancelledByReceiver = true
749
735
  const error = new Error(`push to sink "${name}" cancelled by receiver`)
750
- abortController.abort(error)
751
- if (!pushAcked) {
752
- pushInitialSettled = true
753
- pushInitialReject(error)
754
- }
755
- else if (!pushFinalized) {
756
- pushFinalized = true
757
- pushFinalizeReject(error)
758
- }
736
+ failPush(error)
759
737
  return
760
738
  }
761
739
  if (creditGate !== undefined) {
@@ -767,11 +745,7 @@ export class SinkTrait<T extends APISchema = APISchema> extends SourceTrait<T> {
767
745
  not granting initial credit during ack */
768
746
  const error = new Error(`push to sink "${name}" received unsolicited credit (credit-flow disabled)`)
769
747
  remoteErrorObject = error
770
- abortController.abort(error)
771
- if (!pushFinalized) {
772
- pushFinalized = true
773
- pushFinalizeReject(error)
774
- }
748
+ failPush(error)
775
749
  }
776
750
  else
777
751
  pendingCredit += response.credit
@@ -897,7 +871,7 @@ export class SinkTrait<T extends APISchema = APISchema> extends SourceTrait<T> {
897
871
  response from the receiver to be processed, making the
898
872
  error outcome deterministic (only when race is possible) */
899
873
  if (pushAcked && !remoteErrorObject)
900
- await new Promise<void>((resolve) => { setImmediate(resolve) })
874
+ await new Promise<void>((resolve) => { setTimeout(resolve, 0) })
901
875
 
902
876
  if (remoteErrorObject)
903
877
  throw remoteErrorObject