mqtt-plus 1.4.9 → 1.4.11

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 (44) hide show
  1. package/AGENTS.md +7 -3
  2. package/CHANGELOG.md +28 -0
  3. package/doc/mqtt-plus-api.md +7 -7
  4. package/doc/mqtt-plus-internals.md +0 -3
  5. package/dst-stage1/mqtt-plus-base.d.ts +1 -1
  6. package/dst-stage1/mqtt-plus-event.d.ts +3 -3
  7. package/dst-stage1/mqtt-plus-event.js +6 -6
  8. package/dst-stage1/mqtt-plus-service.js +1 -1
  9. package/dst-stage1/mqtt-plus-sink.js +29 -19
  10. package/dst-stage1/mqtt-plus-source.d.ts +1 -0
  11. package/dst-stage1/mqtt-plus-source.js +45 -18
  12. package/dst-stage1/mqtt-plus-subscription.js +3 -4
  13. package/dst-stage1/mqtt-plus-timer.d.ts +1 -1
  14. package/dst-stage1/mqtt-plus-timer.js +8 -2
  15. package/dst-stage1/mqtt-plus-util.js +5 -3
  16. package/dst-stage2/mqtt-plus.cjs.js +90 -50
  17. package/dst-stage2/mqtt-plus.esm.js +90 -50
  18. package/dst-stage2/mqtt-plus.umd.js +12 -12
  19. package/etc/vite.mts +1 -1
  20. package/package.json +6 -10
  21. package/src/mqtt-plus-base.ts +1 -1
  22. package/src/mqtt-plus-event.ts +10 -10
  23. package/src/mqtt-plus-service.ts +1 -1
  24. package/src/mqtt-plus-sink.ts +35 -24
  25. package/src/mqtt-plus-source.ts +51 -21
  26. package/src/mqtt-plus-subscription.ts +4 -4
  27. package/src/mqtt-plus-timer.ts +9 -3
  28. package/src/mqtt-plus-util.ts +6 -4
  29. package/tst/mqtt-plus-0-broker.ts +2 -2
  30. package/tst/mqtt-plus-2-event.spec.ts +2 -2
  31. package/tst/mqtt-plus-5-source.spec.ts +2 -2
  32. package/tst/mqtt-plus-6-misc.spec.ts +1 -1
  33. package/dst-stage1/mqtt-plus-receiver.d.ts +0 -12
  34. package/dst-stage1/mqtt-plus-receiver.js +0 -42
  35. package/dst-stage1/mqtt-plus-resource-dn.d.ts +0 -41
  36. package/dst-stage1/mqtt-plus-resource-dn.js +0 -286
  37. package/dst-stage1/mqtt-plus-resource-up.d.ts +0 -32
  38. package/dst-stage1/mqtt-plus-resource-up.js +0 -230
  39. package/dst-stage1/mqtt-plus-resource.d.ts +0 -49
  40. package/dst-stage1/mqtt-plus-resource.js +0 -385
  41. package/dst-stage1/mqtt-plus-stream.d.ts +0 -24
  42. package/dst-stage1/mqtt-plus-stream.js +0 -191
  43. package/dst-stage1/mqtt-plus-topic.d.ts +0 -20
  44. package/dst-stage1/mqtt-plus-topic.js +0 -112
package/AGENTS.md CHANGED
@@ -33,11 +33,13 @@ npm start sample # run sample/sample.ts via `tsx`
33
33
 
34
34
  npm start clean # remove dst-stage1/ and dst-stage2/
35
35
  npm start distclean # remove node_modules/ and package-lock.json
36
+ npm start publish # publish to npm (restricted to maintainer host)
36
37
 
