mqtt-plus 1.4.8 → 1.4.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,34 @@
2
2
  ChangeLog
3
3
  =========
4
4
 
5
+ 1.4.10 (2026-03-01)
6
+ -------------------
7
+
8
+ - IMPROVEMENT: improve performance
9
+ - IMPROVEMENT: improve typing and export more public API types
10
+ - IMPROVEMENT: improve description
11
+ - BUGFIX: fix error handling and destruction problems
12
+ - BUGFIX: fix name of module
13
+ - BUGFIX: do not make fields exclusive
14
+ - UPDATE: upgrade NPM dependencies
15
+ - CLEANUP: various code cleanups (simplification, formatting, comments, output polishing)
16
+ - CLEANUP: cleanups for error handling
17
+
18
+ 1.4.9 (2026-02-22)
19
+ ------------------
20
+
21
+ - BUGFIX: clear internal response handlers in destroy()
22
+ - BUGFIX: correctly decrement counter in subscription handling
23
+ - BUGFIX: let the registration's destroy() throw errors correctly
24
+ - BUGFIX: correctly handle synchronous response handler failures
25
+ - BUGFIX: fix internal chunkToBuffer() method for byte-length calculation
26
+ - BUGFIX: apply the same limits on sender size for authenticate() as on receiver side
27
+ - BUGFIX: check for name/topic mismatches also in source fetch()
28
+ - REFACTOR: factor out topic subscription and spooling topic unsubscription into helper function
29
+ - REFACTOR: make response handlers async functions to correctly catch their failures
30
+ - IMPROVEMENT: use a cached TextEncoder in utility functions
31
+ - IMPROVEMENT: ensure generated NanoIDs do not conflict with pending requests
32
+
5
33
  1.4.8 (2026-02-22)
6
34
  ------------------
7
35
 
@@ -27,6 +27,8 @@ import { jwtVerify } from "jose/jwt/verify";
27
27
  import * as pbkdf2 from "@stablelib/pbkdf2";
28
28
  import * as sha256 from "@stablelib/sha256";
29
29
  import { MetaTrait } from "./mqtt-plus-meta";
30
+ /* reusable encoder instance */
31
+ const textEncoder = new TextEncoder();
30
32
  /* authentication trait */
