mqtt-plus 1.4.4 → 1.4.6

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,25 @@
2
2
  ChangeLog
3
3
  =========
4
4
 
5
+ 1.4.6 (2026-02-22)
6
+ ------------------
7
+
8
+ - IMPROVEMENT: improve rendering of about information and add chalk for sample code
9
+ - DOCUMENTATION: update sample code
10
+ - DOCUMENTATION: adjust about description
11
+
12
+ 1.4.5 (2026-02-21)
13
+ ------------------
14
+
15
+ - IMPROVEMENT: add a "npm start publish" target for convenient publishing
16
+ - IMPROVEMENT: allow QoS to be overridden and change default to level 2
17
+ - BUGFIX: properly destroy resources on cleanup
18
+ - CLEANUP: factor out registration code into base trait
19
+ - CLEANUP: use ensureError utility function for consistent error handling
20
+ - CLEANUP: rename and protect internal symbols and reduce unnecessary typing
21
+ - CLEANUP: avoid a race condition in topic unsubscription handling
22
+ - CLEANUP: improve about information
23
+
5
24
  1.4.4 (2026-02-21)
6
25
  ------------------
7
26
 
package/README.md CHANGED
@@ -16,12 +16,25 @@ About
16
16
  -----
17
17
 
18
18
  **MQTT+** is a companion add-on API for the TypeScript/JavaScript
