mqtt-plus 1.4.4 → 1.4.5

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,18 @@
2
2
  ChangeLog
3
3
  =========
4
4
 
5
+ 1.4.5 (2026-02-21)
6
+ ------------------
7
+
8
+ - IMPROVEMENT: add a "npm start publish" target for convenient publishing
9
+ - IMPROVEMENT: allow QoS to be overridden and change default to level 2
10
+ - BUGFIX: properly destroy resources on cleanup
11
+ - CLEANUP: factor out registration code into base trait
12
+ - CLEANUP: use ensureError utility function for consistent error handling
13
+ - CLEANUP: rename and protect internal symbols and reduce unnecessary typing
14
+ - CLEANUP: avoid a race condition in topic unsubscription handling
15
+ - CLEANUP: improve about information
16
+
5
17
  1.4.4 (2026-02-21)
6
18
  ------------------
7
19
 
package/README.md CHANGED
@@ -18,10 +18,14 @@ About
18
18
  **MQTT+** is a companion add-on API for the TypeScript/JavaScript
19
19
  API [MQTT.js](https://www.npmjs.com/package/mqtt), providing
20
20
  additional communication patterns with full type safety for
21
- [MQTT](http://mqtt.org/). Currently the essential
21
+ [MQTT](http://mqtt.org/). Currently, the essential
22
22
  [communication patterns](doc/mqtt-plus-comm.md)
23
23
  *Event Emission*, *Service Call*, *Sink Push* and *Source Fetch* are
24
- supported.
24
+ supported. This allows you to implement complex and bi-directional
25
+ client/server and server/server communications over the robust but
26
+ uni-directional [MQTT](http://mqtt.org/) protocol. This is key in
27
+ applications based on the [*Hub & Spoke*](https://en.wikipedia.org/wiki/Spoke%E2%80%93hub_distribution_paradigm)
28
+ communication architecture pattern.
25
29
 
26
30
  Installation
27
31
  ------------
@@ -1,16 +1,18 @@
1
1
  import { MqttClient, type IClientSubscribeOptions, type IClientPublishOptions } from "mqtt";
2
- import type { APISchema } from "./mqtt-plus-api";
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
6
  export declare class BaseTrait<T extends APISchema = APISchema> extends TraceTrait<T> {
6
- protected mqtt: MqttClient;
7
- private _messageHandler;
7
+ private mqtt;
8
+ private messageHandler;
8
9
  protected onRequest: Map<string, (message: any, topicName: string) => void>;
9
10
  protected onResponse: Map<string, (message: any, topicName: string) => void>;
10
11
  constructor(mqtt: MqttClient | null, options?: Partial<APIOptions>);
11
12
  destroy(): Promise<void>;
12
- protected _subscribeTopic(topic: string, options?: Partial<IClientSubscribeOptions>): Promise<void>;
13
- protected _unsubscribeTopic(topic: string): Promise<void>;
14
- protected _publishToTopic(topic: string, message: string | Uint8Array, options?: IClientPublishOptions): Promise<void>;
13
+ protected makeRegistration(spool: Spool, kind: string, name: string, key: string): Registration;
14
+ protected subscribeTopic(topic: string, options?: Partial<IClientSubscribeOptions>): Promise<void>;
15
+ protected unsubscribeTopic(topic: string): Promise<void>;
16
+ protected publishToTopic(topic: string, message: string | Uint8Array, options?: IClientPublishOptions): Promise<void>;
15
17
  private _onMessage;
16
18
  }
@@ -56,7 +56,7 @@ export class BaseTrait extends TraceTrait {
56
56
  this.mqtt = mqtt;
57
57
  /* hook into the MQTT message processing */
58
58
  this.log("info", "hooking into MQTT client");
59
- this._messageHandler = (topic, message, packet) => {
59
+ this.messageHandler = (topic, message, packet) => {
60
60
  /* convert message to codec-specific input format
61
61
  (NOTICE: MQTT.js uses Buffer in its handler signature only,
62
62
  but internally supports string or Buffer, while we are
@@ -72,15 +72,27 @@ export class BaseTrait extends TraceTrait {
72
72
  throw new Error("invalid codec configured");
73
73
  this._onMessage(topic, input, packet);
74
74
  };
75
- this.mqtt.on("message", this._messageHandler);
75
+ this.mqtt.on("message", this.messageHandler);
76
76
  }
77
77
  /* destroy API class */
78
78
  async destroy() {
79
79
  this.log("info", "un-hooking from MQTT client");
80
- this.mqtt.off("message", this._messageHandler);
80
+ this.mqtt.off("message", this.messageHandler);
81
+ }
82
+ /* create a registration for subsequent destruction */
83
+ makeRegistration(spool, kind, name, key) {
84
+ return {
85
+ destroy: async () => {
86
+ if (!this.onRequest.has(key))
87
+ throw new Error(`destroy: ${kind} "${name}" not registered`);
88
+ await spool.unroll(false)?.catch((err) => {
89
+ this.error(err, `destroy: failed to cleanup: ${err.message}`);
90
+ });
91
+ }
92
+ };
81
93
  }
82
94
  /* subscribe to an MQTT topic (Promise-based) */
83
- async _subscribeTopic(topic, options = {}) {
95
+ async subscribeTopic(topic, options = {}) {
84
96
  this.log("info", `subscribing to MQTT topic "${topic}"`);
85
97
  return new Promise((resolve, reject) => {
86
98
  this.mqtt.subscribe(topic, { qos: 2, ...options }, (err, _granted) => {
@@ -94,7 +106,7 @@ export class BaseTrait extends TraceTrait {
94
106
  });
95
107
  }
96
108
  /* unsubscribe from an MQTT topic (Promise-based) */
97
- async _unsubscribeTopic(topic) {
109
+ async unsubscribeTopic(topic) {
98
110
  this.log("info", `unsubscribing from MQTT topic "${topic}"`);
99
111
  return new Promise((resolve, reject) => {
100
112
  this.mqtt.unsubscribe(topic, (err, _packet) => {
@@ -108,7 +120,7 @@ export class BaseTrait extends TraceTrait {
108
120
  });
109
121
  }
110
122
  /* publish to an MQTT topic (Promise-based) */
111
- async _publishToTopic(topic, message, options = {}) {
123
+ async publishToTopic(topic, message, options = {}) {
112
124
  /* determine buffer */
113
125
  if (typeof message === "string")
114
126
  this.log("info", `publishing to MQTT topic "${topic}" (type: string, length: ${message.length} chars)`);
@@ -82,20 +82,12 @@ export class EventTrait extends AuthTrait {
82
82
  });
83
83
  spool.roll(() => { this.onRequest.delete(`event-emission:${name}`); });
84
84
  /* 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(() => { }));
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(() => { }));
89
89
  /* provide a registration for subsequent destruction */
90
- return {
91
- destroy: async () => {
92
- if (!this.onRequest.has(`event-emission:${name}`))
93
- throw new Error(`destroy: event "${name}" not registered`);
94
- await spool.unroll(false)?.catch((err) => {
95
- this.error(err, `destroy: failed to cleanup: ${err.message}`);
96
- });
97
- }
98
- };
90
+ return this.makeRegistration(spool, "event", name, `event-emission:${name}`);
99
91
  }
100
92
  emit(eventOrConfig, ...args) {
101
93
  /* determine actual parameters */
@@ -131,10 +123,10 @@ export class EventTrait extends AuthTrait {
131
123
  /* produce result */
132
124
  if (dry)
133
125
  /* return publish information */
134
- return { topic, payload: message, options: { qos: 0, ...options } };
126
+ return { topic, payload: message, options: { qos: 2, ...options } };
135
127
  else
136
128
  /* publish message to MQTT topic */
137
- this._publishToTopic(topic, message, { qos: 0, ...options }).catch((err) => {
129
+ this.publishToTopic(topic, message, { qos: 2, ...options }).catch((err) => {
138
130
  this.error(err, `emitting event "${event}" failed`);
139
131
  });
140
132
  }
@@ -91,27 +91,19 @@ export class ServiceTrait extends EventTrait {
91
91
  /* send response message */
92
92
  const encoded = this.codec.encode(rpcResponse);
93
93
  const topic = this.options.topicMake(name, "service-call-response", senderId);
94
- return this._publishToTopic(topic, encoded, { qos: 2 });
94
+ return this.publishToTopic(topic, encoded, { qos: options.qos ?? 2 });
95
95
  }).catch((err) => {
96
96
  this.error(err, `handler for service "${name}" failed`);
97
97
  });
98
98
  });
99
99
  spool.roll(() => { this.onRequest.delete(`service-call-request:${name}`); });
100
100
  /* 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(() => { }));
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(() => { }));
105
105
  /* provide a registration for subsequent destruction */
106
- return {
107
- destroy: async () => {
108
- if (!this.onRequest.has(`service-call-request:${name}`))
109
- throw new Error(`destroy: service "${name}" not registered`);
110
- await spool.unroll(false)?.catch((err) => {
111
- this.error(err, `destroy: failed to cleanup: ${err.message}`);
112
- });
113
- }
114
- };
106
+ return this.makeRegistration(spool, "service", name, `service-call-request:${name}`);
115
107
  }
116
108
  async call(nameOrConfig, ...args) {
117
109
  /* determine actual parameters */
@@ -171,7 +163,7 @@ export class ServiceTrait extends EventTrait {
171
163
  /* generate corresponding MQTT topic */
172
164
  const topic = this.options.topicMake(name, "service-call-request", receiver);
173
165
  /* publish message to MQTT topic */
174
- await run(`publish service request as MQTT message to topic "${topic}"`, spool, () => this._publishToTopic(topic, message, { qos: 2, ...options }));
166
+ await run(`publish service request as MQTT message to topic "${topic}"`, spool, () => this.publishToTopic(topic, message, { qos: 2, ...options }));
175
167
  return promise;
176
168
  }
177
169
  }
@@ -7,6 +7,7 @@ import type { AuthOption } from "./mqtt-plus-auth";
7
7
  export declare class SinkTrait<T extends APISchema = APISchema> extends SourceTrait<T> {
8
8
  private pushStreams;
9
9
  private pushSpools;
10
+ destroy(): Promise<void>;
10
11
  sink<K extends SinkKeys<T> & string>(name: K, callback: WithInfo<T[K], InfoSink>): Promise<Registration>;
11
12
  sink<K extends SinkKeys<T> & string>(config: {
12
13
  name: K;
@@ -26,7 +26,7 @@ import { Readable } from "node:stream";
26
26
  import { nanoid } from "nanoid";
27
27
  /* internal requirements */
28
28
  import { CreditGate, streamToBuffer, sendBufferAsChunks, sendStreamAsChunks, makeMutuallyExclusiveFields } from "./mqtt-plus-util";
29
- import { run, Spool } from "./mqtt-plus-error";
29
+ import { run, Spool, ensureError } from "./mqtt-plus-error";
30
30
  import { SourceTrait } from "./mqtt-plus-source";
31
31
  /* Sink Push Trait */
32
32
  export class SinkTrait extends SourceTrait {
@@ -36,6 +36,14 @@ export class SinkTrait extends SourceTrait {
36
36
  this.pushStreams = new Map();
37
37
  this.pushSpools = new Map();
38
38
  }
39
+ /* destroy sink trait */
40
+ async destroy() {
41
+ for (const stream of this.pushStreams.values())
42
+ stream.destroy();
43
+ this.pushStreams.clear();
44
+ this.pushSpools.clear();
45
+ await super.destroy();
46
+ }
39
47
  async sink(nameOrConfig, ...args) {
40
48
  /* determine actual parameters */
41
49
  let name;
@@ -90,7 +98,7 @@ export class SinkTrait extends SourceTrait {
90
98
  const credit = chunkCredit > 0 ? chunkCredit : undefined;
91
99
  const response = this.msg.makeSinkPushResponse(requestId, name, error, this.options.id, sender, authToken, metaStore, credit);
92
100
  const message = this.codec.encode(response);
93
- await this._publishToTopic(responseTopic, message, { qos: 2 });
101
+ await this.publishToTopic(responseTopic, message, { qos: options.qos ?? 2 });
94
102
  };
95
103
  /* create a resource spool for stream cleanup */
96
104
  const reqSpool = new Spool();
@@ -130,7 +138,7 @@ export class SinkTrait extends SourceTrait {
130
138
  const creditMsg = this.msg.makeSinkPushCredit(requestId, name, creditToGrant, this.options.id, sender);
131
139
  const encoded = this.codec.encode(creditMsg);
132
140
  const creditTopic = this.options.topicMake(name, "sink-push-credit", sender);
133
- this._publishToTopic(creditTopic, encoded, { qos: 2 }).catch((err) => {
141
+ this.publishToTopic(creditTopic, encoded, { qos: options.qos ?? 2 }).catch((err) => {
134
142
  this.error(err, `sending credit for push "${name}" failed`);
135
143
  });
136
144
  refreshPushTimeout();
@@ -189,22 +197,14 @@ export class SinkTrait extends SourceTrait {
189
197
  });
190
198
  spool.roll(() => { this.onRequest.delete(`sink-push-request:${name}`); });
191
199
  /* subscribe to MQTT topics */
192
- await run(`subscribe to MQTT topic "${topicReqB}"`, spool, () => this._subscribeTopic(topicReqB, { qos: 2, ...options }));
193
- spool.roll(() => this._unsubscribeTopic(topicReqB).catch(() => { }));
194
- await run(`subscribe to MQTT topic "${topicReqD}"`, spool, () => this._subscribeTopic(topicReqD, { qos: 2, ...options }));
195
- spool.roll(() => this._unsubscribeTopic(topicReqD).catch(() => { }));
196
- await run(`subscribe to MQTT topic "${topicChunkD}"`, spool, () => this._subscribeTopic(topicChunkD, { qos: 2, ...options }));
197
- spool.roll(() => this._unsubscribeTopic(topicChunkD).catch(() => { }));
200
+ await run(`subscribe to MQTT topic "${topicReqB}"`, spool, () => this.subscribeTopic(topicReqB, { qos: 2, ...options }));
201
+ spool.roll(() => this.unsubscribeTopic(topicReqB).catch(() => { }));
202
+ await run(`subscribe to MQTT topic "${topicReqD}"`, spool, () => this.subscribeTopic(topicReqD, { qos: 2, ...options }));
203
+ spool.roll(() => this.unsubscribeTopic(topicReqD).catch(() => { }));
204
+ await run(`subscribe to MQTT topic "${topicChunkD}"`, spool, () => this.subscribeTopic(topicChunkD, { qos: 2, ...options }));
205
+ spool.roll(() => this.unsubscribeTopic(topicChunkD).catch(() => { }));
198
206
  /* provide a registration for subsequent destruction */
199
- return {
200
- destroy: async () => {
201
- if (!this.onRequest.has(`sink-push-request:${name}`))
202
- throw new Error(`destroy: sink "${name}" not established`);
203
- await spool.unroll(false)?.catch((err) => {
204
- this.error(err, `destroy: failed to cleanup: ${err.message}`);
205
- });
206
- }
207
- };
207
+ return this.makeRegistration(spool, "sink", name, `sink-push-request:${name}`);
208
208
  }
209
209
  async push(nameOrConfig, ...args) {
210
210
  /* determine actual parameters */
@@ -290,7 +290,7 @@ export class SinkTrait extends SourceTrait {
290
290
  const request = this.msg.makeSinkPushRequest(requestId, name, params, this.options.id, receiver, auth, metaStore);
291
291
  const message = this.codec.encode(request);
292
292
  const requestTopic = this.options.topicMake(name, "sink-push-request", receiver);
293
- run(`publish push request as MQTT message to topic "${requestTopic}"`, spool, () => this._publishToTopic(requestTopic, message, { qos: 2, ...options })).catch((err) => {
293
+ run(`publish push request as MQTT message to topic "${requestTopic}"`, spool, () => this.publishToTopic(requestTopic, message, { qos: 2, ...options })).catch((err) => {
294
294
  reject(err);
295
295
  });
296
296
  });
@@ -305,7 +305,7 @@ export class SinkTrait extends SourceTrait {
305
305
  /* subscribe to credit topic if flow control is active */
306
306
  if (creditGate) {
307
307
  const creditTopic = this.options.topicMake(name, "sink-push-credit", this.options.id);
308
- await run(`subscribe to MQTT topic "${creditTopic}"`, spool, () => this.subscriptions.subscribe(creditTopic, { qos: 2 }));
308
+ await run(`subscribe to MQTT topic "${creditTopic}"`, spool, () => this.subscriptions.subscribe(creditTopic, { qos: options.qos ?? 2 }));
309
309
  spool.roll(() => this.subscriptions.unsubscribe(creditTopic));
310
310
  const gate = creditGate;
311
311
  spool.roll(() => { gate.abort(); });
@@ -322,7 +322,7 @@ export class SinkTrait extends SourceTrait {
322
322
  refreshTimeout();
323
323
  const chunkMsg = this.msg.makeSinkPushChunk(requestId, name, chunk, error, final, this.options.id, receiver);
324
324
  const message = this.codec.encode(chunkMsg);
325
- await this._publishToTopic(chunkTopic, message, { qos: 2, ...options });
325
+ await this.publishToTopic(chunkTopic, message, { qos: 2, ...options });
326
326
  };
327
327
  /* iterate over all chunks of the buffer */
328
328
  if (data instanceof Readable)
@@ -333,11 +333,11 @@ export class SinkTrait extends SourceTrait {
333
333
  await sendBufferAsChunks(data, this.options.chunkSize, sendChunk, creditGate, abortSignal);
334
334
  }
335
335
  catch (err) {
336
- const error = err instanceof Error ? err.message : String(err);
336
+ const error = ensureError(err).message;
337
337
  const chunkTopic = this.options.topicMake(name, "sink-push-chunk", receiver);
338
338
  const chunkMsg = this.msg.makeSinkPushChunk(requestId, name, undefined, error, true, this.options.id, receiver);
339
339
  const message = this.codec.encode(chunkMsg);
340
- await this._publishToTopic(chunkTopic, message, { qos: 2, ...options }).catch(() => { });
340
+ await this.publishToTopic(chunkTopic, message, { qos: 2, ...options }).catch(() => { });
341
341
  throw err;
342
342
  }
343
343
  finally {
@@ -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
+ destroy(): Promise<void>;
9
10
  source<K extends SourceKeys<T> & string>(name: K, callback: WithInfo<T[K], InfoSource>): Promise<Registration>;
10
11
  source<K extends SourceKeys<T> & string>(config: {
11
12
  name: K;
@@ -35,6 +35,13 @@ export class SourceTrait extends ServiceTrait {
35
35
  /* source state */
36
36
  this.sourceCreditGates = new Map();
37
37
  }
38
+ /* destroy source trait */
39
+ async destroy() {
40
+ for (const gate of this.sourceCreditGates.values())
41
+ gate.abort();
42
+ this.sourceCreditGates.clear();
43
+ await super.destroy();
44
+ }
38
45
  async source(nameOrConfig, ...args) {
39
46
  /* determine actual parameters */
40
47
  let name;
@@ -88,7 +95,7 @@ export class SourceTrait extends ServiceTrait {
88
95
  const metaStore = this.metaStore(info.meta);
89
96
  const response = this.msg.makeSourceFetchResponse(requestId, name, error, this.options.id, sender, authToken, metaStore);
90
97
  const message = this.codec.encode(response);
91
- await this._publishToTopic(responseTopic, message, { qos: 2 });
98
+ await this.publishToTopic(responseTopic, message, { qos: options.qos ?? 2 });
92
99
  };
93
100
  /* utility functions for timeout management */
94
101
  const refreshSourceTimeout = () => this.timerRefresh(requestId, () => {
@@ -103,7 +110,7 @@ export class SourceTrait extends ServiceTrait {
103
110
  refreshSourceTimeout();
104
111
  const chunkMsg = this.msg.makeSourceFetchChunk(requestId, name, chunk, error, final, this.options.id, sender);
105
112
  const message = this.codec.encode(chunkMsg);
106
- await this._publishToTopic(chunkTopic, message, { qos: 2 });
113
+ await this.publishToTopic(chunkTopic, message, { qos: options.qos ?? 2 });
107
114
  };
108
115
  /* handle credit-based flow control (if credit provided in request) */
109
116
  const initialCredit = request.credit;
@@ -162,22 +169,14 @@ export class SourceTrait extends ServiceTrait {
162
169
  });
163
170
  spool.roll(() => { this.onRequest.delete(`source-fetch-request:${name}`); });
164
171
  /* subscribe to MQTT topics */
165
- await run(`subscribe to MQTT topic "${topicReqB}"`, spool, () => this._subscribeTopic(topicReqB, { qos: 2, ...options }));
166
- spool.roll(() => this._unsubscribeTopic(topicReqB).catch(() => { }));
167
- await run(`subscribe to MQTT topic "${topicReqD}"`, spool, () => this._subscribeTopic(topicReqD, { qos: 2, ...options }));
168
- spool.roll(() => this._unsubscribeTopic(topicReqD).catch(() => { }));
169
- await run(`subscribe to MQTT topic "${topicCreditD}"`, spool, () => this._subscribeTopic(topicCreditD, { qos: 2, ...options }));
170
- spool.roll(() => this._unsubscribeTopic(topicCreditD).catch(() => { }));
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(() => { }));
171
178
  /* provide a registration for subsequent destruction */
172
- return {
173
- destroy: async () => {
174
- if (!this.onRequest.has(`source-fetch-request:${name}`))
175
- throw new Error(`destroy: source "${name}" not established`);
176
- await spool.unroll(false)?.catch((err) => {
177
- this.error(err, `destroy: failed to cleanup: ${err.message}`);
178
- });
179
- }
180
- };
179
+ return this.makeRegistration(spool, "source", name, `source-fetch-request:${name}`);
181
180
  }
182
181
  async fetch(nameOrConfig, ...args) {
183
182
  /* determine actual parameters */
@@ -230,7 +229,7 @@ export class SourceTrait extends ServiceTrait {
230
229
  const creditMsg = this.msg.makeSourceFetchCredit(requestId, name, creditToGrant, this.options.id, targetId);
231
230
  const encoded = this.codec.encode(creditMsg);
232
231
  const creditTopic = this.options.topicMake(name, "source-fetch-credit", targetId);
233
- this._publishToTopic(creditTopic, encoded, { qos: 2 }).catch((err) => {
232
+ this.publishToTopic(creditTopic, encoded, { qos: options.qos ?? 2 }).catch((err) => {
234
233
  this.error(err, `sending credit for fetch "${name}" failed`);
235
234
  });
236
235
  }
@@ -311,7 +310,7 @@ export class SourceTrait extends ServiceTrait {
311
310
  /* generate corresponding MQTT topic */
312
311
  const topic = this.options.topicMake(name, "source-fetch-request", receiver);
313
312
  /* publish message to MQTT topic */
314
- run(`publish fetch request as MQTT message to topic "${topic}"`, spool, () => this._publishToTopic(topic, message, { qos: 2, ...options })).catch((err) => {
313
+ run(`publish fetch request as MQTT message to topic "${topic}"`, spool, () => this.publishToTopic(topic, message, { qos: 2, ...options })).catch((err) => {
315
314
  stream.destroy(ensureError(err));
316
315
  spool.unroll();
317
316
  });
@@ -8,6 +8,7 @@ declare class RefCountedSubscription {
8
8
  private counts;
9
9
  private pending;
10
10
  private lingers;
11
+ private unsubbing;
11
12
  constructor(subscribeFn: (topic: string, options: IClientSubscribeOptions) => Promise<void>, unsubscribeFn: (topic: string) => Promise<void>, lingerMs?: number);
12
13
  subscribe(topic: string, options?: IClientSubscribeOptions): Promise<void>;
13
14
  unsubscribe(topic: string): Promise<void>;
@@ -31,6 +31,7 @@ class RefCountedSubscription {
31
31
  this.counts = new Map();
32
32
  this.pending = new Map();
33
33
  this.lingers = new Map();
34
+ this.unsubbing = new Map();
34
35
  }
35
36
  /* subscribe to a topic (reference-counted) */
36
37
  async subscribe(topic, options = { qos: 2 }) {
@@ -47,6 +48,11 @@ class RefCountedSubscription {
47
48
  }
48
49
  /* if we are the first, we must perform the actual subscription */
49
50
  if (count === 0) {
51
+ /* await any in-flight linger unsubscription to avoid a race
52
+ where the broker processes UNSUBSCRIBE after our SUBSCRIBE */
53
+ const inflight = this.unsubbing.get(topic);
54
+ if (inflight)
55
+ await inflight;
50
56
  const promise = this.subscribeFn(topic, options).finally(() => {
51
57
  this.pending.delete(topic);
52
58
  }).catch((err) => {
@@ -88,7 +94,10 @@ class RefCountedSubscription {
88
94
  /* defer the actual broker unsubscription */
89
95
  const timer = setTimeout(() => {
90
96
  this.lingers.delete(topic);
91
- this.unsubscribeFn(topic).catch(() => { });
97
+ const promise = this.unsubscribeFn(topic).catch(() => { }).finally(() => {
98
+ this.unsubbing.delete(topic);
99
+ });
100
+ this.unsubbing.set(topic, promise);
92
101
  }, this.lingerMs);
93
102
  this.lingers.set(topic, timer);
94
103
  }
@@ -116,7 +125,7 @@ class RefCountedSubscription {
116
125
  export class SubscriptionTrait extends BaseTrait {
117
126
  constructor() {
118
127
  super(...arguments);
119
- this.subscriptions = new RefCountedSubscription((topic, options) => this._subscribeTopic(topic, options), (topic) => this._unsubscribeTopic(topic));
128
+ this.subscriptions = new RefCountedSubscription((topic, options) => this.subscribeTopic(topic, options), (topic) => this.unsubscribeTopic(topic));
120
129
  }
121
130
  /* destroy subscription trait */
122
131
  async destroy() {
@@ -17,7 +17,7 @@ export declare class TraceTrait<T extends APISchema = APISchema> extends MsgTrai
17
17
  off(event: "log", callback: (log: LogEvent) => void): void;
18
18
  protected emitEvent(event: "error", error: Error): void;
19
19
  protected emitEvent(event: "log", log: LogEvent): void;
20
- log(level: string, msg: string | Promise<string>, data?: Record<string, Promise<any> | any>): void;
21
- error(error: Error, msg?: string): void;
20
+ protected log(level: string, msg: string | Promise<string>, data?: Record<string, Promise<any> | any>): void;
21
+ protected error(error: Error, msg?: string): void;
22
22
  }
23
23
  export {};