31
33
  export class AuthTrait extends MetaTrait {
32
34
  constructor() {
@@ -41,8 +43,8 @@ export class AuthTrait extends MetaTrait {
41
43
  if (credential.length === 0)
42
44
  throw new Error("credential must not be empty");
43
45
  /* use a derived key with minimum length of 32 for JWT HS256 */
44
- const pass = new TextEncoder().encode(credential);
45
- const salt = new TextEncoder().encode("mqtt-plus");
46
+ const pass = textEncoder.encode(credential);
47
+ const salt = textEncoder.encode("mqtt-plus");
46
48
  this._credential = pbkdf2.deriveKey(sha256.SHA256, pass, salt, 600000, 32);
47
49
  }
48
50
  /* issue client-side token on server-side */
@@ -59,12 +61,19 @@ export class AuthTrait extends MetaTrait {
59
61
  return token;
60
62
  }
61
63
  authenticate(token, remove) {
62
- if (token === undefined)
63
- return this._tokens.size > 0 ? Array.from(this._tokens) : undefined;
64
+ if (token === undefined) {
65
+ const tokens = Array.from(this._tokens).filter((token) => token.length <= 8192).slice(0, 8);
66
+ return tokens.length > 0 ? tokens : undefined;
67
+ }
64
68
  else if (remove === true)
65
69
  this._tokens.delete(token);
66
- else
70
+ else {
71
+ if (token.length > 8192)
72
+ throw new Error("token must not exceed 8192 characters");
73
+ if (!this._tokens.has(token) && this._tokens.size >= 8)
74
+ throw new Error("at most 8 tokens can be authenticated at once");
67
75
  this._tokens.add(token);
76
+ }
68
77
  }
69
78
  /* validate client-side token on server-side */
70
79
  async validateToken(token) {
@@ -2,7 +2,7 @@ import { MqttClient, type IClientSubscribeOptions, type IClientPublishOptions }
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";
5
- import type { Spool } from "./mqtt-plus-error";
5
+ import { Spool } from "./mqtt-plus-error";
6
6
  export declare class BaseTrait<T extends APISchema = APISchema> extends TraceTrait<T> {
7
7
  private mqtt;
8
8
  private messageHandler;
@@ -11,6 +11,7 @@ export declare class BaseTrait<T extends APISchema = APISchema> extends TraceTra
11
11
  constructor(mqtt: MqttClient | null, options?: Partial<APIOptions>);
12
12
  destroy(): Promise<void>;
13
13
  protected makeRegistration(spool: Spool, kind: string, name: string, key: string): Registration;
14
+ protected subscribeTopicAndSpool(spool: Spool, topic: string, options?: Partial<IClientSubscribeOptions>): Promise<void>;
14
15
  protected subscribeTopic(topic: string, options?: Partial<IClientSubscribeOptions>): Promise<void>;
15
16
  protected unsubscribeTopic(topic: string): Promise<void>;
16
17
  protected publishToTopic(topic: string, message: string | Uint8Array, options?: IClientPublishOptions): Promise<void>;
@@ -22,7 +22,7 @@
22
22
  ** SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
23
23
  */
24
24
  import { TraceTrait } from "./mqtt-plus-trace";
25
- import { ensureError } from "./mqtt-plus-error";
25
+ import { run, ensureError } from "./mqtt-plus-error";
26
26
  import { PLazy } from "./mqtt-plus-util";
27
27
  /* MQTTp Base class with shared infrastructure */
28
28
  export class BaseTrait extends TraceTrait {
@@ -77,6 +77,8 @@ export class BaseTrait extends TraceTrait {
77
77
  async destroy() {
78
78
  this.log("info", "un-hooking from MQTT client");
79
79
  this.mqtt.off("message", this.messageHandler);
80
+ this.onRequest.clear();
81
+ this.onResponse.clear();
80
82
  }
81
83
  /* create a registration for subsequent destruction */
82
84
  makeRegistration(spool, kind, name, key) {
@@ -85,11 +87,18 @@ export class BaseTrait extends TraceTrait {
85
87
  if (!this.onRequest.has(key))
86
88
  throw new Error(`destroy: ${kind} "${name}" not registered`);
87
89
  await spool.unroll(false)?.catch((err) => {
88
- this.error(err, `destroy: failed to cleanup: ${err.message}`);
90
+ const error = ensureError(err, `destroy: ${kind} "${name}" failed to cleanup`);
91
+ this.error(error);
92
+ throw error;
89
93
  });
90
94
  }
91
95
  };
92
96
  }
97
+ /* subscribe to an MQTT topic and spool the unsubscription */
98
+ async subscribeTopicAndSpool(spool, topic, options = {}) {
99
+ await run(`subscribe to MQTT topic "${topic}"`, spool, () => this.subscribeTopic(topic, { qos: 2, ...options }));
100
+ spool.roll(() => this.unsubscribeTopic(topic).catch(() => { }));
101
+ }
93
102
  /* subscribe to an MQTT topic (Promise-based) */
94
103
  async subscribeTopic(topic, options = {}) {
95
104
  this.log("info", `subscribing to MQTT topic "${topic}"`);
@@ -155,7 +164,7 @@ export class BaseTrait extends TraceTrait {
155
164
  });
156
165
  }
157
166
  /* handle incoming MQTT message */
158
- _onMessage(topic, data, packet) {
167
+ _onMessage(topic, data, _packet) {
159
168
  /* parse MQTT topic */
160
169
  const topicMatch = this.options.topicMatch(topic);
161
170
  if (topicMatch === null)
@@ -188,7 +197,7 @@ export class BaseTrait extends TraceTrait {
188
197
  /* dispatch request message */
189
198
  const handler = this.onRequest.get(`${topicMatch.operation}:${message.name}`);
190
199
  if (handler !== undefined) {
191
- Promise.resolve(handler(message, topicMatch.name)).catch((err) => {
200
+ Promise.resolve().then(() => handler(message, topicMatch.name)).catch((err) => {
192
201
  this.error(ensureError(err, `dispatching request message from MQTT topic "${topic}" failed`));
193
202
  });
194
203
  }
@@ -197,7 +206,7 @@ export class BaseTrait extends TraceTrait {
197
206
  /* dispatch response message */
198
207
  const handler = this.onResponse.get(`${topicMatch.operation}:${message.id}`);
199
208
  if (handler !== undefined) {
200
- Promise.resolve(handler(message, topicMatch.name)).catch((err) => {
209
+ Promise.resolve().then(() => handler(message, topicMatch.name)).catch((err) => {
201
210
  this.error(ensureError(err, `dispatching response message from MQTT topic "${topic}" failed`));
202
211
  });
203
212
  }
@@ -167,7 +167,7 @@ export function run(...args) {
167
167
  }
168
168
  else if (typeof args[0] === "string") {
169
169
  description = args[0];
170
- if (args[1] instanceof Spool) {
170
+ if (args[1] instanceof Spool || (args[1] === undefined && typeof args[2] === "function")) {
171
171
  spool = args[1];
172
172
  action = args[2];
173
173
  oncatch = args[3];
@@ -182,7 +182,7 @@ export function run(...args) {
182
182
  }
183
183
  }
184
184
  else {
185
- if (args[0] instanceof Spool) {
185
+ if (args[0] instanceof Spool || (args[0] === undefined && typeof args[1] === "function")) {
186
186
  spool = args[0];
187
187
  action = args[1];
188
188
  oncatch = args[2];
@@ -23,7 +23,7 @@
23
23
  */
24
24
  import { nanoid } from "nanoid";
25
25
  import { AuthTrait } from "./mqtt-plus-auth";
26
- import { run, Spool, ensureError } from "./mqtt-plus-error";
26
+ import { Spool, ensureError } from "./mqtt-plus-error";
27
27
  /* Event Emission Trait */
28
28
  export class EventTrait extends AuthTrait {
29
29
  async event(nameOrConfig, ...args) {
@@ -56,36 +56,37 @@ export class EventTrait extends AuthTrait {
56
56
  const topicB = this.options.topicMake(topicS, "event-emission");
57
57
  const topicD = this.options.topicMake(name, "event-emission", this.options.id);
58
58
  /* remember the registration */
59
- this.onRequest.set(`event-emission:${name}`, (request, topicName) => {
59
+ this.onRequest.set(`event-emission:${name}`, async (request, topicName) => {
60
60
  /* determine event information */
61
61
  const senderId = request.sender;
62
+ if (senderId === undefined || senderId === "")
63
+ throw new Error("invalid request: missing sender");
62
64
  const params = request.params ?? [];
63
65
  /* create information object */
64
- const info = { sender: senderId ?? "" };
66
+ const info = { sender: senderId };
65
67
  if (request.receiver)
66
68
  info.receiver = request.receiver;
67
69
  if (request.meta)
68
70
  info.meta = request.meta;
69
71
  /* asynchronously execute handler */
70
- Promise.resolve().then(async () => {
72
+ try {
71
73
  if (topicName !== request.name)
72
74
  throw new Error(`event name mismatch (topic: "${topicName}", payload: "${request.name}")`);
73
75
  if (auth)
74
76
  info.authenticated = await this.authenticated(request.sender, request.auth, auth);
75
77
  if (info.authenticated !== undefined && !info.authenticated)
76
78
  throw new Error(`authentication on event "${name}" failed`);
77
- return callback(...params, info);
78
- }).catch((result) => {
79
+ await callback(...params, info);
80
+ }
81
+ catch (result) {
79
82
  const error = ensureError(result);
80
83
  this.error(error, `handler for event "${name}" failed`);
81
- });
84
+ }
82
85
  });
83
86
  spool.roll(() => { this.onRequest.delete(`event-emission:${name}`); });
84
87
  /* subscribe to MQTT topics */
85
- await run(`subscribe to MQTT topic "${topicB}"`, spool, () => this.subscribeTopic(topicB, { qos: 2, ...options }));
86
- spool.roll(() => this.unsubscribeTopic(topicB).catch(() => { }));
87
- await run(`subscribe to MQTT topic "${topicD}"`, spool, () => this.subscribeTopic(topicD, { qos: 2, ...options }));
88
- spool.roll(() => this.unsubscribeTopic(topicD).catch(() => { }));
88
+ await this.subscribeTopicAndSpool(spool, topicB, options);
89
+ await this.subscribeTopicAndSpool(spool, topicD, options);
89
90
  /* provide a registration for subsequent destruction */
90
91
  return this.makeRegistration(spool, "event", name, `event-emission:${name}`);
91
92
  }
@@ -57,7 +57,7 @@ export class ServiceTrait extends EventTrait {
57
57
  const topicB = this.options.topicMake(topicS, "service-call-request");
58
58
  const topicD = this.options.topicMake(name, "service-call-request", this.options.id);
59
59
  /* remember the registration */
60
- this.onRequest.set(`service-call-request:${name}`, (request, topicName) => {
60
+ this.onRequest.set(`service-call-request:${name}`, async (request, topicName) => {
61
61
  /* determine request information */
62
62
  const requestId = request.id;
63
63
  const senderId = request.sender;
@@ -70,38 +70,42 @@ export class ServiceTrait extends EventTrait {
70
70
  info.receiver = request.receiver;
71
71
  if (request.meta)
72
72
  info.meta = request.meta;
73
- /* asynchronously execute handler and send response */
74
- Promise.resolve().then(async () => {
73
+ /* execute handler and send response */
74
+ try {
75
75
  if (topicName !== request.name)
76
76
  throw new Error(`service name mismatch (topic: "${topicName}", payload: "${request.name}")`);
77
77
  if (auth)
78
78
  info.authenticated = await this.authenticated(senderId, request.auth, auth);
79
79
  if (info.authenticated !== undefined && !info.authenticated)
80
80
  throw new Error(`service "${name}" failed authentication`);
81
- return callback(...params, info);
82
- }).then((result) => {
81
+ const result = await callback(...params, info);
83
82
  /* create success response message */
84
- return this.msg.makeServiceCallResponse(requestId, result, undefined, this.options.id, senderId);
85
- }, (result) => {
86
- /* create error response message */
87
- const error = ensureError(result);
88
- this.error(error, `handler for service "${name}" failed`);
89
- return this.msg.makeServiceCallResponse(requestId, undefined, error.message, this.options.id, senderId);
90
- }).then((rpcResponse) => {
83
+ const rpcResponse = this.msg.makeServiceCallResponse(requestId, result, undefined, this.options.id, senderId);
91
84
  /* send response message */
92
85
  const encoded = this.codec.encode(rpcResponse);
93
86
  const topic = this.options.topicMake(name, "service-call-response", senderId);
94
- return this.publishToTopic(topic, encoded, { qos: options.qos ?? 2 });
95
- }).catch((err) => {
96
- this.error(err, `handler for service "${name}" failed`);
97
- });
87
+ await this.publishToTopic(topic, encoded, { qos: options.qos ?? 2 });
88
+ }
89
+ catch (err) {
90
+ const error = ensureError(err);
91
+ /* create error response message */
92
+ this.error(error, `handler for service "${name}" failed`);
93
+ const rpcResponse = this.msg.makeServiceCallResponse(requestId, undefined, error.message, this.options.id, senderId);
94
+ /* send response message */
95
+ try {
96
+ const encoded = this.codec.encode(rpcResponse);
97
+ const topic = this.options.topicMake(name, "service-call-response", senderId);
98
+ await this.publishToTopic(topic, encoded, { qos: options.qos ?? 2 });
99
+ }
100
+ catch (err2) {
101
+ this.error(ensureError(err2), `sending error response for service "${name}" failed`);
102
+ }
103
+ }
98
104
  });
99
105
  spool.roll(() => { this.onRequest.delete(`service-call-request:${name}`); });
100
106
  /* subscribe to MQTT topics */
101
- await run(`subscribe to MQTT topic "${topicB}"`, spool, () => this.subscribeTopic(topicB, { qos: 2, ...options }));
102
- spool.roll(() => this.unsubscribeTopic(topicB).catch(() => { }));
103
- await run(`subscribe to MQTT topic "${topicD}"`, spool, () => this.subscribeTopic(topicD, { qos: 2, ...options }));
104
- spool.roll(() => this.unsubscribeTopic(topicD).catch(() => { }));
107
+ await this.subscribeTopicAndSpool(spool, topicB, options);
108
+ await this.subscribeTopicAndSpool(spool, topicD, options);
105
109
  /* provide a registration for subsequent destruction */
106
110
  return this.makeRegistration(spool, "service", name, `service-call-request:${name}`);
107
111
  }
@@ -128,7 +132,9 @@ export class ServiceTrait extends EventTrait {
128
132
  /* create a resource spool */
129
133
  const spool = new Spool();
130
134
  /* generate unique request id */
131
- const requestId = nanoid();
135
+ let requestId = nanoid();
136
+ while (this.onResponse.has(`service-call-response:${requestId}`))
137
+ requestId = nanoid();
132
138
  /* subscribe to MQTT response topic */
133
139
  const responseTopic = this.options.topicMake(name, "service-call-response", this.options.id);
134
140
  await run(`subscribe to MQTT topic "${responseTopic}"`, spool, () => this.subscriptions.subscribe(responseTopic, { qos: options.qos ?? 2 }));
@@ -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();
@@ -77,7 +77,7 @@ export class SinkTrait extends SourceTrait {
77
77
  const topicReqD = this.options.topicMake(name, "sink-push-request", this.options.id);
78
78
  const topicChunkD = this.options.topicMake(name, "sink-push-chunk", this.options.id);
79
79
  /* remember the registration */
80
- this.onRequest.set(`sink-push-request:${name}`, (request, topicName) => {
80
+ this.onRequest.set(`sink-push-request:${name}`, async (request, topicName) => {
81
81
  /* determine information */
82
82
  const requestId = request.id;
83
83
  const params = request.params ?? [];
@@ -100,7 +100,8 @@ export class SinkTrait extends SourceTrait {
100
100
  this.pushSpools.set(requestId, reqSpool);
101
101
  reqSpool.roll(() => { this.pushSpools.delete(requestId); });
102
102
  /* check authentication and prepare stream */
103
- Promise.resolve().then(async () => {
103
+ let ackSent = false;
104
+ try {
104
105
  if (topicName !== request.name)
105
106
  throw new Error(`sink name mismatch (topic: "${topicName}", payload: "${request.name}")`);
106
107
  let authenticated = undefined;
@@ -148,8 +149,12 @@ export class SinkTrait extends SourceTrait {
148
149
  readable.once("error", () => reqSpool.unroll());
149
150
  /* register chunk dispatch callback */
150
151
  this.onResponse.set(`sink-push-chunk:${requestId}`, (chunkParsed, chunkTopicName) => {
151
- if (chunkTopicName !== chunkParsed.name)
152
- throw new Error(`sink name mismatch (topic: "${chunkTopicName}", payload: "${chunkParsed.name}")`);
152
+ if (chunkTopicName !== chunkParsed.name) {
153
+ const error = new Error(`sink name mismatch (topic: "${chunkTopicName}", payload: "${chunkParsed.name}")`);
154
+ readable.destroy(error);
155
+ reqSpool.unroll();
156
+ return;
157
+ }
153
158
  if (chunkParsed.error !== undefined) {
154
159
  readable.destroy(new Error(chunkParsed.error));
155
160
  reqSpool.unroll();
@@ -187,27 +192,33 @@ export class SinkTrait extends SourceTrait {
187
192
  makeMutuallyExclusiveFields(info, "stream", "buffer");
188
193
  /* send ack response */
189
194
  await sendResponse();
195
+ ackSent = true;
190
196
  /* call handler */
191
- return callback(...params, info);
192
- }).catch(async (err) => {
197
+ await callback(...params, info);
198
+ }
199
+ catch (err) {
200
+ const error = ensureError(err, `handler for sink "${name}" failed`);
193
201
  /* cleanup resources */
194
202
  const stream = this.pushStreams.get(requestId);
195
203
  if (stream !== undefined)
196
- stream.destroy(err);
197
- reqSpool.unroll();
198
- /* send error (nak response) */
199
- this.error(err);
200
- await sendResponse(err.message).catch(() => { });
201
- });
204
+ stream.destroy(error);
205
+ await reqSpool.unroll();
206
+ /* send error as nak response or as mid-stream error response */
207
+ this.error(error);
208
+ if (ackSent) {
209
+ const responseMsg = this.msg.makeSinkPushResponse(requestId, name, error.message, this.options.id, sender);
210
+ const message = this.codec.encode(responseMsg);
211
+ await this.publishToTopic(responseTopic, message, { qos: options.qos ?? 2 }).catch(() => { });
212
+ }
213
+ else
214
+ await sendResponse(error.message).catch(() => { });
215
+ }
202
216
  });
203
217
  spool.roll(() => { this.onRequest.delete(`sink-push-request:${name}`); });
204
218
  /* subscribe to MQTT topics */
205
- await run(`subscribe to MQTT topic "${topicReqB}"`, spool, () => this.subscribeTopic(topicReqB, { qos: 2, ...options }));
206
- spool.roll(() => this.unsubscribeTopic(topicReqB).catch(() => { }));
207
- await run(`subscribe to MQTT topic "${topicReqD}"`, spool, () => this.subscribeTopic(topicReqD, { qos: 2, ...options }));
208
- spool.roll(() => this.unsubscribeTopic(topicReqD).catch(() => { }));
209
- await run(`subscribe to MQTT topic "${topicChunkD}"`, spool, () => this.subscribeTopic(topicChunkD, { qos: 2, ...options }));
210
- spool.roll(() => this.unsubscribeTopic(topicChunkD).catch(() => { }));
219
+ await this.subscribeTopicAndSpool(spool, topicReqB, options);
220
+ await this.subscribeTopicAndSpool(spool, topicReqD, options);
221
+ await this.subscribeTopicAndSpool(spool, topicChunkD, options);
211
222
  /* provide a registration for subsequent destruction */
212
223
  return this.makeRegistration(spool, "sink", name, `sink-push-request:${name}`);
213
224
  }
@@ -240,7 +251,10 @@ export class SinkTrait extends SourceTrait {
240
251
  /* create a resource spool */
241
252
  const spool = new Spool();
242
253
  /* generate unique request id */
243
- const requestId = nanoid();
254
+ let requestId = nanoid();
255
+ while (this.onResponse.has(`sink-push-response:${requestId}`)
256
+ || this.onResponse.has(`sink-push-credit:${requestId}`))
257
+ requestId = nanoid();
244
258
  /* subscribe to response topic (for ack/nak) */
245
259
  const responseTopic = this.options.topicMake(name, "sink-push-response", this.options.id);
246
260
  await run(`subscribe to MQTT topic "${responseTopic}"`, spool, () => this.subscriptions.subscribe(responseTopic, { qos: options.qos ?? 2 }));
@@ -278,10 +292,6 @@ export class SinkTrait extends SourceTrait {
278
292
  }
279
293
  });
280
294
  spool.roll(() => { this.onResponse.delete(`sink-push-response:${requestId}`); });
281
- this.onResponse.set(`sink-push-credit:${requestId}`, (_response) => {
282
- refreshTimeout();
283
- });
284
- spool.roll(() => { this.onResponse.delete(`sink-push-credit:${requestId}`); });
285
295
  /* generate and send request message */
286
296
  const auth = this.authenticate();
287
297
  const metaStore = this.metaStore(meta);
@@ -312,6 +322,7 @@ export class SinkTrait extends SourceTrait {
312
322
  gate.replenish(response.credit);
313
323
  refreshTimeout();
314
324
  });
325
+ spool.roll(() => { this.onResponse.delete(`sink-push-credit:${requestId}`); });
315
326
  }
316
327
  /* generate corresponding MQTT topic for chunks */
317
328
  const chunkTopic = this.options.topicMake(name, "sink-push-chunk", receiver);
@@ -331,11 +342,15 @@ export class SinkTrait extends SourceTrait {
331
342
  await sendBufferAsChunks(data, this.options.chunkSize, sendChunk, creditGate, abortSignal);
332
343
  }
333
344
  catch (err) {
334
- const error = ensureError(err).message;
335
- const chunkTopic = this.options.topicMake(name, "sink-push-chunk", receiver);
336
- const chunkMsg = this.msg.makeSinkPushChunk(requestId, name, undefined, error, true, this.options.id, receiver);
337
- const message = this.codec.encode(chunkMsg);
338
- await this.publishToTopic(chunkTopic, message, { qos: 2, ...options }).catch(() => { });
345
+ /* send error chunk only if receiver is known
346
+ (otherwise the sink already received the error via the nak response) */
347
+ if (receiver !== undefined) {
348
+ const error = ensureError(err).message;
349
+ const chunkTopic = this.options.topicMake(name, "sink-push-chunk", receiver);
350
+ const chunkMsg = this.msg.makeSinkPushChunk(requestId, name, undefined, error, true, this.options.id, receiver);
351
+ const message = this.codec.encode(chunkMsg);
352
+ await this.publishToTopic(chunkTopic, message, { qos: 2, ...options }).catch(() => { });
353
+ }
339
354
  throw err;
340
355
  }
341
356
  finally {
@@ -73,7 +73,7 @@ export class SourceTrait extends ServiceTrait {
73
73
  const topicReqD = this.options.topicMake(name, "source-fetch-request", this.options.id);
74
74
  const topicCreditD = this.options.topicMake(name, "source-fetch-credit", this.options.id);
75
75
  /* remember the registration */
76
- this.onRequest.set(`source-fetch-request:${name}`, (request, topicName) => {
76
+ this.onRequest.set(`source-fetch-request:${name}`, async (request, topicName) => {
77
77
  /* determine information */
78
78
  const requestId = request.id;
79
79
  const params = request.params ?? [];
@@ -96,9 +96,13 @@ export class SourceTrait extends ServiceTrait {
96
96
  const message = this.codec.encode(response);
97
97
  await this.publishToTopic(responseTopic, message, { qos: options.qos ?? 2 });
98
98
  };
99
+ /* define abort controller and signal */
100
+ const abortController = new AbortController();
101
+ const abortSignal = abortController.signal;
99
102
  /* utility functions for timeout management */
100
103
  const sourceTimerId = `source-fetch-send:${requestId}`;
101
104
  const refreshSourceTimeout = () => this.timerRefresh(sourceTimerId, () => {
105
+ abortController.abort(new Error(`source fetch "${name}" timed out`));
102
106
  const gate = this.sourceCreditGates.get(requestId);
103
107
  if (gate !== undefined)
104
108
  gate.abort();
@@ -112,28 +116,29 @@ export class SourceTrait extends ServiceTrait {
112
116
  const message = this.codec.encode(chunkMsg);
113
117
  await this.publishToTopic(chunkTopic, message, { qos: options.qos ?? 2 });
114
118
  };
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
119
  /* call the handler callback */
127
120
  let ackSent = false;
128
- Promise.resolve().then(async () => {
121
+ let creditGate;
122
+ try {
129
123
  if (topicName !== request.name)
130
124
  throw new Error(`source name mismatch (topic: "${topicName}", payload: "${request.name}")`);
131
125
  if (auth)
132
126
  info.authenticated = await this.authenticated(request.sender, request.auth, auth);
133
127
  if (info.authenticated !== undefined && !info.authenticated)
134
128
  throw new Error(`source "${name}" failed authentication`);
135
- return callback(...params, info);
136
- }).then(async () => {
129
+ /* handle credit-based flow control (if credit provided in request) */
130
+ const initialCredit = request.credit;
131
+ creditGate = (initialCredit !== undefined && initialCredit > 0)
132
+ ? new CreditGate(initialCredit) : undefined;
133
+ if (creditGate) {
134
+ const gate = creditGate;
135
+ this.sourceCreditGates.set(requestId, gate);
136
+ this.onResponse.set(`source-fetch-credit:${requestId}`, (creditParsed) => {
137
+ gate.replenish(creditParsed.credit);
138
+ refreshSourceTimeout();
139
+ });
140
+ }
141
+ await callback(...params, info);
137
142
  /* check for valid data source */
138
143
  if (!(info.stream instanceof Readable) && !(info.buffer instanceof Promise))
139
144
  throw new Error("handler did not provide data via info.stream or info.buffer fields");
@@ -145,19 +150,24 @@ export class SourceTrait extends ServiceTrait {
145
150
  /* dispatch according to data type */
146
151
  if (info.stream instanceof Readable)
147
152
  /* handle Readable stream result */
148
- await sendStreamAsChunks(info.stream, this.options.chunkSize, sendChunk, creditGate);
153
+ await sendStreamAsChunks(info.stream, this.options.chunkSize, sendChunk, creditGate, abortSignal);
149
154
  else if (info.buffer instanceof Promise)
150
155
  /* handle Buffer result */
151
- await sendBufferAsChunks(await info.buffer, this.options.chunkSize, sendChunk, creditGate);
152
- }).catch((err) => {
156
+ await sendBufferAsChunks(await info.buffer, this.options.chunkSize, sendChunk, creditGate, abortSignal);
157
+ }
158
+ catch (err) {
159
+ /* cleanup stream resource (if provided by handler) */
160
+ const error = ensureError(err, `handler for source "${name}" failed`);
161
+ if (info.stream instanceof Readable && !info.stream.destroyed)
162
+ info.stream.destroy(error);
153
163
  /* send error as nak response or as error chunk */
154
- const error = ensureError(err);
155
- this.error(error, `handler for source "${name}" failed`);
164
+ this.error(error);
156
165
  if (ackSent)
157
- return sendChunk(undefined, error.message, true).catch(() => { });
166
+ await sendChunk(undefined, error.message, true).catch(() => { });
158
167
  else
159
- return sendResponse(error.message).catch(() => { });
160
- }).finally(() => {
168
+ await sendResponse(error.message).catch(() => { });
169
+ }
170
+ finally {
161
171
  /* cleanup resources */
162
172
  clearSourceTimeout();
163
173
  if (creditGate) {
@@ -165,16 +175,13 @@ export class SourceTrait extends ServiceTrait {
165
175
  this.sourceCreditGates.delete(requestId);
166
176
  }
167
177
  this.onResponse.delete(`source-fetch-credit:${requestId}`);
168
- });
178
+ }
169
179
  });
170
180
  spool.roll(() => { this.onRequest.delete(`source-fetch-request:${name}`); });
171
181
  /* subscribe to MQTT topics */
172
- await run(`subscribe to MQTT topic "${topicReqB}"`, spool, () => this.subscribeTopic(topicReqB, { qos: 2, ...options }));
173
- spool.roll(() => this.unsubscribeTopic(topicReqB).catch(() => { }));
174
- await run(`subscribe to MQTT topic "${topicReqD}"`, spool, () => this.subscribeTopic(topicReqD, { qos: 2, ...options }));
175
- spool.roll(() => this.unsubscribeTopic(topicReqD).catch(() => { }));
176
- await run(`subscribe to MQTT topic "${topicCreditD}"`, spool, () => this.subscribeTopic(topicCreditD, { qos: 2, ...options }));
177
- spool.roll(() => this.unsubscribeTopic(topicCreditD).catch(() => { }));
182
+ await this.subscribeTopicAndSpool(spool, topicReqB, options);
183
+ await this.subscribeTopicAndSpool(spool, topicReqD, options);
184
+ await this.subscribeTopicAndSpool(spool, topicCreditD, options);
178
185
  /* provide a registration for subsequent destruction */
179
186
  return this.makeRegistration(spool, "source", name, `source-fetch-request:${name}`);
180
187
  }
@@ -201,7 +208,10 @@ export class SourceTrait extends ServiceTrait {
201
208
  /* create a resource spool */
202
209
  const spool = new Spool();
203
210
  /* generate unique request id */
204
- const requestId = nanoid();
211
+ let requestId = nanoid();
212
+ while (this.onResponse.has(`source-fetch-response:${requestId}`)
213
+ || this.onResponse.has(`source-fetch-chunk:${requestId}`))
214
+ requestId = nanoid();
205
215
  /* subscribe to response topic (for ack/nak) and chunk topic (for data) */
206
216
  const responseTopic = this.options.topicMake(name, "source-fetch-response", this.options.id);
207
217
  const chunkTopic = this.options.topicMake(name, "source-fetch-chunk", this.options.id);
@@ -232,6 +242,7 @@ export class SourceTrait extends ServiceTrait {
232
242
  this.publishToTopic(creditTopic, encoded, { qos: options.qos ?? 2 }).catch((err) => {
233
243
  this.error(err, `sending credit for fetch "${name}" failed`);
234
244
  });
245
+ refreshTimeout();
235
246
  }
236
247
  }
237
248
  });
@@ -242,7 +253,7 @@ export class SourceTrait extends ServiceTrait {
242
253
  const metaP = new Promise((resolve) => {
243
254
  metaResolve = resolve;
244
255
  });
245
- spool.roll(() => { metaResolve?.(undefined); });
256
+ spool.roll(() => { metaResolve(undefined); });
246
257
  /* define timer */
247
258
  const timerId = `source-fetch:${requestId}`;
248
259
  const refreshTimeout = () => {
@@ -259,18 +270,29 @@ export class SourceTrait extends ServiceTrait {
259
270
  stream.once("error", () => spool.unroll());
260
271
  /* register response dispatch callback */
261
272
  this.onResponse.set(`source-fetch-response:${requestId}`, (response) => {
273
+ if (response.name !== name) {
274
+ stream.destroy(new Error(`source name mismatch (expected "${name}", got "${response.name}")`));
275
+ spool.unroll();
276
+ return;
277
+ }
262
278
  if (response.sender)
263
279
  serverId = response.sender;
264
- metaResolve?.(response.meta);
265
280
  if (response.error) {
266
281
  stream.destroy(new Error(response.error));
267
282
  spool.unroll();
268
283
  }
269
- else
284
+ else {
285
+ metaResolve(response.meta);
270
286
  refreshTimeout();
287
+ }
271
288
  });
272
289
  /* register chunk dispatch callback */
273
290
  this.onResponse.set(`source-fetch-chunk:${requestId}`, (response) => {
291
+ if (response.name !== name) {
292
+ stream.destroy(new Error(`source name mismatch (expected "${name}", got "${response.name}")`));
293
+ spool.unroll();
294
+ return;
295
+ }
274
296
  if (response.sender)
275
297
  serverId = response.sender;
276
298
  if (response.error) {