37
38
  ```
38
39
 
39
- Tests require a Mosquitto MQTT broker under run-time; the `mosquitto`
40
- npm package provides one that the test suite starts/stops automatically.
40
+ Tests require an MQTT broker under run-time; the test suite starts/stops
41
+ one automatically. If Docker is available, a Mosquitto broker is used;
42
+ otherwise, the Aedes in-process broker serves as the fallback.
41
43
 
42
44
  Build Pipeline
43
45
  --------------
@@ -51,7 +53,7 @@ Two-stage build:
51
53
  `mqtt-plus.esm.js`, `mqtt-plus.cjs.js`, `mqtt-plus.umd.js`.
52
54
  UMD build includes Node polyfills (events, stream, buffer).
53
55
 
54
- Configuration lives in `etc/`: `tsc.json`, `vite.mts`, `eslint.mts`, `knip.jsonc`, `stx.conf`, `d2.mts`.
56
+ Configuration lives in `etc/`: `tsc.json`, `vite.mts`, `eslint.mts`, `knip.jsonc`, `stx.conf`, `d2.mts`, `d2.theme.d2`, `logo.ai`, `logo.svg`.
55
57
 
56
58
  Architecture
57
59
  ------------
@@ -70,6 +72,7 @@ the bottom of this chain:
70
72
  ↓ TraceTrait — EventEmitter + structured logging
71
73
  ↓ BaseTrait — MQTT client hookup, subscription management, message routing
72
74
  ↓ SubscriptionTrait — ref-counted MQTT topic subscription management
75
+ ↓ TimerTrait — named timer management (refresh/clear)
73
76
  ↓ MetaTrait — instance/per-request metadata
74
77
  ↓ AuthTrait — JWT authentication (jose), role-based access
75
78
  ↓ EventTrait — Event Emission pattern (event/emit)
@@ -98,6 +101,7 @@ Each trait lives in its own file: `src/mqtt-plus-<trait>.ts`.
98
101
  | `src/mqtt-plus-trace.ts` | TraceTrait — EventEmitter and structured logging |
99
102
  | `src/mqtt-plus-base.ts` | BaseTrait — MQTT client connection, subscription management, message routing |
100
103
  | `src/mqtt-plus-subscription.ts` | SubscriptionTrait — ref-counted MQTT topic subscription management |
104
+ | `src/mqtt-plus-timer.ts` | TimerTrait — named timer management (refresh/clear) |
101
105
  | `src/mqtt-plus-meta.ts` | MetaTrait — instance and per-request metadata management |
102
106
  | `src/mqtt-plus-auth.ts` | AuthTrait — JWT authentication (jose) and role-based access control |
103
107
  | `src/mqtt-plus-event.ts` | EventTrait — Event Emission communication pattern (event/emit) |
package/CHANGELOG.md CHANGED
@@ -2,6 +2,34 @@
2
2
  ChangeLog
3
3
  =========
4
4
 
5
+ 1.4.11 (2026-03-05)
6
+ -------------------
7
+
8
+ - IMPROVEMENT: improve error handling and use error ensure function consistently
9
+ - IMPROVEMENT: improve cleanup handling and correctly track resources
10
+ - IMPROVEMENT: improve typing and fix overloads
11
+ - IMPROVEMENT: avoid warning in Vite
12
+ - BUGFIX: fix building for Vite compatibility
13
+ - BUGFIX: avoid unhandled rejection error
14
+ - BUGFIX: fix test
15
+ - UPDATE: update documentation
16
+ - UPDATE: upgrade NPM dependencies
17
+ - CLEANUP: rename emit() parameter "event" to "name" for consistency
18
+ - CLEANUP: cleanup code
19
+
20
+ 1.4.10 (2026-03-01)
21
+ -------------------
22
+
23
+ - IMPROVEMENT: improve performance
24
+ - IMPROVEMENT: improve typing and export more public API types
25
+ - IMPROVEMENT: improve description
26
+ - BUGFIX: fix error handling and destruction problems
27
+ - BUGFIX: fix name of module
28
+ - BUGFIX: do not make fields exclusive
29
+ - UPDATE: upgrade NPM dependencies
30
+ - CLEANUP: various code cleanups (simplification, formatting, comments, output polishing)
31
+ - CLEANUP: cleanups for error handling
32
+
5
33
  1.4.9 (2026-02-22)
6
34
  ------------------
7
35
 
@@ -65,7 +65,7 @@ describing the available events, services, sources, and sinks.
65
65
  Destruction
66
66
  -----------
67
67
 
68
- destroy(): void
68
+ destroy(): Promise<void>
69
69
 
70
70
  Clean up the MQTT+ instance by removing all event listeners.
71
71
  Call this method when the instance is no longer needed.
@@ -265,18 +265,18 @@ Event Emission
265
265
 
266
266
  /* (simplified TypeScript API method signature) */
267
267
  emit(
268
- event: string,
268
+ name: string,
269
269
  ...params: any[]
270
270
  ): void
271
271
  emit({
272
- event: string,
272
+ name: string,
273
273
  params: any[],
274
274
  receiver?: string,
275
275
  options?: MQTT::IClientPublishOptions,
276
276
  meta?: Record<string, any>
277
277
  }): void
278
278
  emit({
279
- event: string,
279
+ name: string,
280
280
  params: any[],
281
281
  receiver?: string,
282
282
  options?: MQTT::IClientPublishOptions,
@@ -300,8 +300,8 @@ Emit an event to all subscribers or a specific subscriber ("fire and forget").
300
300
  - The remote `event()` `callback` is called with `params` and its
301
301
  return value is silently ignored.
302
302
 
303
- - Internally, publishes to the MQTT topic by `topicMake(event, "event-emission", peerId)`
304
- (default: `${event}/event-emission/any` or `${event}/event-emission/${peerId}`).
303
+ - Internally, publishes to the MQTT topic by `topicMake(name, "event-emission", peerId)`
304
+ (default: `${name}/event-emission/any` or `${name}/event-emission/${peerId}`).
305
305
 
306
306
  - *Dry-Run Publishing for MQTT Last-Will:*
307
307
  When you need to set up an MQTT "last will" message (automatically published
@@ -315,7 +315,7 @@ Emit an event to all subscribers or a specific subscriber ("fire and forget").
315
315
  const mqttpDry = new MQTTp<API>(null, { id: "my-client" })
316
316
  const will = mqttpDry.emit({
317
317
  dry: true,
318
- event: "example/connection",
318
+ name: "example/connection",
319
319
  params: [ "close" ],
320
320
  [...]
321
321
  })
@@ -122,8 +122,6 @@ Exactly one of `result` or `error` is present.
122
122
  |----------|------------------------|----------|-------------------------------|
123
123
  | `name` | `string` | yes | Sink endpoint name |
124
124
  | `error` | `string` | no | Error message (nak) or absent (ack) |
125
- | `auth` | `string[]` | no | JWT tokens (max 8) |
126
- | `meta` | `Record<string, any>` | no | Arbitrary metadata |
127
125
  | `credit` | `integer` | no | Initial flow control credit (min 1) |
128
126
 
129
127
  ### `sink-push-chunk`
@@ -158,7 +156,6 @@ Exactly one of `result` or `error` is present.
158
156
  |----------|------------------------|----------|-------------------------------|
159
157
  | `name` | `string` | yes | Source endpoint name |
160
158
  | `error` | `string` | no | Error message (nak) or absent (ack) |
161
- | `auth` | `string[]` | no | JWT tokens (max 8) |
162
159
  | `meta` | `Record<string, any>` | no | Arbitrary metadata |
163
160
 
164
161
  ### `source-fetch-chunk`
@@ -1,4 +1,4 @@
1
- import { MqttClient, type IClientSubscribeOptions, type IClientPublishOptions } from "mqtt";
1
+ import { type MqttClient, type IClientSubscribeOptions, type IClientPublishOptions } from "mqtt";
2
2
  import type { APISchema, Registration } from "./mqtt-plus-api";
3
3
  import type { APIOptions } from "./mqtt-plus-options";
4
4
  import { TraceTrait } from "./mqtt-plus-trace";
@@ -11,16 +11,16 @@ export declare class EventTrait<T extends APISchema = APISchema> extends AuthTra
11
11
  share?: string;
12
12
  auth?: AuthOption;
13
13
  }): Promise<Registration>;
14
- emit<K extends EventKeys<T> & string>(event: K, ...params: Parameters<T[K]>): void;
14
+ emit<K extends EventKeys<T> & string>(name: K, ...params: Parameters<T[K]>): void;
15
15
  emit<K extends EventKeys<T> & string>(config: {
16
- event: K;
16
+ name: K;
17
17
  params: Parameters<T[K]>;
18
18
  receiver?: string;
19
19
  options?: IClientPublishOptions;
20
20
  meta?: Record<string, any>;
21
21
  }): void;
22
22
  emit<K extends EventKeys<T> & string>(config: {
23
- event: K;
23
+ name: K;
24
24
  params: Parameters<T[K]>;
25
25
  receiver?: string;
26
26
  options?: IClientPublishOptions;
@@ -92,7 +92,7 @@ export class EventTrait extends AuthTrait {
92
92
  }
93
93
  emit(eventOrConfig, ...args) {
94
94
  /* determine actual parameters */
95
- let event;
95
+ let name;
96
96
  let params;
97
97
  let receiver;
98
98
  let options = {};
@@ -100,7 +100,7 @@ export class EventTrait extends AuthTrait {
100
100
  let dry;
101
101
  if (typeof eventOrConfig === "object" && eventOrConfig !== null) {
102
102
  /* object-based API */
103
- event = eventOrConfig.event;
103
+ name = eventOrConfig.name;
104
104
  params = eventOrConfig.params;
105
105
  receiver = eventOrConfig.receiver;
106
106
  options = eventOrConfig.options ?? {};
@@ -109,7 +109,7 @@ export class EventTrait extends AuthTrait {
109
109
  }
110
110
  else {
111
111
  /* positional API */
112
- event = eventOrConfig;
112
+ name = eventOrConfig;
113
113
  params = args;
114
114
  }
115
115
  /* generate unique request id */
@@ -117,10 +117,10 @@ export class EventTrait extends AuthTrait {
117
117
  /* generate encoded message */
118
118
  const auth = this.authenticate();
119
119
  const metaStore = this.metaStore(meta);
120
- const request = this.msg.makeEventEmission(requestId, event, params, this.options.id, receiver, auth, metaStore);
120
+ const request = this.msg.makeEventEmission(requestId, name, params, this.options.id, receiver, auth, metaStore);
121
121
  const message = this.codec.encode(request);
122
122
  /* generate corresponding MQTT topic */
123
- const topic = this.options.topicMake(event, "event-emission", receiver);
123
+ const topic = this.options.topicMake(name, "event-emission", receiver);
124
124
  /* produce result */
125
125
  if (dry)
126
126
  /* return publish information */
@@ -128,7 +128,7 @@ export class EventTrait extends AuthTrait {
128
128
  else
129
129
  /* publish message to MQTT topic */
130
130
  this.publishToTopic(topic, message, { qos: 2, ...options }).catch((err) => {
131
- this.error(err, `emitting event "${event}" failed`);
131
+ this.error(err, `emitting event "${name}" failed`);
132
132
  });
133
133
  }
134
134
  }
@@ -98,7 +98,7 @@ export class ServiceTrait extends EventTrait {
98
98
  await this.publishToTopic(topic, encoded, { qos: options.qos ?? 2 });
99
99
  }
100
100
  catch (err2) {
101
- this.error(ensureError(err2), `handler for service "${name}" failed`);
101
+ this.error(ensureError(err2), `sending error response for service "${name}" failed`);
102
102
  }
103
103
  }
104
104
  });
@@ -39,7 +39,7 @@ export class SinkTrait extends SourceTrait {
39
39
  /* destroy sink trait */
40
40
  async destroy() {
41
41
  for (const stream of this.pushStreams.values())
42
- stream.destroy();
42
+ stream.destroy(new Error("sink destroyed"));
43
43
  for (const spool of this.pushSpools.values())
44
44
  await spool.unroll();
45
45
  this.pushStreams.clear();
@@ -178,6 +178,7 @@ export class SinkTrait extends SourceTrait {
178
178
  reqSpool.roll(() => { clearPushTimeout(); });
179
179
  /* prepare info object */
180
180
  const promise = streamToBuffer(readable);
181
+ promise.catch(() => { }); /* avoid unhandled promise rejection */
181
182
  const info = {
182
183
  sender,
183
184
  stream: readable,
@@ -194,22 +195,21 @@ export class SinkTrait extends SourceTrait {
194
195
  await sendResponse();
195
196
  ackSent = true;
196
197
  /* call handler */
197
- return await callback(...params, info);
198
+ await callback(...params, info);
198
199
  }
199
200
  catch (err) {
200
- const error = ensureError(err);
201
+ const error = ensureError(err, `handler for sink "${name}" failed`);
201
202
  /* cleanup resources */
202
203
  const stream = this.pushStreams.get(requestId);
203
204
  if (stream !== undefined)
204
205
  stream.destroy(error);
205
- reqSpool.unroll();
206
- /* send error as nak response or as error chunk */
206
+ await reqSpool.unroll();
207
+ /* send error as nak response or as mid-stream error response */
207
208
  this.error(error);
208
209
  if (ackSent) {
209
- const chunkTopic = this.options.topicMake(name, "sink-push-chunk", sender);
210
- const chunkMsg = this.msg.makeSinkPushChunk(requestId, name, undefined, error.message, true, this.options.id, sender);
211
- const message = this.codec.encode(chunkMsg);
212
- await this.publishToTopic(chunkTopic, message, { qos: options.qos ?? 2 }).catch(() => { });
210
+ const responseMsg = this.msg.makeSinkPushResponse(requestId, name, error.message, this.options.id, sender);
211
+ const message = this.codec.encode(responseMsg);
212
+ await this.publishToTopic(responseTopic, message, { qos: options.qos ?? 2 }).catch(() => { });
213
213
  }
214
214
  else
215
215
  await sendResponse(error.message).catch(() => { });
@@ -263,10 +263,19 @@ export class SinkTrait extends SourceTrait {
263
263
  /* define abort controller and signal */
264
264
  const abortController = new AbortController();
265
265
  const abortSignal = abortController.signal;
266
+ /* ensure stream gets destroyed on abort */
267
+ if (data instanceof Readable) {
268
+ const stream = data;
269
+ abortSignal.addEventListener("abort", () => {
270
+ if (!stream.destroyed)
271
+ stream.destroy(ensureError(abortSignal.reason));
272
+ }, { once: true });
273
+ }
266
274
  /* utility function for timeout refresh */
267
275
  const pushTimerId = `sink-push-send:${requestId}`;
268
276
  const refreshTimeout = () => this.timerRefresh(pushTimerId, () => {
269
- abortController.abort(new Error(`push to sink "${name}" timed out`));
277
+ const error = new Error(`push to sink "${name}" timed out`);
278
+ abortController.abort(error);
270
279
  spool.unroll();
271
280
  });
272
281
  spool.roll(() => { this.timerClear(pushTimerId); });
@@ -275,6 +284,7 @@ export class SinkTrait extends SourceTrait {
275
284
  /* send request and wait for response before sending chunks */
276
285
  let initialCredit;
277
286
  let creditGate;
287
+ let remoteError = false;
278
288
  try {
279
289
  await new Promise((resolve, reject) => {
280
290
  /* handle abort signal */
@@ -293,10 +303,6 @@ export class SinkTrait extends SourceTrait {
293
303
  }
294
304
  });
295
305
  spool.roll(() => { this.onResponse.delete(`sink-push-response:${requestId}`); });
296
- this.onResponse.set(`sink-push-credit:${requestId}`, (_response) => {
297
- refreshTimeout();
298
- });
299
- spool.roll(() => { this.onResponse.delete(`sink-push-credit:${requestId}`); });
300
306
  /* generate and send request message */
301
307
  const auth = this.authenticate();
302
308
  const metaStore = this.metaStore(meta);
@@ -309,8 +315,10 @@ export class SinkTrait extends SourceTrait {
309
315
  });
310
316
  /* override handler for mid-stream (error) responses */
311
317
  this.onResponse.set(`sink-push-response:${requestId}`, (response) => {
312
- if (response.error)
318
+ if (response.error) {
319
+ remoteError = true;
313
320
  abortController.abort(new Error(response.error));
321
+ }
314
322
  });
315
323
  /* create credit gate for flow control (if server granted credit) */
316
324
  if (initialCredit !== undefined && initialCredit > 0)
@@ -327,6 +335,7 @@ export class SinkTrait extends SourceTrait {
327
335
  gate.replenish(response.credit);
328
336
  refreshTimeout();
329
337
  });
338
+ spool.roll(() => { this.onResponse.delete(`sink-push-credit:${requestId}`); });
330
339
  }
331
340
  /* generate corresponding MQTT topic for chunks */
332
341
  const chunkTopic = this.options.topicMake(name, "sink-push-chunk", receiver);
@@ -346,12 +355,13 @@ export class SinkTrait extends SourceTrait {
346
355
  await sendBufferAsChunks(data, this.options.chunkSize, sendChunk, creditGate, abortSignal);
347
356
  }
348
357
  catch (err) {
349
- /* send error chunk only if receiver is known
358
+ const error = ensureError(err);
359
+ abortController.abort(error);
360
+ /* send error chunk only if receiver is known and error did not originate from receiver
350
361
  (otherwise the sink already received the error via the nak response) */
351
- if (receiver !== undefined) {
352
- const error = ensureError(err).message;
362
+ if (receiver !== undefined && !remoteError) {
353
363
  const chunkTopic = this.options.topicMake(name, "sink-push-chunk", receiver);
354
- const chunkMsg = this.msg.makeSinkPushChunk(requestId, name, undefined, error, true, this.options.id, receiver);
364
+ const chunkMsg = this.msg.makeSinkPushChunk(requestId, name, undefined, error.message, true, this.options.id, receiver);
355
365
  const message = this.codec.encode(chunkMsg);
356
366
  await this.publishToTopic(chunkTopic, message, { qos: 2, ...options }).catch(() => { });
357
367
  }
@@ -6,6 +6,7 @@ import { ServiceTrait } from "./mqtt-plus-service";
6
6
  import type { AuthOption } from "./mqtt-plus-auth";
7
7
  export declare class SourceTrait<T extends APISchema = APISchema> extends ServiceTrait<T> {
8
8
  private sourceCreditGates;
9
+ private sourceControllers;
9
10
  destroy(): Promise<void>;
10
11
  source<K extends SourceKeys<T> & string>(name: K, callback: WithInfo<T[K], InfoSource>): Promise<Registration>;
11
12
  source<K extends SourceKeys<T> & string>(config: {
@@ -34,9 +34,13 @@ export class SourceTrait extends ServiceTrait {
34
34
  super(...arguments);
35
35
  /* source state */
36
36
  this.sourceCreditGates = new Map();
37
+ this.sourceControllers = new Map();
37
38
  }
38
39
  /* destroy source trait */
39
40
  async destroy() {
41
+ for (const controller of this.sourceControllers.values())
42
+ controller.abort(new Error("source destroyed"));
43
+ this.sourceControllers.clear();
40
44
  for (const gate of this.sourceCreditGates.values())
41
45
  gate.abort();
42
46
  this.sourceCreditGates.clear();
@@ -96,12 +100,27 @@ export class SourceTrait extends ServiceTrait {
96
100
  const message = this.codec.encode(response);
97
101
  await this.publishToTopic(responseTopic, message, { qos: options.qos ?? 2 });
98
102
  };
103
+ /* define abort controller and signal */
104
+ const abortController = new AbortController();
105
+ this.sourceControllers.set(requestId, abortController);
106
+ const abortSignal = abortController.signal;
107
+ /* ensure stream gets destroyed on abort */
108
+ abortSignal.addEventListener("abort", () => {
109
+ if (info.stream instanceof Readable && !info.stream.destroyed)
110
+ info.stream.destroy(ensureError(abortSignal.reason));
111
+ }, { once: true });
99
112
  /* utility functions for timeout management */
100
113
  const sourceTimerId = `source-fetch-send:${requestId}`;
101
114
  const refreshSourceTimeout = () => this.timerRefresh(sourceTimerId, () => {
115
+ const error = new Error(`source fetch "${name}" timed out`);
116
+ abortController.abort(error);
102
117
  const gate = this.sourceCreditGates.get(requestId);
103
- if (gate !== undefined)
118
+ if (gate !== undefined) {
104
119
  gate.abort();
120
+ this.sourceCreditGates.delete(requestId);
121
+ }
122
+ this.sourceControllers.delete(requestId);
123
+ this.onResponse.delete(`source-fetch-credit:${requestId}`);
105
124
  });
106
125
  const clearSourceTimeout = () => this.timerClear(sourceTimerId);
107
126
  refreshSourceTimeout();
@@ -112,19 +131,9 @@ export class SourceTrait extends ServiceTrait {
112
131
  const message = this.codec.encode(chunkMsg);
113
132
  await this.publishToTopic(chunkTopic, message, { qos: options.qos ?? 2 });
114
133
  };
115
- /* handle credit-based flow control (if credit provided in request) */
116
- const initialCredit = request.credit;
117
- const creditGate = (initialCredit !== undefined && initialCredit > 0)
118
- ? new CreditGate(initialCredit) : undefined;
119
- if (creditGate) {
120
- this.sourceCreditGates.set(requestId, creditGate);
121
- this.onResponse.set(`source-fetch-credit:${requestId}`, (creditParsed) => {
122
- creditGate.replenish(creditParsed.credit);
123
- refreshSourceTimeout();
124
- });
125
- }
126
134
  /* call the handler callback */
127
135
  let ackSent = false;
136
+ let creditGate;
128
137
  try {
129
138
  if (topicName !== request.name)
130
139
  throw new Error(`source name mismatch (topic: "${topicName}", payload: "${request.name}")`);
@@ -132,6 +141,18 @@ export class SourceTrait extends ServiceTrait {
132
141
  info.authenticated = await this.authenticated(request.sender, request.auth, auth);
133
142
  if (info.authenticated !== undefined && !info.authenticated)
134
143
  throw new Error(`source "${name}" failed authentication`);
144
+ /* handle credit-based flow control (if credit provided in request) */
145
+ const initialCredit = request.credit;
146
+ creditGate = (initialCredit !== undefined && initialCredit > 0)
147
+ ? new CreditGate(initialCredit) : undefined;
148
+ if (creditGate) {
149
+ const gate = creditGate;
150
+ this.sourceCreditGates.set(requestId, gate);
151
+ this.onResponse.set(`source-fetch-credit:${requestId}`, (creditParsed) => {
152
+ gate.replenish(creditParsed.credit);
153
+ refreshSourceTimeout();
154
+ });
155
+ }
135
156
  await callback(...params, info);
136
157
  /* check for valid data source */
137
158
  if (!(info.stream instanceof Readable) && !(info.buffer instanceof Promise))
@@ -144,15 +165,17 @@ export class SourceTrait extends ServiceTrait {
144
165
  /* dispatch according to data type */
145
166
  if (info.stream instanceof Readable)
146
167
  /* handle Readable stream result */
147
- await sendStreamAsChunks(info.stream, this.options.chunkSize, sendChunk, creditGate);
168
+ await sendStreamAsChunks(info.stream, this.options.chunkSize, sendChunk, creditGate, abortSignal);
148
169
  else if (info.buffer instanceof Promise)
149
170
  /* handle Buffer result */
150
- await sendBufferAsChunks(await info.buffer, this.options.chunkSize, sendChunk, creditGate);
171
+ await sendBufferAsChunks(await info.buffer, this.options.chunkSize, sendChunk, creditGate, abortSignal);
151
172
  }
152
173
  catch (err) {
174
+ /* cleanup stream resource (if provided by handler) */
175
+ const error = ensureError(err, `handler for source "${name}" failed`);
176
+ abortController.abort(error);
153
177
  /* send error as nak response or as error chunk */
154
- const error = ensureError(err);
155
- this.error(error, `handler for source "${name}" failed`);
178
+ this.error(error);
156
179
  if (ackSent)
157
180
  await sendChunk(undefined, error.message, true).catch(() => { });
158
181
  else
@@ -165,6 +188,7 @@ export class SourceTrait extends ServiceTrait {
165
188
  creditGate.abort();
166
189
  this.sourceCreditGates.delete(requestId);
167
190
  }
191
+ this.sourceControllers.delete(requestId);
168
192
  this.onResponse.delete(`source-fetch-credit:${requestId}`);
169
193
  }
170
194
  });
@@ -233,11 +257,13 @@ export class SourceTrait extends ServiceTrait {
233
257
  this.publishToTopic(creditTopic, encoded, { qos: options.qos ?? 2 }).catch((err) => {
234
258
  this.error(err, `sending credit for fetch "${name}" failed`);
235
259
  });
260
+ refreshTimeout();
236
261
  }
237
262
  }
238
263
  });
239
264
  /* create promise for collecting stream chunks */
240
265
  const buffer = streamToBuffer(stream);
266
+ buffer.catch(() => { }); /* avoid unhandled promise rejection */
241
267
  /* create promise for meta (resolved on first chunk) */
242
268
  let metaResolve;
243
269
  const metaP = new Promise((resolve) => {
@@ -267,13 +293,14 @@ export class SourceTrait extends ServiceTrait {
267
293
  }
268
294
  if (response.sender)
269
295
  serverId = response.sender;
270
- metaResolve(response.meta);
271
296
  if (response.error) {
272
297
  stream.destroy(new Error(response.error));
273
298
  spool.unroll();
274
299
  }
275
- else
300
+ else {
301
+ metaResolve(response.meta);
276
302
  refreshTimeout();
303
+ }
277
304
  });
278
305
  /* register chunk dispatch callback */
279
306
  this.onResponse.set(`source-fetch-chunk:${requestId}`, (response) => {
@@ -143,15 +143,14 @@ class RefCountedSubscription {
143
143
  ...this.unsubbing.keys()
144
144
  ]);
145
145
  /* cancel all pending linger timers first (synchronously) */
146
- for (const topic of this.lingers.keys())
147
- clearTimeout(this.lingers.get(topic));
146
+ for (const timer of this.lingers.values())
147
+ clearTimeout(timer);
148
148
  this.lingers.clear();
149
149
  this.counts.clear();
150
150
  /* wait for any in-flight subscribe/unsubscribe operations to settle first */
151
151
  await Promise.allSettled([...this.pending.values(), ...this.unsubbing.values()]);
152
152
  /* then unsubscribe from all potentially active topics */
153
- for (const topic of topics)
154
- await this.unsubscribeFn(topic).catch(() => { });
153
+ await Promise.allSettled([...topics].map((topic) => this.unsubscribeFn(topic).catch(() => { })));
155
154
  /* clear remaining internal state */
156
155
  this.pending.clear();
157
156
  this.unsubbing.clear();
@@ -3,6 +3,6 @@ import { SubscriptionTrait } from "./mqtt-plus-subscription";
3
3
  export declare class TimerTrait<T extends APISchema = APISchema> extends SubscriptionTrait<T> {
4
4
  private timers;
5
5
  destroy(): Promise<void>;
6
- protected timerRefresh(id: string, onTimeout: () => void): void;
6
+ protected timerRefresh(id: string, onTimeout: () => void | Promise<void>): void;
7
7
  protected timerClear(id: string): void;
8
8
  }
@@ -22,6 +22,7 @@
22
22
  ** SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
23
23
  */
24
24
  import { SubscriptionTrait } from "./mqtt-plus-subscription";
25
+ import { ensureError } from "./mqtt-plus-error";
25
26
  /* Timer trait with reusable timer management */
26
27
  export class TimerTrait extends SubscriptionTrait {
27
28
  constructor() {
@@ -41,9 +42,14 @@ export class TimerTrait extends SubscriptionTrait {
41
42
  const timer = this.timers.get(id);
42
43
  if (timer !== undefined)
43
44
  clearTimeout(timer);
44
- this.timers.set(id, setTimeout(() => {
45
+ this.timers.set(id, setTimeout(async () => {
45
46
  this.timers.delete(id);
46
- onTimeout();
47
+ try {
48
+ await onTimeout();
49
+ }
50
+ catch (err) {
51
+ this.error(ensureError(err), `timer "${id}" failed`);
52
+ }
47
53
  }, this.options.timeout));
48
54
  }
49
55
  /* clear a named timer */
@@ -25,8 +25,8 @@
25
25
  import { Buffer } from "node:buffer";
26
26
  /* external requirements */
27
27
  import PLazyAPI from "p-lazy";
28
- /* workaround for ESM-only module "plazy" which, when used in the context
29
- of MQTT+'s CJS built (e.g. inside test suite), exports via "default" */
28
+ /* workaround for ESM-only module "p-lazy" which, when used in the context
29
+ of MQTT+'s CJS build (e.g. inside test suite), exports via "default" */
30
30
  export const PLazy = (PLazyAPI.default ?? PLazyAPI);
31
31
  /* credit-based flow control gate for chunk producers */
32
32
  export class CreditGate {
@@ -49,7 +49,9 @@ export class CreditGate {
49
49
  /* wait for credit to be replenished */
50
50
  await new Promise((resolve, reject) => {
51
51
  const onAbort = () => {
52
- this.waiters.splice(this.waiters.indexOf(waiter), 1);
52
+ const idx = this.waiters.indexOf(waiter);
53
+ if (idx !== -1)
54
+ this.waiters.splice(idx, 1);
53
55
  reject(abortSignal?.reason ?? new Error("aborted"));
54
56
  };
55
57
  if (abortSignal)