mqtt-plus 1.4.7 → 1.4.8

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,7 +2,17 @@
2
2
  ChangeLog
3
3
  =========
4
4
 
5
- 1.5.0 (2026-02-22)
5
+ 1.4.8 (2026-02-22)
6
+ ------------------
7
+
8
+ - PERFORMANCE: cache encoder/decoder in encoding functions
9
+ - BUGFIX: fix memory leak in destroy() for sink
10
+ - BUGFIX: namespace timers of sink() and source() to avoid conflicts
11
+ - BUGFIX: align event() share default with service/source/sink
12
+ - CLEANUP: refactor RefCountedSubscription class to be redundancy-free
13
+ - CLEANUP: various minor code cleanups (formatting, modernization)
14
+
15
+ 1.4.7 (2026-02-22)
6
16
  ------------------
7
17
 
8
18
  - IMPROVEMENT: provide a global "share" option
@@ -49,6 +49,10 @@ export class AuthTrait extends MetaTrait {
49
49
  async issue(payload) {
50
50
  if (this._credential === null)
51
51
  throw new Error("credential has to be provided before issuing tokens");
52
+ if (payload.roles.length === 0)
53
+ throw new Error("payload.roles must be a non-empty array");
54
+ if (payload.roles.length > 64)
55
+ throw new Error("payload.roles must not exceed 64 roles");
52
56
  const jwt = new SignJWT(payload);
53
57
  jwt.setProtectedHeader({ alg: "HS256", typ: "JWT" });
54
58
  const token = await jwt.sign(this._credential);
@@ -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) {
@@ -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,7 +52,7 @@ 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 */
@@ -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);
@@ -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();
@@ -112,14 +114,15 @@ export class SinkTrait extends SourceTrait {
112
114
  creditGranted: chunkCredit
113
115
  } : undefined;
114
116
  /* utility functions for timeout management */
115
- const refreshPushTimeout = () => this.timerRefresh(requestId, () => {
117
+ const pushTimerId = `sink-push-recv:${requestId}`;
118
+ const refreshPushTimeout = () => this.timerRefresh(pushTimerId, () => {
116
119
  const stream = this.pushStreams.get(requestId);
117
120
  if (stream !== undefined)
118
121
  stream.destroy(new Error("push stream timeout"));
119
122
  const spool = this.pushSpools.get(requestId);
120
123
  spool?.unroll();
121
124
  });
122
- const clearPushTimeout = () => this.timerClear(requestId);
125
+ const clearPushTimeout = () => this.timerClear(pushTimerId);
123
126
  /* create a readable for buffering received chunks */
124
127
  const readable = new Readable({
125
128
  highWaterMark: chunkCredit > 0 ? chunkCredit * this.options.chunkSize : 16 * 1024,
@@ -246,11 +249,12 @@ export class SinkTrait extends SourceTrait {
246
249
  const abortController = new AbortController();
247
250
  const abortSignal = abortController.signal;
248
251
  /* utility function for timeout refresh */
249
- const refreshTimeout = () => this.timerRefresh(requestId, () => {
252
+ const pushTimerId = `sink-push-send:${requestId}`;
253
+ const refreshTimeout = () => this.timerRefresh(pushTimerId, () => {
250
254
  abortController.abort(new Error(`push to sink "${name}" timed out`));
251
255
  spool.unroll();
252
256
  });
253
- spool.roll(() => { this.timerClear(requestId); });
257
+ spool.roll(() => { this.timerClear(pushTimerId); });
254
258
  /* start timeout handler */
255
259
  refreshTimeout();
256
260
  /* send request and wait for response before sending chunks */
@@ -97,12 +97,13 @@ export class SourceTrait extends ServiceTrait {
97
97
  await this.publishToTopic(responseTopic, message, { qos: options.qos ?? 2 });
98
98
  };
99
99
  /* utility functions for timeout management */
100
- const refreshSourceTimeout = () => this.timerRefresh(requestId, () => {
100
+ const sourceTimerId = `source-fetch-send:${requestId}`;
101
+ const refreshSourceTimeout = () => this.timerRefresh(sourceTimerId, () => {
101
102
  const gate = this.sourceCreditGates.get(requestId);
102
103
  if (gate !== undefined)
103
104
  gate.abort();
104
105
  });
105
- const clearSourceTimeout = () => this.timerClear(requestId);
106
+ const clearSourceTimeout = () => this.timerClear(sourceTimerId);
106
107
  refreshSourceTimeout();
107
108
  /* callback for creating and sending a chunk message */
108
109
  const sendChunk = async (chunk, error, final) => {
@@ -10,6 +10,8 @@ declare class RefCountedSubscription {
10
10
  private lingers;
11
11
  private unsubbing;
12
12
  constructor(subscribeFn: (topic: string, options: IClientSubscribeOptions) => Promise<void>, unsubscribeFn: (topic: string) => Promise<void>, lingerMs?: number);
13
+ private incrementCount;
14
+ private decrementCount;
13
15
  subscribe(topic: string, options?: IClientSubscribeOptions): Promise<void>;
14
16
  unsubscribe(topic: string): Promise<void>;
15
17
  flush(): Promise<void>;
@@ -24,20 +24,42 @@
24
24
  import { BaseTrait } from "./mqtt-plus-base";
25
25
  /* reference-counted subscription helper */
26
26
  class RefCountedSubscription {
27
+ /* initial construction with configuration */
27
28
  constructor(subscribeFn, unsubscribeFn, lingerMs = 30 * 1000) {
28
29
  this.subscribeFn = subscribeFn;
29
30
  this.unsubscribeFn = unsubscribeFn;
30
31
  this.lingerMs = lingerMs;
32
+ /* internal state */
31
33
  this.counts = new Map();
32
34
  this.pending = new Map();
33
35
  this.lingers = new Map();
34
36
  this.unsubbing = new Map();
35
37
  }
38
+ /* increment reference count for a topic */
39
+ incrementCount(topic) {
40
+ const count = this.counts.get(topic) ?? 0;
41
+ this.counts.set(topic, count + 1);
42
+ return count;
43
+ }
44
+ /* decrement reference count for a topic */
45
+ decrementCount(topic) {
46
+ const count = this.counts.get(topic);
47
+ if (count) {
48
+ if (count <= 1) {
49
+ this.counts.delete(topic);
50
+ return 0;
51
+ }
52
+ else {
53
+ this.counts.set(topic, count - 1);
54
+ return count - 1;
55
+ }
56
+ }
57
+ return undefined;
58
+ }
36
59
  /* subscribe to a topic (reference-counted) */
37
60
  async subscribe(topic, options = { qos: 2 }) {
38
61
  /* increment count first to reserve our interest */
39
- const count = this.counts.get(topic) ?? 0;
40
- this.counts.set(topic, count + 1);
62
+ const count = this.incrementCount(topic);
41
63
  /* optionally just cancel a pending linger unsubscription
42
64
  (subscription is still kept active on the broker) */
43
65
  const linger = this.lingers.get(topic);
@@ -48,24 +70,31 @@ class RefCountedSubscription {
48
70
  }
49
71
  /* if we are the first, we must perform the actual subscription */
50
72
  if (count === 0) {
73
+ /* create a deferred promise and store it in pending immediately,
74
+ so concurrent subscribers arriving during the await below
75
+ will find and await it instead of returning prematurely */
76
+ let resolve;
77
+ let reject;
78
+ const deferred = new Promise((res, rej) => {
79
+ resolve = res;
80
+ reject = rej;
81
+ });
82
+ this.pending.set(topic, deferred);
51
83
  /* await any in-flight linger unsubscription to avoid a race
52
84
  where the broker processes UNSUBSCRIBE after our SUBSCRIBE */
53
85
  const inflight = this.unsubbing.get(topic);
54
86
  if (inflight)
55
87
  await inflight;
56
- const promise = this.subscribeFn(topic, options).finally(() => {
88
+ /* perform the actual subscription */
89
+ const promise = this.subscribeFn(topic, options).then(() => {
57
90
  this.pending.delete(topic);
91
+ resolve();
58
92
  }).catch((err) => {
59
- const count = this.counts.get(topic);
60
- if (count) {
61
- if (count <= 1)
62
- this.counts.delete(topic);
63
- else
64
- this.counts.set(topic, count - 1);
65
- }
93
+ this.pending.delete(topic);
94
+ this.decrementCount(topic);
95
+ reject(err);
66
96
  throw err;
67
97
  });
68
- this.pending.set(topic, promise);
69
98
  return promise;
70
99
  }
71
100
  else {
@@ -73,39 +102,35 @@ class RefCountedSubscription {
73
102
  const pending = this.pending.get(topic);
74
103
  if (pending)
75
104
  return pending.catch((err) => {
76
- const count = this.counts.get(topic);
77
- if (count) {
78
- if (count <= 1)
79
- this.counts.delete(topic);
80
- else
81
- this.counts.set(topic, count - 1);
82
- }
105
+ this.decrementCount(topic);
83
106
  throw err;
84
107
  });
85
108
  }
86
109
  }
87
110
  /* unsubscribe from a topic (reference-counted) */
88
111
  async unsubscribe(topic) {
89
- const count = this.counts.get(topic);
90
- if (count) {
91
- if (count <= 1) {
92
- this.counts.delete(topic);
93
- if (this.lingerMs > 0) {
94
- /* defer the actual broker unsubscription */
95
- const timer = setTimeout(() => {
96
- this.lingers.delete(topic);
97
- const promise = this.unsubscribeFn(topic).catch(() => { }).finally(() => {
98
- this.unsubbing.delete(topic);
99
- });
100
- this.unsubbing.set(topic, promise);
101
- }, this.lingerMs);
102
- this.lingers.set(topic, timer);
103
- }
104
- else
105
- await this.unsubscribeFn(topic).catch(() => { });
112
+ const count = this.decrementCount(topic);
113
+ if (count === 0) {
114
+ if (this.lingerMs > 0) {
115
+ /* defer the actual broker unsubscription */
116
+ const timer = setTimeout(() => {
117
+ this.lingers.delete(topic);
118
+ const promise = this.unsubscribeFn(topic).catch(() => { }).finally(() => {
119
+ this.unsubbing.delete(topic);
120
+ });
121
+ this.unsubbing.set(topic, promise);
122
+ }, this.lingerMs);
123
+ this.lingers.set(topic, timer);
124
+ }
125
+ else {
126
+ /* perform the unsubscription immediately, but still store the
127
+ promise in unsubbing so a concurrent subscribe can await it */
128
+ const promise = this.unsubscribeFn(topic).catch(() => { }).finally(() => {
129
+ this.unsubbing.delete(topic);
130
+ });
131
+ this.unsubbing.set(topic, promise);
132
+ await promise;
106
133
  }
107
- else
108
- this.counts.set(topic, count - 1);
109
134
  }
110
135
  }
111
136
  /* flush all pending linger timers and unsubscribe */
@@ -38,9 +38,9 @@ class LogEvent {
38
38
  if (this.msg instanceof Promise)
39
39
  this.msg = await this.msg.catch(() => "<resolve-failed>");
40
40
  if (this.data)
41
- for (const field of Object.keys(this.data))
42
- if (this.data[field] instanceof Promise)
43
- this.data[field] = await this.data[field].catch(() => "<resolve-failed>");
41
+ for (const k of Object.keys(this.data))
42
+ if (this.data[k] instanceof Promise)
43
+ this.data[k] = await this.data[k].catch(() => "<resolve-failed>");
44
44
  }
45
45
  /* render log event as string */
46
46
  toString() {
@@ -85,9 +85,9 @@ function uint8ArrayConcat(arrays) {
85
85
  const totalLength = arrays.reduce((acc, value) => acc + value.byteLength, 0);
86
86
  const result = new Uint8Array(totalLength);
87
87
  let offset = 0;
88
- for (const array of arrays) {
89
- result.set(array, offset);
90
- offset += array.byteLength;
88
+ for (const a of arrays) {
89
+ result.set(a, offset);
90
+ offset += a.byteLength;
91
91
  }
92
92
  return result;
93
93
  }