mqtt-plus 1.4.7 → 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.
Files changed (36) hide show
  1. package/CHANGELOG.md +26 -1
  2. package/dst-stage1/mqtt-plus-auth.js +18 -5
  3. package/dst-stage1/mqtt-plus-base.d.ts +2 -1
  4. package/dst-stage1/mqtt-plus-base.js +14 -5
  5. package/dst-stage1/mqtt-plus-codec.js +4 -0
  6. package/dst-stage1/mqtt-plus-encode.d.ts +2 -0
  7. package/dst-stage1/mqtt-plus-encode.js +5 -2
  8. package/dst-stage1/mqtt-plus-error.js +2 -2
  9. package/dst-stage1/mqtt-plus-event.js +15 -14
  10. package/dst-stage1/mqtt-plus-meta.js +4 -3
  11. package/dst-stage1/mqtt-plus-service.js +27 -21
  12. package/dst-stage1/mqtt-plus-sink.js +50 -27
  13. package/dst-stage1/mqtt-plus-source.js +32 -20
  14. package/dst-stage1/mqtt-plus-subscription.d.ts +2 -0
  15. package/dst-stage1/mqtt-plus-subscription.js +73 -38
  16. package/dst-stage1/mqtt-plus-trace.js +3 -3
  17. package/dst-stage1/mqtt-plus-util.js +7 -5
  18. package/dst-stage1/mqtt-plus-version.js +1 -1
  19. package/dst-stage2/mqtt-plus.cjs.js +233 -153
  20. package/dst-stage2/mqtt-plus.esm.js +233 -153
  21. package/dst-stage2/mqtt-plus.umd.js +12 -12
  22. package/package.json +1 -1
  23. package/src/mqtt-plus-auth.ts +19 -5
  24. package/src/mqtt-plus-base.ts +21 -7
  25. package/src/mqtt-plus-codec.ts +4 -0
  26. package/src/mqtt-plus-encode.ts +6 -2
  27. package/src/mqtt-plus-error.ts +2 -2
  28. package/src/mqtt-plus-event.ts +15 -16
  29. package/src/mqtt-plus-meta.ts +7 -6
  30. package/src/mqtt-plus-service.ts +31 -25
  31. package/src/mqtt-plus-sink.ts +55 -31
  32. package/src/mqtt-plus-source.ts +36 -24
  33. package/src/mqtt-plus-subscription.ts +80 -38
  34. package/src/mqtt-plus-trace.ts +11 -11
  35. package/src/mqtt-plus-util.ts +8 -5
  36. package/src/mqtt-plus-version.ts +1 -1
package/CHANGELOG.md CHANGED
@@ -2,7 +2,32 @@
2
2
  ChangeLog
3
3
  =========
4
4
 
5
- 1.5.0 (2026-02-22)
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
+
20
+ 1.4.8 (2026-02-22)
21
+ ------------------
22
+
23
+ - PERFORMANCE: cache encoder/decoder in encoding functions
24
+ - BUGFIX: fix memory leak in destroy() for sink
25
+ - BUGFIX: namespace timers of sink() and source() to avoid conflicts
26
+ - BUGFIX: align event() share default with service/source/sink
27
+ - CLEANUP: refactor RefCountedSubscription class to be redundancy-free
28
+ - CLEANUP: various minor code cleanups (formatting, modernization)
29
+
30
+ 1.4.7 (2026-02-22)
6
31
  ------------------
7
32
 
8
33
  - IMPROVEMENT: provide a global "share" option
@@ -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,26 +43,37 @@ 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 */
49
51
  async issue(payload) {
50
52
  if (this._credential === null)
51
53
  throw new Error("credential has to be provided before issuing tokens");
54
+ if (payload.roles.length === 0)
55
+ throw new Error("payload.roles must be a non-empty array");
56
+ if (payload.roles.length > 64)
57
+ throw new Error("payload.roles must not exceed 64 roles");
52
58
  const jwt = new SignJWT(payload);
53
59
  jwt.setProtectedHeader({ alg: "HS256", typ: "JWT" });
54
60
  const token = await jwt.sign(this._credential);
55
61
  return token;
56
62
  }