19
- API [MQTT.js](https://www.npmjs.com/package/mqtt), providing
20
- additional communication patterns with full type safety for
21
- [MQTT](http://mqtt.org/). Currently the essential
22
- [communication patterns](doc/mqtt-plus-comm.md)
23
- *Event Emission*, *Service Call*, *Sink Push* and *Source Fetch* are
24
- supported.
19
+ API [MQTT.js](https://www.npmjs.com/package/mqtt), designed to
20
+ extend [MQTT](http://mqtt.org/) with higher-level
21
+ [communication patterns](doc/mqtt-plus-comm.md) while preserving full type-safety.
22
+ It provides four core communication patterns: fire-and-forget *Event
23
+ Emission*, RPC-style *Service Call*, stream-based *Sink Push*, and
24
+ stream-based *Source Fetch*.
25
+ These patterns enable structured,
26
+ bi-directional client/server and server/server communication
27
+ on top of [MQTT](http://mqtt.org/)’s inherently uni-directional publish/subscribe model.
28
+ Internally, the communication is based on the exchange of typed [CBOR](https://www.rfc-editor.org/rfc/rfc8949.html)
29
+ or [JSON](https://ecma-international.org/publications-and-standards/standards/ecma-404/) messages.
30
+
31
+ The result is a more expressive and maintainable messaging layer
32
+ without sacrificing [MQTT](http://mqtt.org/)’s excellent robustness and
33
+ scalability.
34
+ **MQTT+** is particularly well suited for systems built around a
35
+ [*Hub & Spoke*](https://en.wikipedia.org/wiki/Spoke%E2%80%93hub_distribution_paradigm)
36
+ communication architecture, where typed API contracts and controlled interaction flows are
37
+ critical for reliability and long-term maintainability.
25
38
 
26
39
  Installation
27
40
  ------------
@@ -33,16 +46,25 @@ $ npm install mqtt mqtt-plus
33
46
  Usage
34
47
  -----
35
48
 
36
- ### API:
37
-
38
- The API type defines the available endpoints. Use the marker types
39
- `Event<T>`, `Service<T>`, `Source<T>`, and `Sink<T>` to declare the
40
- communication pattern of each endpoint:
49
+ The following is a simple but self-contained example usage of
50
+ **MQTT+** based on a common API, a server part, a client part,
51
+ and a MQTT infrastructure. It can be found in the file
52
+ [sample.ts](sample/sample.ts) and can be executed from the **MQTT+**
53
+ source tree via `npm start sample` (assuming the prerequisite *Docker* is
54
+ available for the underlying *Mosquitto* broker based infrastructure):
41
55
 
42
56
  ```ts
57
+ import { Readable } from "node:stream"
58
+ import chalk from "chalk"
59
+ import Mosquitto from "mosquitto"
60
+ import MQTT from "mqtt"
61
+ import MQTTp from "mqtt-plus"
43
62
  import type { Event, Service, Source, Sink } from "mqtt-plus"
63
+ ```
44
64
 
45
- export type API = {
65
+ ```ts
66
+ /* ==== SAMPLE COMMON API ==== */
67
+ type API = {
46
68
  "example/sample": Event<(a1: string, a2: number) => void>
47
69
  "example/hello": Service<(a1: string, a2: number) => string>
48
70
  "example/download": Source<(filename: string) => void>
@@ -50,67 +72,84 @@ export type API = {
50
72
  }
51
73
  ```
52
74
 
53
- The marker types ensure that `event()` and `emit()` only accept
54
- `Event<T>` endpoints, `service()` and `call()` only accept
55
- `Service<T>` endpoints, `source()` and `fetch()` only
56
- accept `Source<T>` endpoints, and `sink()` and `push()` only
57
- accept `Sink<T>` endpoints.
58
-
59
- ### Server:
60
-
61
75
  ```ts
62
- import MQTT from "mqtt"
63
- import MQTTp from "mqtt-plus"
64
- import type { API } from [...]
65
-
66
- const mqtt = MQTT.connect("wss://127.0.0.1:8883", { [...] })
67
- const mqttp = new MQTTp<API>(mqtt)
68
-
69
- mqtt.on("connect", async () => {
70
- await mqttp.event("example/sample", (a1, a2, info) => {
71
- console.log("example/sample: SERVER:", a1, a2, info.sender)
76
+ /* ==== SAMPLE SERVER ==== */
77
+ const Server = async (api: MQTTp<API>, log: (msg: string, ...args: any[]) => void) => {
78
+ await api.event("example/sample", (a1, a2) => {
79
+ log("example/sample: SERVER:", a1, a2)
72
80
  })
73
- await mqttp.service("example/hello", (a1, a2, info) => {
74
- console.log("example/hello: SERVER:", a1, a2, info.sender)
81
+ await api.service("example/hello", (a1, a2) => {
82
+ log("example/hello: SERVER:", a1, a2)
75
83
  return `${a1}:${a2}`
76
84
  })
77
- await mqttp.source("example/download", async (filename, info) => {
78
- console.log("example/download: SERVER:", filename, info.sender)
79
- info.buffer = Promise.resolve(mqttp.str2buf(`the ${filename} content`))
85
+ await api.source("example/download", async (filename, info) => {
86
+ log("example/download: SERVER:", filename)
87
+ const input = new Readable()
88
+ input.push(api.str2buf(`the ${filename} content`))
89
+ input.push(null)
90
+ info.stream = readable
80
91
  })
81
- await mqttp.sink("example/upload", async (filename, info) => {
82
- console.log("example/upload: SERVER:", filename, info.sender)
83
- const data = await info.buffer
84
- console.log("received", data.length, "bytes")
92
+ await api.sink("example/upload", async (filename, info) => {
93
+ log("example/upload: SERVER:", filename)
94
+ const chunks: Uint8Array[] = []
95
+ info.stream!.on("data", (chunk: Uint8Array) => { chunks.push(chunk) })
96
+ await new Promise<void>((resolve) => { info.stream!.once("end", resolve) })
97
+ const total = chunks.reduce((n, c) => n + c.length, 0)
98
+ log("received", total, "bytes")
85
99
  })
86
- })
100
+ }
87
101
  ```
88
102
 
89
- ### Client:
90
-
91
103
  ```ts
92
- import MQTT from "mqtt"
93
- import MQTTp from "mqtt-plus"
94
- import type { API } from [...]
95
-
96
- const mqtt = MQTT.connect("wss://127.0.0.1:8883", { [...] })
97
- const mqttp = new MQTTp<API>(mqtt)
104
+ /* ==== SAMPLE CLIENT ==== */
105
+ const Client = async (api: MQTTp<API>, log: (msg: string, ...args: any[]) => void) => {
106
+ api.emit("example/sample", "world", 42)
107
+
108
+ const callOutput = await api.call("example/hello", "world", 42)
109
+ log("example/hello: CLIENT:", callOutput)
110
+
111
+ const output = await api.fetch("example/download", "foo")
112
+ const chunks: Uint8Array[] = []
113
+ output.stream.on("data", (chunk: Uint8Array) => { chunks.push(chunk) })
114
+ await new Promise<void>((resolve) => { output.stream.on("end", resolve) })
115
+ const data = api.buf2str(Buffer.concat(chunks))
116
+ log("example/download: CLIENT:", data)
117
+
118
+ const input = new Readable()
119
+ input.push(api.str2buf("uploaded content"))
120
+ input.push(null)
121
+ await api.push("example/upload", input, "myfile.txt")
122
+ }
123
+ ```
98
124
 
125
+ ```ts
126
+ /* ==== SAMPLE INFRASTRUCTURE ==== */
127
+ process.on("uncaughtException", (err: Error): void => {
128
+ console.error(chalk.red(`ERROR: ${err.stack ?? err.message}`))
129
+ console.log(chalk.yellow(mosquitto.logs()))
130
+ process.exit(1)
131
+ })
132
+ const mosquitto = new Mosquitto({
133
+ listen: [ { protocol: "mqtt", address: "127.0.0.1", port: 1883 } ]
134
+ })
135
+ await mosquitto.start()
136
+ const mqtt = MQTT.connect("mqtt://127.0.0.1:1883", {
137
+ username: "example", password: "example"
138
+ })
139
+ const api = new MQTTp<API>(mqtt)
140
+ api.on("log", async (entry) => {
141
+ await entry.resolve()
142
+ console.log(chalk.grey(`api: ${entry}`))
143
+ })
144
+ const log = (msg: string, ...args: any[]) => {
145
+ console.log(chalk.bold.blue("app:"), chalk.blue(msg), chalk.red(JSON.stringify(args)))
146
+ }
99
147
  mqtt.on("connect", async () => {
100
- mqttp.emit("example/sample", "world", 42)
101
-
102
- const callOutput = await mqttp.call("example/hello", "world", 42)
103
- console.log("example/hello: CLIENT:", callOutput)
104
-
105
- const fetchOutput = await mqttp.fetch("example/download", "foo")
106
- const data = mqttp.buf2str(await fetchOutput.buffer)
107
- console.log("example/download: CLIENT:", data)
108
-
109
- const pushInput = mqttp.str2buf("uploaded content")
110
- await mqttp.push("example/upload", pushInput, "myfile.txt")
111
-
112
- mqttp.destroy()
113
- mqtt.end()
148
+ await Server(api, log)
149
+ await Client(api, log)
150
+ await api.destroy()
151
+ await mqtt.endAsync()
152
+ await mosquitto.stop()
114
153
  })
115
154
  ```
116
155
 
@@ -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>;