mqtt-plus 1.4.8 → 1.4.9

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,21 @@
2
2
  ChangeLog
3
3
  =========
4
4
 
5
+ 1.4.9 (2026-02-22)
6
+ ------------------
7
+
8
+ - BUGFIX: clear internal response handlers in destroy()
9
+ - BUGFIX: correctly decrement counter in subscription handling
10
+ - BUGFIX: let the registration's destroy() throw errors correctly
11
+ - BUGFIX: correctly handle synchronous response handler failures
12
+ - BUGFIX: fix internal chunkToBuffer() method for byte-length calculation
13
+ - BUGFIX: apply the same limits on sender size for authenticate() as on receiver side
14
+ - BUGFIX: check for name/topic mismatches also in source fetch()
15
+ - REFACTOR: factor out topic subscription and spooling topic unsubscription into helper function
16
+ - REFACTOR: make response handlers async functions to correctly catch their failures
17
+ - IMPROVEMENT: use a cached TextEncoder in utility functions
18
+ - IMPROVEMENT: ensure generated NanoIDs do not conflict with pending requests
19
+
5
20
  1.4.8 (2026-02-22)
6
21
  ------------------
7
22
 
@@ -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), `handler 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 }));
@@ -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,34 @@ 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
+ return await callback(...params, info);
198
+ }
199
+ catch (err) {
200
+ const error = ensureError(err);
193
201
  /* cleanup resources */
194
202
  const stream = this.pushStreams.get(requestId);
195
203
  if (stream !== undefined)
196
- stream.destroy(err);
204
+ stream.destroy(error);
197
205
  reqSpool.unroll();
198
- /* send error (nak response) */
199
- this.error(err);
200
- await sendResponse(err.message).catch(() => { });
201
- });
206
+ /* send error as nak response or as error chunk */
207
+ this.error(error);
208
+ 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(() => { });
213
+ }
214
+ else
215
+ await sendResponse(error.message).catch(() => { });
216
+ }
202
217
  });
203
218
  spool.roll(() => { this.onRequest.delete(`sink-push-request:${name}`); });
204
219
  /* 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(() => { }));
220
+ await this.subscribeTopicAndSpool(spool, topicReqB, options);
221
+ await this.subscribeTopicAndSpool(spool, topicReqD, options);
222
+ await this.subscribeTopicAndSpool(spool, topicChunkD, options);
211
223
  /* provide a registration for subsequent destruction */
212
224
  return this.makeRegistration(spool, "sink", name, `sink-push-request:${name}`);
213
225
  }
@@ -240,7 +252,10 @@ export class SinkTrait extends SourceTrait {
240
252
  /* create a resource spool */
241
253
  const spool = new Spool();
242
254
  /* generate unique request id */
243
- const requestId = nanoid();
255
+ let requestId = nanoid();
256
+ while (this.onResponse.has(`sink-push-response:${requestId}`)
257
+ || this.onResponse.has(`sink-push-credit:${requestId}`))
258
+ requestId = nanoid();
244
259
  /* subscribe to response topic (for ack/nak) */
245
260
  const responseTopic = this.options.topicMake(name, "sink-push-response", this.options.id);
246
261
  await run(`subscribe to MQTT topic "${responseTopic}"`, spool, () => this.subscriptions.subscribe(responseTopic, { qos: options.qos ?? 2 }));
@@ -331,11 +346,15 @@ export class SinkTrait extends SourceTrait {
331
346
  await sendBufferAsChunks(data, this.options.chunkSize, sendChunk, creditGate, abortSignal);
332
347
  }
333
348
  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(() => { });
349
+ /* send error chunk only if receiver is known
350
+ (otherwise the sink already received the error via the nak response) */
351
+ if (receiver !== undefined) {
352
+ const error = ensureError(err).message;
353
+ 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);
355
+ const message = this.codec.encode(chunkMsg);
356
+ await this.publishToTopic(chunkTopic, message, { qos: 2, ...options }).catch(() => { });
357
+ }
339
358
  throw err;
340
359
  }
341
360
  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 ?? [];
@@ -125,15 +125,14 @@ export class SourceTrait extends ServiceTrait {
125
125
  }
126
126
  /* call the handler callback */
127
127
  let ackSent = false;
128
- Promise.resolve().then(async () => {
128
+ try {
129
129
  if (topicName !== request.name)
130
130
  throw new Error(`source name mismatch (topic: "${topicName}", payload: "${request.name}")`);
131
131
  if (auth)
132
132
  info.authenticated = await this.authenticated(request.sender, request.auth, auth);
133
133
  if (info.authenticated !== undefined && !info.authenticated)
134
134
  throw new Error(`source "${name}" failed authentication`);
135
- return callback(...params, info);
136
- }).then(async () => {
135
+ await callback(...params, info);
137
136
  /* check for valid data source */
138
137
  if (!(info.stream instanceof Readable) && !(info.buffer instanceof Promise))
139
138
  throw new Error("handler did not provide data via info.stream or info.buffer fields");
@@ -149,15 +148,17 @@ export class SourceTrait extends ServiceTrait {
149
148
  else if (info.buffer instanceof Promise)
150
149
  /* handle Buffer result */
151
150
  await sendBufferAsChunks(await info.buffer, this.options.chunkSize, sendChunk, creditGate);
152
- }).catch((err) => {
151
+ }
152
+ catch (err) {
153
153
  /* send error as nak response or as error chunk */
154
154
  const error = ensureError(err);
155
155
  this.error(error, `handler for source "${name}" failed`);
156
156
  if (ackSent)
157
- return sendChunk(undefined, error.message, true).catch(() => { });
157
+ await sendChunk(undefined, error.message, true).catch(() => { });
158
158
  else
159
- return sendResponse(error.message).catch(() => { });
160
- }).finally(() => {
159
+ await sendResponse(error.message).catch(() => { });
160
+ }
161
+ finally {
161
162
  /* cleanup resources */
162
163
  clearSourceTimeout();
163
164
  if (creditGate) {
@@ -165,16 +166,13 @@ export class SourceTrait extends ServiceTrait {
165
166
  this.sourceCreditGates.delete(requestId);
166
167
  }
167
168
  this.onResponse.delete(`source-fetch-credit:${requestId}`);
168
- });
169
+ }
169
170
  });
170
171
  spool.roll(() => { this.onRequest.delete(`source-fetch-request:${name}`); });
171
172
  /* 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(() => { }));
173
+ await this.subscribeTopicAndSpool(spool, topicReqB, options);
174
+ await this.subscribeTopicAndSpool(spool, topicReqD, options);
175
+ await this.subscribeTopicAndSpool(spool, topicCreditD, options);
178
176
  /* provide a registration for subsequent destruction */
179
177
  return this.makeRegistration(spool, "source", name, `source-fetch-request:${name}`);
180
178
  }
@@ -201,7 +199,10 @@ export class SourceTrait extends ServiceTrait {
201
199
  /* create a resource spool */
202
200
  const spool = new Spool();
203
201
  /* generate unique request id */
204
- const requestId = nanoid();
202
+ let requestId = nanoid();
203
+ while (this.onResponse.has(`source-fetch-response:${requestId}`)
204
+ || this.onResponse.has(`source-fetch-chunk:${requestId}`))
205
+ requestId = nanoid();
205
206
  /* subscribe to response topic (for ack/nak) and chunk topic (for data) */
206
207
  const responseTopic = this.options.topicMake(name, "source-fetch-response", this.options.id);
207
208
  const chunkTopic = this.options.topicMake(name, "source-fetch-chunk", this.options.id);
@@ -242,7 +243,7 @@ export class SourceTrait extends ServiceTrait {
242
243
  const metaP = new Promise((resolve) => {
243
244
  metaResolve = resolve;
244
245
  });
245
- spool.roll(() => { metaResolve?.(undefined); });
246
+ spool.roll(() => { metaResolve(undefined); });
246
247
  /* define timer */
247
248
  const timerId = `source-fetch:${requestId}`;
248
249
  const refreshTimeout = () => {
@@ -259,9 +260,14 @@ export class SourceTrait extends ServiceTrait {
259
260
  stream.once("error", () => spool.unroll());
260
261
  /* register response dispatch callback */
261
262
  this.onResponse.set(`source-fetch-response:${requestId}`, (response) => {
263
+ if (response.name !== name) {
264
+ stream.destroy(new Error(`source name mismatch (expected "${name}", got "${response.name}")`));
265
+ spool.unroll();
266
+ return;
267
+ }
262
268
  if (response.sender)
263
269
  serverId = response.sender;
264
- metaResolve?.(response.meta);
270
+ metaResolve(response.meta);
265
271
  if (response.error) {
266
272
  stream.destroy(new Error(response.error));
267
273
  spool.unroll();
@@ -271,6 +277,11 @@ export class SourceTrait extends ServiceTrait {
271
277
  });
272
278
  /* register chunk dispatch callback */
273
279
  this.onResponse.set(`source-fetch-chunk:${requestId}`, (response) => {
280
+ if (response.name !== name) {
281
+ stream.destroy(new Error(`source name mismatch (expected "${name}", got "${response.name}")`));
282
+ spool.unroll();
283
+ return;
284
+ }
274
285
  if (response.sender)
275
286
  serverId = response.sender;
276
287
  if (response.error) {
@@ -44,7 +44,7 @@ class RefCountedSubscription {
44
44
  /* decrement reference count for a topic */
45
45
  decrementCount(topic) {
46
46
  const count = this.counts.get(topic);
47
- if (count) {
47
+ if (count !== undefined) {
48
48
  if (count <= 1) {
49
49
  this.counts.delete(topic);
50
50
  return 0;
@@ -136,15 +136,25 @@ class RefCountedSubscription {
136
136
  /* flush all pending linger timers and unsubscribe */
137
137
  async flush() {
138
138
  /* determine all topics with potentially active subscriptions */
139
- const topics = new Set([...this.counts.keys(), ...this.lingers.keys()]);
139
+ const topics = new Set([
140
+ ...this.counts.keys(),
141
+ ...this.lingers.keys(),
142
+ ...this.pending.keys(),
143
+ ...this.unsubbing.keys()
144
+ ]);
140
145
  /* cancel all pending linger timers first (synchronously) */
141
146
  for (const topic of this.lingers.keys())
142
147
  clearTimeout(this.lingers.get(topic));
143
148
  this.lingers.clear();
144
149
  this.counts.clear();
150
+ /* wait for any in-flight subscribe/unsubscribe operations to settle first */
151
+ await Promise.allSettled([...this.pending.values(), ...this.unsubbing.values()]);
145
152
  /* then unsubscribe from all potentially active topics */
146
153
  for (const topic of topics)
147
154
  await this.unsubscribeFn(topic).catch(() => { });
155
+ /* clear remaining internal state */
156
+ this.pending.clear();
157
+ this.unsubbing.clear();
148
158
  }
149
159
  }
150
160
  /* Subscription trait with shared MQTT subscription management */
@@ -80,6 +80,8 @@ export class CreditGate {
80
80
  this.waiters.shift()(true);
81
81
  }
82
82
  }
83
+ /* reusable encoder instance */
84
+ const textEncoder = new TextEncoder();
83
85
  /* concatenate elements of a Uint8Array array */
84
86
  function uint8ArrayConcat(arrays) {
85
87
  const totalLength = arrays.reduce((acc, value) => acc + value.byteLength, 0);
@@ -95,11 +97,11 @@ function uint8ArrayConcat(arrays) {
95
97
  function chunkToBuffer(chunk) {
96
98
  let buffer;
97
99
  if (chunk instanceof Buffer)
98
- buffer = new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.length);
100
+ buffer = new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength);
99
101
  else if (chunk instanceof Uint8Array)
100
102
  buffer = chunk;
101
103
  else if (typeof chunk === "string")
102
- buffer = new TextEncoder().encode(chunk);
104
+ buffer = textEncoder.encode(chunk);
103
105
  else
104
106
  throw new Error("invalid chunk type: expected Buffer, Uint8Array, or string");
105
107
  return buffer;
@@ -29,7 +29,7 @@ export const versionToNum = (str) => {
29
29
  const minor = parseInt(m[2], 10);
30
30
  if (minor > 99)
31
31
  throw new Error("invalid version string: minor version exceeds 99");
32
- return parseInt(m[1], 10) + minor / 100;
32
+ return parseInt(m[1], 10) * 100 + minor;
33
33
  };
34
34
  export const VERSION = __VERSION__;
35
35
  /* package version (numeric format) */