57
63
  authenticate(token, remove) {
58
- if (token === undefined)
59
- 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
+ }
60
68
  else if (remove === true)
61
69
  this._tokens.delete(token);
62
- 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");
63
75
  this._tokens.add(token);
76
+ }
64
77
  }
65
78
  /* validate client-side token on server-side */
66
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
  }
@@ -86,6 +86,8 @@ class Codec {
86
86
  if (this.format === "cbor") {
87
87
  if (!(data instanceof Uint8Array))
88
88
  throw new Error("failed to decode CBOR format (data type is not Uint8Array)");
89
+ if (data.byteLength === 0)
90
+ throw new Error("failed to decode CBOR format (data is empty)");
89
91
  try {
90
92
  result = CBOR.decode(data, { tags: this.tags });
91
93
  }
@@ -96,6 +98,8 @@ class Codec {
96
98
  else if (this.format === "json") {
97
99
  if (typeof data !== "string")
98
100
  throw new Error("failed to decode JSON format (data type is not string)");
101
+ if (data.length === 0)
102
+ throw new Error("failed to decode JSON format (data is empty)");
99
103
  try {
100
104
  result = JSONX.parse(data);
101
105
  }
@@ -2,6 +2,8 @@ import { Buffer } from "node:buffer";
2
2
  import type { APISchema } from "./mqtt-plus-api";
3
3
  import { CodecTrait } from "./mqtt-plus-codec";
4
4
  export declare class EncodeTrait<T extends APISchema = APISchema> extends CodecTrait<T> {
5
+ private static encoder;
6
+ private static decoder;
5
7
  str2buf(data: string): Uint8Array;
6
8
  buf2str(data: Uint8Array): string;
7
9
  arr2buf(data: Buffer | Uint8Array | Int8Array): Uint8Array;
@@ -26,13 +26,16 @@ import { Buffer } from "node:buffer";
26
26
  import { CodecTrait } from "./mqtt-plus-codec";
27
27
  /* encoding trait */
28
28
  export class EncodeTrait extends CodecTrait {
29
+ /* reusable encoder/decoder instances */
30
+ static { this.encoder = new TextEncoder(); }
31
+ static { this.decoder = new TextDecoder(); }
29
32
  /* convert character string to buffer */
30
33
  str2buf(data) {
31
- return new TextEncoder().encode(data);
34
+ return EncodeTrait.encoder.encode(data);
32
35
  }
33
36
  /* convert buffer to character string */
34
37
  buf2str(data) {
35
- return new TextDecoder().decode(data);
38
+ return EncodeTrait.decoder.decode(data);
36
39
  }
37
40
  /* convert byte-based typed array to buffer */
38
41
  arr2buf(data) {
@@ -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) {
@@ -31,14 +31,14 @@ export class EventTrait extends AuthTrait {
31
31
  let name;
32
32
  let callback;
33
33
  let options = {};
34
- let share;
34
+ let share = this.options.share;
35
35
  let auth;
36
36
  if (typeof nameOrConfig === "object" && nameOrConfig !== null) {
37
37
  /* object-based API */
38
38
  name = nameOrConfig.name;
39
39
  callback = nameOrConfig.callback;
40
40
  options = nameOrConfig.options ?? {};
41
- share = nameOrConfig.share;
41
+ share = nameOrConfig.share ?? this.options.share;
42
42
  auth = nameOrConfig.auth;
43
43
  }
44
44
  else {
@@ -52,40 +52,41 @@ export class EventTrait extends AuthTrait {
52
52
  if (this.onRequest.has(`event-emission:${name}`))
53
53
  throw new Error(`event: event "${name}" already registered`);
54
54
  /* generate the corresponding MQTT topics for broadcast and direct use */
55
- const topicS = share ? `$share/${share}/${name}` : name;
55
+ const topicS = share !== "" ? `$share/${share}/${name}` : name;
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
  }
@@ -29,10 +29,11 @@ export class MetaTrait extends TimerTrait {
29
29
  /* internal state */
30
30
  this._meta = new Map();
31
31
  }
32
- meta(key, value) {
33
- if (key === undefined)
32
+ meta(...args) {
33
+ const [key, value] = args;
34
+ if (args.length === 0)
34
35
  return Object.fromEntries(this._meta);
35
- else if (arguments.length === 1)
36
+ else if (args.length === 1)
36
37
  return this._meta.get(key);
37
38
  else if (value === undefined || value === null)
38
39
  this._meta.delete(key);
@@ -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 }));
@@ -40,6 +40,8 @@ export class SinkTrait extends SourceTrait {
40
40
  async destroy() {
41
41
  for (const stream of this.pushStreams.values())
42
42
  stream.destroy();
43
+ for (const spool of this.pushSpools.values())
44
+ await spool.unroll();
43
45
  this.pushStreams.clear();
44
46
  this.pushSpools.clear();
45
47
  await super.destroy();
@@ -75,7 +77,7 @@ export class SinkTrait extends SourceTrait {
75
77
  const topicReqD = this.options.topicMake(name, "sink-push-request", this.options.id);
76
78
  const topicChunkD = this.options.topicMake(name, "sink-push-chunk", this.options.id);
77
79
  /* remember the registration */
78
- this.onRequest.set(`sink-push-request:${name}`, (request, topicName) => {
80
+ this.onRequest.set(`sink-push-request:${name}`, async (request, topicName) => {
79
81
  /* determine information */
80
82
  const requestId = request.id;
81
83
  const params = request.params ?? [];
@@ -98,7 +100,8 @@ export class SinkTrait extends SourceTrait {
98
100
  this.pushSpools.set(requestId, reqSpool);
99
101
  reqSpool.roll(() => { this.pushSpools.delete(requestId); });
100
102
  /* check authentication and prepare stream */
101
- Promise.resolve().then(async () => {
103
+ let ackSent = false;
104
+ try {
102
105
  if (topicName !== request.name)
103
106
  throw new Error(`sink name mismatch (topic: "${topicName}", payload: "${request.name}")`);
104
107
  let authenticated = undefined;
@@ -112,14 +115,15 @@ export class SinkTrait extends SourceTrait {
112
115
  creditGranted: chunkCredit
113
116
  } : undefined;
114
117
  /* utility functions for timeout management */
115
- const refreshPushTimeout = () => this.timerRefresh(requestId, () => {
118
+ const pushTimerId = `sink-push-recv:${requestId}`;
119
+ const refreshPushTimeout = () => this.timerRefresh(pushTimerId, () => {
116
120
  const stream = this.pushStreams.get(requestId);
117
121
  if (stream !== undefined)
118
122
  stream.destroy(new Error("push stream timeout"));
119
123
  const spool = this.pushSpools.get(requestId);
120
124
  spool?.unroll();
121
125
  });
122
- const clearPushTimeout = () => this.timerClear(requestId);
126
+ const clearPushTimeout = () => this.timerClear(pushTimerId);
123
127
  /* create a readable for buffering received chunks */
124
128
  const readable = new Readable({
125
129
  highWaterMark: chunkCredit > 0 ? chunkCredit * this.options.chunkSize : 16 * 1024,
@@ -145,8 +149,12 @@ export class SinkTrait extends SourceTrait {
145
149
  readable.once("error", () => reqSpool.unroll());
146
150
  /* register chunk dispatch callback */
147
151
  this.onResponse.set(`sink-push-chunk:${requestId}`, (chunkParsed, chunkTopicName) => {
148
- if (chunkTopicName !== chunkParsed.name)
149
- 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
+ }
150
158
  if (chunkParsed.error !== undefined) {
151
159
  readable.destroy(new Error(chunkParsed.error));
152
160
  reqSpool.unroll();
@@ -184,27 +192,34 @@ export class SinkTrait extends SourceTrait {
184
192
  makeMutuallyExclusiveFields(info, "stream", "buffer");
185
193
  /* send ack response */
186
194
  await sendResponse();
195
+ ackSent = true;
187
196
  /* call handler */
188
- return callback(...params, info);
189
- }).catch(async (err) => {
197
+ return await callback(...params, info);
198
+ }
199
+ catch (err) {
200
+ const error = ensureError(err);
190
201
  /* cleanup resources */
191
202
  const stream = this.pushStreams.get(requestId);
192
203
  if (stream !== undefined)
193
- stream.destroy(err);
204
+ stream.destroy(error);
194
205
  reqSpool.unroll();
195
- /* send error (nak response) */
196
- this.error(err);
197
- await sendResponse(err.message).catch(() => { });
198
- });
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
+ }
199
217
  });
200
218
  spool.roll(() => { this.onRequest.delete(`sink-push-request:${name}`); });
201
219
  /* subscribe to MQTT topics */
202
- await run(`subscribe to MQTT topic "${topicReqB}"`, spool, () => this.subscribeTopic(topicReqB, { qos: 2, ...options }));
203
- spool.roll(() => this.unsubscribeTopic(topicReqB).catch(() => { }));
204
- await run(`subscribe to MQTT topic "${topicReqD}"`, spool, () => this.subscribeTopic(topicReqD, { qos: 2, ...options }));
205
- spool.roll(() => this.unsubscribeTopic(topicReqD).catch(() => { }));
206
- await run(`subscribe to MQTT topic "${topicChunkD}"`, spool, () => this.subscribeTopic(topicChunkD, { qos: 2, ...options }));
207
- 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);
208
223
  /* provide a registration for subsequent destruction */
209
224
  return this.makeRegistration(spool, "sink", name, `sink-push-request:${name}`);
210
225
  }
@@ -237,7 +252,10 @@ export class SinkTrait extends SourceTrait {
237
252
  /* create a resource spool */
238
253
  const spool = new Spool();
239
254
  /* generate unique request id */
240
- 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();
241
259
  /* subscribe to response topic (for ack/nak) */
242
260
  const responseTopic = this.options.topicMake(name, "sink-push-response", this.options.id);
243
261
  await run(`subscribe to MQTT topic "${responseTopic}"`, spool, () => this.subscriptions.subscribe(responseTopic, { qos: options.qos ?? 2 }));
@@ -246,11 +264,12 @@ export class SinkTrait extends SourceTrait {
246
264
  const abortController = new AbortController();
247
265
  const abortSignal = abortController.signal;
248
266
  /* utility function for timeout refresh */
249
- const refreshTimeout = () => this.timerRefresh(requestId, () => {
267
+ const pushTimerId = `sink-push-send:${requestId}`;
268
+ const refreshTimeout = () => this.timerRefresh(pushTimerId, () => {
250
269
  abortController.abort(new Error(`push to sink "${name}" timed out`));
251
270
  spool.unroll();
252
271
  });
253
- spool.roll(() => { this.timerClear(requestId); });
272
+ spool.roll(() => { this.timerClear(pushTimerId); });
254
273
  /* start timeout handler */
255
274
  refreshTimeout();
256
275
  /* send request and wait for response before sending chunks */
@@ -327,11 +346,15 @@ export class SinkTrait extends SourceTrait {
327
346
  await sendBufferAsChunks(data, this.options.chunkSize, sendChunk, creditGate, abortSignal);
328
347
  }
329
348
  catch (err) {
330
- const error = ensureError(err).message;
331
- const chunkTopic = this.options.topicMake(name, "sink-push-chunk", receiver);
332
- const chunkMsg = this.msg.makeSinkPushChunk(requestId, name, undefined, error, true, this.options.id, receiver);
333
- const message = this.codec.encode(chunkMsg);
334
- 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
+ }
335
358
  throw err;
336
359
  }
337
360
  finally {