mqtt-plus 1.2.0 → 1.2.1

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,13 @@
2
2
  ChangeLog
3
3
  =========
4
4
 
5
+ 1.2.1 (2026-02-07)
6
+ ------------------
7
+
8
+ - REFACTOR: use a reference counting subscription class
9
+ - CLEANUP: improve internal validation logic
10
+ - CLEANUP: various code cleanups
11
+
5
12
  1.2.0 (2026-02-06)
6
13
  ------------------
7
14
 
@@ -41,9 +41,9 @@ export class AuthTrait extends MetaTrait {
41
41
  if (credential.length === 0)
42
42
  throw new Error("credential must not be empty");
43
43
  /* use a derived key with minimum length of 32 for JWT HS256 */
44
- const pw = new TextEncoder().encode(credential);
45
- const st = new TextEncoder().encode("mqtt-plus");
46
- this._credential = pbkdf2.deriveKey(sha256.SHA256, pw, st, 100000, 32);
44
+ const pass = new TextEncoder().encode(credential);
45
+ const salt = new TextEncoder().encode("mqtt-plus");
46
+ this._credential = pbkdf2.deriveKey(sha256.SHA256, pass, salt, 100000, 32);
47
47
  }
48
48
  /* issue client-side token on server-side */
49
49
  async issue(payload) {
@@ -93,6 +93,10 @@ export class AuthTrait extends MetaTrait {
93
93
  continue;
94
94
  if (payload.id && payload.id !== clientId)
95
95
  continue;
96
+ if (!Array.isArray(payload.roles))
97
+ continue;
98
+ if (payload.roles.length > 64)
99
+ continue;
96
100
  for (const role of roles) {
97
101
  if (payload.roles.includes(role)) {
98
102
  authenticated = true;
@@ -7,10 +7,10 @@ export declare class JSONX {
7
7
  static parse(json: string): unknown;
8
8
  }
9
9
  declare class Codec {
10
- private type;
10
+ private format;
11
11
  private types;
12
12
  private tags;
13
- constructor(type: "cbor" | "json");
13
+ constructor(format: "cbor" | "json");
14
14
  encode(data: unknown): Uint8Array | string;
15
15
  decode(data: Uint8Array | string): unknown;
16
16
  }
@@ -46,8 +46,8 @@ export class JSONX {
46
46
  }
47
47
  /* the encoder/decoder abstraction */
48
48
  class Codec {
49
- constructor(type) {
50
- this.type = type;
49
+ constructor(format) {
50
+ this.format = format;
51
51
  this.types = new CBOR.TypeEncoderMap();
52
52
  this.tags = new Map();
53
53
  /* support direct encoding/decoding of Buffer */
@@ -61,7 +61,7 @@ class Codec {
61
61
  }
62
62
  encode(data) {
63
63
  let result;
64
- if (this.type === "cbor") {
64
+ if (this.format === "cbor") {
65
65
  try {
66
66
  result = CBOR.encode(data, { types: this.types });
67
67
  }
@@ -69,7 +69,7 @@ class Codec {
69
69
  throw new Error("failed to encode CBOR format", { cause: ex });
70
70
  }
71
71
  }
72
- else if (this.type === "json") {
72
+ else if (this.format === "json") {
73
73
  try {
74
74
  result = JSONX.stringify(data);
75
75
  }
@@ -78,12 +78,12 @@ class Codec {
78
78
  }
79
79
  }
80
80
  else
81
- throw new Error(`invalid format "${this.type}"`);
81
+ throw new Error(`invalid format "${this.format}"`);
82
82
  return result;
83
83
  }
84
84
  decode(data) {
85
85
  let result;
86
- if (this.type === "cbor") {
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
89
  try {
@@ -93,7 +93,7 @@ class Codec {
93
93
  throw new Error("failed to decode CBOR format", { cause: ex });
94
94
  }
95
95
  }
96
- else if (this.type === "json") {
96
+ else if (this.format === "json") {
97
97
  if (typeof data !== "string")
98
98
  throw new Error("failed to decode JSON format (data type is not string)");
99
99
  try {
@@ -104,7 +104,7 @@ class Codec {
104
104
  }
105
105
  }
106
106
  else
107
- throw new Error(`invalid format "${this.type}"`);
107
+ throw new Error(`invalid format "${this.format}"`);
108
108
  return result;
109
109
  }
110
110
  }
@@ -45,7 +45,7 @@ export class EncodeTrait extends CodecTrait {
45
45
  }
46
46
  buf2arr(data, cons) {
47
47
  let arr;
48
- if (typeof Buffer !== "undefined" && cons === Buffer)
48
+ if (cons === Buffer)
49
49
  arr = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
50
50
  else if (cons === Uint8Array)
51
51
  arr = data;
@@ -56,9 +56,9 @@ export class EventTrait extends AuthTrait {
56
56
  if (this.events.has(name))
57
57
  throw new Error(`event: event "${name}" already registered`);
58
58
  /* generate the corresponding MQTT topics for broadcast and direct use */
59
- const topic = share ? `$share/${share}/${name}` : name;
60
- const topicB = this.options.topicMake(topic, "event-emission");
61
- const topicD = this.options.topicMake(topic, "event-emission", this.options.id);
59
+ const topicS = share ? `$share/${share}/${name}` : name;
60
+ const topicB = this.options.topicMake(topicS, "event-emission");
61
+ const topicD = this.options.topicMake(name, "event-emission", this.options.id);
62
62
  /* subscribe to MQTT topics */
63
63
  await Promise.all([
64
64
  this._subscribeTopic(topicB, { qos: 0, ...options }),
@@ -142,14 +142,14 @@ export class EventTrait extends AuthTrait {
142
142
  if (topicMatch.name !== name)
143
143
  throw new Error(`event name mismatch between topic "${topicMatch.name}" and payload "${name}"`);
144
144
  const handler = this.events.get(name);
145
+ if (handler === undefined)
146
+ throw new Error(`handler for event "${name}" not found`);
145
147
  const params = parsed.params ?? [];
146
148
  const info = { sender: parsed.sender ?? "" };
147
149
  if (parsed.receiver)
148
150
  info.receiver = parsed.receiver;
149
151
  if (parsed.meta)
150
152
  info.meta = parsed.meta;
151
- if (handler === undefined)
152
- throw new Error(`handler for event "${name}" not found`);
153
153
  if (handler.auth)
154
154
  info.authenticated = await this.authenticated(parsed.sender, parsed.auth, handler.auth);
155
155
  Promise.resolve().then(() => {
@@ -26,8 +26,8 @@ import * as v from "valibot";
26
26
  import { EncodeTrait } from "./mqtt-plus-encode";
27
27
  /* meta validation schema (non-array plain object) */
28
28
  const MetaSchema = v.pipe(v.record(v.string(), v.unknown()), v.check((data) => !Array.isArray(data)));
29
- /* reusable object schema (any non-null object) */
30
- const ObjectSchema = v.custom((input) => typeof input === "object" && input !== null);
29
+ /* reusable auth validation schema (max 8 tokens, max 8192 chars each) */
30
+ const AuthSchema = v.pipe(v.array(v.pipe(v.string(), v.maxLength(8192))), v.maxLength(8));
31
31
  /* base class */
32
32
  class Base {
33
33
  constructor(type, id, sender, receiver) {
@@ -57,8 +57,8 @@ const EventEmissionSchema = v.strictObject({
57
57
  ...BaseSchema,
58
58
  type: v.literal("event-emission"),
59
59
  name: v.string(),
60
- params: v.optional(v.array(v.unknown())),
61
- auth: v.optional(v.array(v.string())),
60
+ params: v.optional(v.pipe(v.array(v.unknown()), v.maxLength(64))),
61
+ auth: v.optional(AuthSchema),
62
62
  meta: v.optional(MetaSchema)
63
63
  });
64
64
  /* service request */
@@ -75,8 +75,8 @@ const ServiceCallRequestSchema = v.strictObject({
75
75
  ...BaseSchema,
76
76
  type: v.literal("service-call-request"),
77
77
  name: v.string(),
78
- params: v.optional(v.array(v.unknown())),
79
- auth: v.optional(v.array(v.string())),
78
+ params: v.optional(v.pipe(v.array(v.unknown()), v.maxLength(64))),
79
+ auth: v.optional(AuthSchema),
80
80
  meta: v.optional(MetaSchema)
81
81
  });
82
82
  /* service response */
@@ -107,8 +107,8 @@ const SinkPushRequestSchema = v.strictObject({
107
107
  ...BaseSchema,
108
108
  type: v.literal("sink-push-request"),
109
109
  name: v.string(),
110
- params: v.optional(v.array(v.unknown())),
111
- auth: v.optional(v.array(v.string())),
110
+ params: v.optional(v.pipe(v.array(v.unknown()), v.maxLength(64))),
111
+ auth: v.optional(AuthSchema),
112
112
  meta: v.optional(MetaSchema)
113
113
  });
114
114
  /* sink push response (ack/nak) */
@@ -126,7 +126,7 @@ const SinkPushResponseSchema = v.strictObject({
126
126
  type: v.literal("sink-push-response"),
127
127
  name: v.string(),
128
128
  error: v.optional(v.string()),
129
- auth: v.optional(v.array(v.string())),
129
+ auth: v.optional(AuthSchema),
130
130
  meta: v.optional(MetaSchema)
131
131
  });
132
132
  /* sink push chunk (actual data transfer) */
@@ -143,7 +143,7 @@ const SinkPushChunkSchema = v.strictObject({
143
143
  ...BaseSchema,
144
144
  type: v.literal("sink-push-chunk"),
145
145
  name: v.string(),
146
- chunk: v.optional(ObjectSchema),
146
+ chunk: v.optional(v.instance(Uint8Array)),
147
147
  error: v.optional(v.string()),
148
148
  final: v.optional(v.boolean())
149
149
  });
@@ -161,8 +161,8 @@ const SourceFetchRequestSchema = v.strictObject({
161
161
  ...BaseSchema,
162
162
  type: v.literal("source-fetch-request"),
163
163
  name: v.string(),
164
- params: v.optional(v.array(v.unknown())),
165
- auth: v.optional(v.array(v.string())),
164
+ params: v.optional(v.pipe(v.array(v.unknown()), v.maxLength(64))),
165
+ auth: v.optional(AuthSchema),
166
166
  meta: v.optional(MetaSchema)
167
167
  });
168
168
  /* source fetch response (ack/nak) */
@@ -180,7 +180,7 @@ const SourceFetchResponseSchema = v.strictObject({
180
180
  type: v.literal("source-fetch-response"),
181
181
  name: v.string(),
182
182
  error: v.optional(v.string()),
183
- auth: v.optional(v.array(v.string())),
183
+ auth: v.optional(AuthSchema),
184
184
  meta: v.optional(MetaSchema)
185
185
  });
186
186
  /* source fetch chunk (actual data transfer) */
@@ -197,55 +197,48 @@ const SourceFetchChunkSchema = v.strictObject({
197
197
  ...BaseSchema,
198
198
  type: v.literal("source-fetch-chunk"),
199
199
  name: v.string(),
200
- chunk: v.optional(ObjectSchema),
200
+ chunk: v.optional(v.instance(Uint8Array)),
201
201
  error: v.optional(v.string()),
202
202
  final: v.optional(v.boolean())
203
203
  });
204
204
  /* utility class */
205
205
  class Msg {
206
- /* factory for event emission */
206
+ /* factories for creating objects */
207
207
  makeEventEmission(id, name, params, sender, receiver, auth, meta) {
208
208
  return new EventEmission(id, name, params, sender, receiver, auth, meta);
209
209
  }
210
- /* factory for service request */
211
210
  makeServiceCallRequest(id, name, params, sender, receiver, auth, meta) {
212
211
  return new ServiceCallRequest(id, name, params, sender, receiver, auth, meta);
213
212
  }
214
- /* factory for service response */
215
213
  makeServiceCallResponse(id, result, error, sender, receiver) {
216
214
  return new ServiceCallResponse(id, result, error, sender, receiver);
217
215
  }
218
- /* factory for sink push request */
219
216
  makeSinkPushRequest(id, name, params, sender, receiver, auth, meta) {
220
217
  return new SinkPushRequest(id, name, params, sender, receiver, auth, meta);
221
218
  }
222
- /* factory for sink push response */
223
219
  makeSinkPushResponse(id, name, error, sender, receiver, auth, meta) {
224
220
  return new SinkPushResponse(id, name, error, sender, receiver, auth, meta);
225
221
  }
226
- /* factory for sink push chunk */
227
222
  makeSinkPushChunk(id, name, chunk, error, final, sender, receiver) {
228
223
  return new SinkPushChunk(id, name, chunk, error, final, sender, receiver);
229
224
  }
230
- /* factory for source fetch request */
231
225
  makeSourceFetchRequest(id, name, params, sender, receiver, auth, meta) {
232
226
  return new SourceFetchRequest(id, name, params, sender, receiver, auth, meta);
233
227
  }
234
- /* factory for source fetch response */
235
228
  makeSourceFetchResponse(id, name, error, sender, receiver, auth, meta) {
236
229
  return new SourceFetchResponse(id, name, error, sender, receiver, auth, meta);
237
230
  }
238
- /* factory for source fetch chunk */
239
231
  makeSourceFetchChunk(id, name, chunk, error, final, sender, receiver) {
240
232
  return new SourceFetchChunk(id, name, chunk, error, final, sender, receiver);
241
233
  }
242
234
  /* parse any object into typed object */
243
235
  parse(obj) {
236
+ /* sanity check input */
244
237
  if (typeof obj !== "object" || obj === null)
245
238
  throw new Error("invalid argument: not an object");
246
239
  if (typeof obj.type !== "string")
247
240
  throw new Error("invalid object: missing or invalid \"type\" field");
248
- /* dispatch according to type indication by field */
241
+ /* helper function for Valibot-based validation */
249
242
  const parseObject = (obj, name, schema) => {
250
243
  const res = v.safeParse(schema, obj);
251
244
  if (!res.success) {
@@ -254,6 +247,7 @@ class Msg {
254
247
  }
255
248
  return res.output;
256
249
  };
250
+ /* dispatch according to type indication by field */
257
251
  if (obj.type === "event-emission") {
258
252
  const out = parseObject(obj, "EventEmission", EventEmissionSchema);
259
253
  return this.makeEventEmission(out.id, out.name, out.params, out.sender, out.receiver, out.auth, out.meta);
@@ -23,7 +23,5 @@ export declare class ServiceTrait<T extends APISchema = APISchema> extends Event
23
23
  options?: IClientPublishOptions;
24
24
  meta?: Record<string, any>;
25
25
  }): Promise<ReturnType<T[K]>>;
26
- private callSubscribe;
27
- private callUnsubscribe;
28
26
  protected _dispatchMessage(topic: string, parsed: any): Promise<void>;
29
27
  }
@@ -23,6 +23,7 @@
23
23
  */
24
24
  import { nanoid } from "nanoid";
25
25
  /* internal requirements */
26
+ import { RefCountedSubscription } from "./mqtt-plus-util";
26
27
  import { ServiceCallRequest, ServiceCallResponse } from "./mqtt-plus-msg";
27
28
  import { EventTrait } from "./mqtt-plus-event";
28
29
  /* Service Communication Trait */
@@ -32,7 +33,7 @@ export class ServiceTrait extends EventTrait {
32
33
  /* internal state */
33
34
  this.services = new Map();
34
35
  this.callCallbacks = new Map();
35
- this.callSubscriptions = new Map();
36
+ this.callSubscriptions = new RefCountedSubscription((topic, options) => this._subscribeTopic(topic, options), (topic) => this._unsubscribeTopic(topic), (err) => this.error(err));
36
37
  }
37
38
  async service(nameOrConfig, ...args) {
38
39
  /* determine actual parameters */
@@ -58,9 +59,9 @@ export class ServiceTrait extends EventTrait {
58
59
  if (this.services.has(name))
59
60
  throw new Error(`register: service "${name}" already registered`);
60
61
  /* generate the corresponding MQTT topics for broadcast and direct use */
61
- const topic = `$share/${share}/${name}`;
62
- const topicB = this.options.topicMake(topic, "service-call-request");
63
- const topicD = this.options.topicMake(topic, "service-call-request", this.options.id);
62
+ const topicS = `$share/${share}/${name}`;
63
+ const topicB = this.options.topicMake(topicS, "service-call-request");
64
+ const topicD = this.options.topicMake(name, "service-call-request", this.options.id);
64
65
  /* subscribe to MQTT topics */
65
66
  await Promise.all([
66
67
  this._subscribeTopic(topicB, { qos: 2, ...options }),
@@ -114,12 +115,13 @@ export class ServiceTrait extends EventTrait {
114
115
  /* generate unique request id */
115
116
  const requestId = nanoid();
116
117
  /* subscribe to MQTT response topic */
117
- await this.callSubscribe(name, { qos: options.qos ?? 2 });
118
+ const responseTopic = this.options.topicMake(name, "service-call-response", this.options.id);
119
+ await this.callSubscriptions.subscribe(responseTopic, { qos: options.qos ?? 2 });
118
120
  /* create promise for MQTT response handling */
119
121
  const promise = new Promise((resolve, reject) => {
120
122
  let timer = setTimeout(() => {
121
123
  this.callCallbacks.delete(requestId);
122
- this.callUnsubscribe(name);
124
+ this.callSubscriptions.unsubscribe(responseTopic);
123
125
  timer = null;
124
126
  reject(new Error("communication timeout"));
125
127
  }, this.options.timeout);
@@ -150,49 +152,12 @@ export class ServiceTrait extends EventTrait {
150
152
  const pendingRequest = this.callCallbacks.get(requestId);
151
153
  if (pendingRequest !== undefined) {
152
154
  this.callCallbacks.delete(requestId);
153
- this.callUnsubscribe(name);
155
+ this.callSubscriptions.unsubscribe(responseTopic);
154
156
  pendingRequest.callback(err, undefined);
155
157
  }
156
158
  });
157
159
  return promise;
158
160
  }
159
- /* subscribe to RPC response */
160
- async callSubscribe(service, options = { qos: 2 }) {
161
- /* generate corresponding MQTT topic */
162
- const topic = this.options.topicMake(service, "service-call-response", this.options.id);
163
- /* subscribe to MQTT topic and remember subscription */
164
- const count = this.callSubscriptions.get(topic) ?? 0;
165
- this.callSubscriptions.set(topic, count + 1);
166
- if (count === 0) {
167
- await this._subscribeTopic(topic, options).catch((err) => {
168
- const currentCount = this.callSubscriptions.get(topic) ?? 0;
169
- if (currentCount > 1)
170
- this.callSubscriptions.set(topic, currentCount - 1);
171
- else
172
- this.callSubscriptions.delete(topic);
173
- this.error(err);
174
- throw err;
175
- });
176
- }
177
- }
178
- /* unsubscribe from RPC response */
179
- callUnsubscribe(service) {
180
- /* generate corresponding MQTT topic */
181
- const topic = this.options.topicMake(service, "service-call-response", this.options.id);
182
- /* short-circuit processing if (no longer) subscribed */
183
- if (!this.callSubscriptions.has(topic))
184
- return;
185
- /* unsubscribe from MQTT topic and forget subscription */
186
- const count = this.callSubscriptions.get(topic) ?? 0;
187
- if (count > 1)
188
- this.callSubscriptions.set(topic, count - 1);
189
- else {
190
- this.callSubscriptions.delete(topic);
191
- this._unsubscribeTopic(topic).catch((err) => {
192
- this.error(err);
193
- });
194
- }
195
- }
196
161
  /* dispatch message (Service pattern handling) */
197
162
  async _dispatchMessage(topic, parsed) {
198
163
  await super._dispatchMessage(topic, parsed);
@@ -263,7 +228,8 @@ export class ServiceTrait extends EventTrait {
263
228
  request.callback(undefined, parsed.result);
264
229
  /* unsubscribe from response */
265
230
  this.callCallbacks.delete(requestId);
266
- this.callUnsubscribe(request.name);
231
+ const respTopic = this.options.topicMake(request.name, "service-call-response", this.options.id);
232
+ this.callSubscriptions.unsubscribe(respTopic);
267
233
  }
268
234
  }
269
235
  }
@@ -27,7 +27,5 @@ export declare class SinkTrait<T extends APISchema = APISchema> extends SourceTr
27
27
  options?: IClientPublishOptions;
28
28
  meta?: Record<string, any>;
29
29
  }): Promise<void>;
30
- private pushSubscribe;
31
- private pushUnsubscribe;
32
30
  protected _dispatchMessage(topic: string, parsed: any): Promise<void>;
33
31
  }
@@ -25,7 +25,7 @@
25
25
  import { Readable } from "node:stream";
26
26
  import { nanoid } from "nanoid";
27
27
  /* internal requirements */
28
- import { streamToBuffer, sendBufferAsChunks, sendStreamAsChunks } from "./mqtt-plus-util";
28
+ import { RefCountedSubscription, streamToBuffer, sendBufferAsChunks, sendStreamAsChunks } from "./mqtt-plus-util";
29
29
  import { SinkPushRequest, SinkPushResponse, SinkPushChunk } from "./mqtt-plus-msg";
30
30
  import { SourceTrait } from "./mqtt-plus-source";
31
31
  /* Sink Push Communication Trait */
@@ -37,7 +37,7 @@ export class SinkTrait extends SourceTrait {
37
37
  this.pushStreams = new Map();
38
38
  this.pushTimers = new Map();
39
39
  this.pushCallbacks = new Map();
40
- this.pushSubscriptions = new Map();
40
+ this.pushSubscriptions = new RefCountedSubscription((topic, options) => this._subscribeTopic(topic, options), (topic) => this._unsubscribeTopic(topic), (err) => this.error(err));
41
41
  }
42
42
  async sink(nameOrConfig, ...args) {
43
43
  /* determine actual parameters */
@@ -63,10 +63,10 @@ export class SinkTrait extends SourceTrait {
63
63
  if (this.sinks.has(name))
64
64
  throw new Error(`sink: sink "${name}" already established`);
65
65
  /* generate the corresponding MQTT topics for broadcast and direct use */
66
- const topic = `$share/${share}/${name}`;
67
- const topicReqB = this.options.topicMake(topic, "sink-push-request");
68
- const topicReqD = this.options.topicMake(topic, "sink-push-request", this.options.id);
69
- const topicChunkD = this.options.topicMake(topic, "sink-push-chunk", this.options.id);
66
+ const topicS = `$share/${share}/${name}`;
67
+ const topicReqB = this.options.topicMake(topicS, "sink-push-request");
68
+ const topicReqD = this.options.topicMake(name, "sink-push-request", this.options.id);
69
+ const topicChunkD = this.options.topicMake(name, "sink-push-chunk", this.options.id);
70
70
  /* subscribe to MQTT topics */
71
71
  await Promise.all([
72
72
  this._subscribeTopic(topicReqB, { qos: 2, ...options }),
@@ -127,7 +127,7 @@ export class SinkTrait extends SourceTrait {
127
127
  const requestId = nanoid();
128
128
  /* subscribe to response topic (for ack/nak) */
129
129
  const responseTopic = this.options.topicMake(name, "sink-push-response", this.options.id);
130
- await this.pushSubscribe(responseTopic, { qos: 2 });
130
+ await this.pushSubscriptions.subscribe(responseTopic, { qos: 2 });
131
131
  /* define timer */
132
132
  let timer = null;
133
133
  /* utility function for cleanup */
@@ -136,7 +136,7 @@ export class SinkTrait extends SourceTrait {
136
136
  clearTimeout(timer);
137
137
  timer = null;
138
138
  }
139
- this.pushUnsubscribe(responseTopic);
139
+ this.pushSubscriptions.unsubscribe(responseTopic);
140
140
  this.pushCallbacks.delete(requestId);
141
141
  };
142
142
  /* send request and wait for response before sending chunks */
@@ -188,31 +188,6 @@ export class SinkTrait extends SourceTrait {
188
188
  /* split buffer into chunks and send them */
189
189
  await sendBufferAsChunks(data, this.options.chunkSize, sendChunk);
190
190
  }
191
- /* subscribe to sink push response topic with reference counting */
192
- async pushSubscribe(topic, options = { qos: 2 }) {
193
- const count = this.pushSubscriptions.get(topic) ?? 0;
194
- this.pushSubscriptions.set(topic, count + 1);
195
- if (count === 0) {
196
- await this._subscribeTopic(topic, options).catch((err) => {
197
- const currentCount = this.pushSubscriptions.get(topic) ?? 0;
198
- if (currentCount > 1)
199
- this.pushSubscriptions.set(topic, currentCount - 1);
200
- else
201
- this.pushSubscriptions.delete(topic);
202
- throw err;
203
- });
204
- }
205
- }
206
- /* unsubscribe from sink push response topic with reference counting */
207
- pushUnsubscribe(topic) {
208
- const count = this.pushSubscriptions.get(topic) ?? 0;
209
- if (count <= 1) {
210
- this.pushSubscriptions.delete(topic);
211
- this._unsubscribeTopic(topic).catch(() => { });
212
- }
213
- else
214
- this.pushSubscriptions.set(topic, count - 1);
215
- }
216
191
  /* dispatch incoming MQTT message */
217
192
  async _dispatchMessage(topic, parsed) {
218
193
  /* forward dispatching to other traits */
@@ -329,8 +304,7 @@ export class SinkTrait extends SourceTrait {
329
304
  throw new Error(`sink name mismatch between topic "${topicMatch.name}" and payload "${parsed.name}"`);
330
305
  const error = parsed.error;
331
306
  const final = parsed.final;
332
- const chunk = (parsed.chunk !== undefined && !(parsed.chunk instanceof Uint8Array))
333
- ? Uint8Array.from(parsed.chunk) : parsed.chunk;
307
+ const chunk = parsed.chunk;
334
308
  /* handle chunk on push */
335
309
  const readable = this.pushStreams.get(requestId);
336
310
  if (readable !== undefined) {
@@ -32,7 +32,5 @@ export declare class SourceTrait<T extends APISchema = APISchema> extends Servic
32
32
  buffer: Promise<Uint8Array>;
33
33
  meta: Promise<Record<string, any> | undefined>;
34
34
  }>;
35
- private fetchSubscribe;
36
- private fetchUnsubscribe;
37
35
  protected _dispatchMessage(topic: string, parsed: any): Promise<void>;
38
36
  }
@@ -25,7 +25,7 @@
25
25
  import { Readable } from "node:stream";
26
26
  import { nanoid } from "nanoid";
27
27
  /* internal requirements */
28
- import { streamToBuffer, sendBufferAsChunks, sendStreamAsChunks } from "./mqtt-plus-util";
28
+ import { RefCountedSubscription, streamToBuffer, sendBufferAsChunks, sendStreamAsChunks } from "./mqtt-plus-util";
29
29
  import { SourceFetchRequest, SourceFetchResponse, SourceFetchChunk } from "./mqtt-plus-msg";
30
30
  import { ServiceTrait } from "./mqtt-plus-service";
31
31
  /* Source Fetch Communication Trait */
@@ -35,7 +35,7 @@ export class SourceTrait extends ServiceTrait {
35
35
  /* source state */
36
36
  this.sources = new Map();
37
37
  this.fetchCallbacks = new Map();
38
- this.fetchSubscriptions = new Map();
38
+ this.fetchSubscriptions = new RefCountedSubscription((topic, options) => this._subscribeTopic(topic, options), (topic) => this._unsubscribeTopic(topic), (err) => this.error(err));
39
39
  }
40
40
  async source(nameOrConfig, ...args) {
41
41
  /* determine actual parameters */
@@ -61,9 +61,9 @@ export class SourceTrait extends ServiceTrait {
61
61
  if (this.sources.has(name))
62
62
  throw new Error(`source: source "${name}" already established`);
63
63
  /* generate the corresponding MQTT topics for broadcast and direct use */
64
- const topic = `$share/${share}/${name}`;
65
- const topicReqB = this.options.topicMake(topic, "source-fetch-request");
66
- const topicReqD = this.options.topicMake(topic, "source-fetch-request", this.options.id);
64
+ const topicS = `$share/${share}/${name}`;
65
+ const topicReqB = this.options.topicMake(topicS, "source-fetch-request");
66
+ const topicReqD = this.options.topicMake(name, "source-fetch-request", this.options.id);
67
67
  /* subscribe to MQTT topics */
68
68
  await Promise.all([
69
69
  this._subscribeTopic(topicReqB, { qos: 2, ...options }),
@@ -120,11 +120,11 @@ export class SourceTrait extends ServiceTrait {
120
120
  const responseTopic = this.options.topicMake(name, "source-fetch-response", this.options.id);
121
121
  const chunkTopic = this.options.topicMake(name, "source-fetch-chunk", this.options.id);
122
122
  await Promise.all([
123
- this.fetchSubscribe(responseTopic, { qos: 2 }),
124
- this.fetchSubscribe(chunkTopic, { qos: 2 })
123
+ this.fetchSubscriptions.subscribe(responseTopic, { qos: 2 }),
124
+ this.fetchSubscriptions.subscribe(chunkTopic, { qos: 2 })
125
125
  ]).catch((err) => {
126
- this.fetchUnsubscribe(responseTopic);
127
- this.fetchUnsubscribe(chunkTopic);
126
+ this.fetchSubscriptions.unsubscribe(responseTopic);
127
+ this.fetchSubscriptions.unsubscribe(chunkTopic);
128
128
  throw err;
129
129
  });
130
130
  /* establish readable for buffering received chunks */
@@ -157,8 +157,8 @@ export class SourceTrait extends ServiceTrait {
157
157
  clearTimeout(timer);
158
158
  timer = null;
159
159
  }
160
- this.fetchUnsubscribe(responseTopic);
161
- this.fetchUnsubscribe(chunkTopic);
160
+ this.fetchSubscriptions.unsubscribe(responseTopic);
161
+ this.fetchSubscriptions.unsubscribe(chunkTopic);
162
162
  this.fetchCallbacks.delete(requestId);
163
163
  if (resolveMeta)
164
164
  metaResolve?.(undefined);
@@ -213,31 +213,6 @@ export class SourceTrait extends ServiceTrait {
213
213
  /* produce result */
214
214
  return { stream, buffer, meta: metaP };
215
215
  }
216
- /* subscribe to fetch topics with reference counting */
217
- async fetchSubscribe(topic, options = { qos: 2 }) {
218
- const count = this.fetchSubscriptions.get(topic) ?? 0;
219
- this.fetchSubscriptions.set(topic, count + 1);
220
- if (count === 0) {
221
- await this._subscribeTopic(topic, options).catch((err) => {
222
- const currentCount = this.fetchSubscriptions.get(topic) ?? 0;
223
- if (currentCount > 1)
224
- this.fetchSubscriptions.set(topic, currentCount - 1);
225
- else
226
- this.fetchSubscriptions.delete(topic);
227
- throw err;
228
- });
229
- }
230
- }
231
- /* unsubscribe from fetch topics with reference counting */
232
- fetchUnsubscribe(topic) {
233
- const count = this.fetchSubscriptions.get(topic) ?? 0;
234
- if (count <= 1) {
235
- this.fetchSubscriptions.delete(topic);
236
- this._unsubscribeTopic(topic).catch(() => { });
237
- }
238
- else
239
- this.fetchSubscriptions.set(topic, count - 1);
240
- }
241
216
  /* dispatch message (Source Fetch pattern handling) */
242
217
  async _dispatchMessage(topic, parsed) {
243
218
  await super._dispatchMessage(topic, parsed);
@@ -296,7 +271,9 @@ export class SourceTrait extends ServiceTrait {
296
271
  await sendResponse();
297
272
  ackSent = true;
298
273
  /* dispatch according to data type */
299
- if (info.stream instanceof Readable)
274
+ if (info.stream instanceof Readable && info.buffer instanceof Promise)
275
+ throw new Error("handler has set both info.stream and info.buffer");
276
+ else if (info.stream instanceof Readable)
300
277
  /* handle Readable stream result */
301
278
  await sendStreamAsChunks(info.stream, this.options.chunkSize, sendChunk);
302
279
  else if (info.buffer instanceof Promise)
@@ -342,8 +319,7 @@ export class SourceTrait extends ServiceTrait {
342
319
  throw new Error(`source name mismatch between topic "${topicMatch.name}" and payload "${parsed.name}"`);
343
320
  const error = parsed.error;
344
321
  const final = parsed.final;
345
- const chunk = (parsed.chunk !== undefined && !(parsed.chunk instanceof Uint8Array))
346
- ? Uint8Array.from(parsed.chunk) : parsed.chunk;
322
+ const chunk = parsed.chunk;
347
323
  /* handle chunk on fetch */
348
324
  const handler = this.fetchCallbacks.get(requestId);
349
325
  if (handler !== undefined)
@@ -4,8 +4,8 @@ declare class LogEvent {
4
4
  timestamp: number;
5
5
  level: string;
6
6
  msg: string | Promise<string>;
7
- data?: (Record<string, any> | Record<string, Promise<any>>) | undefined;
8
- constructor(timestamp: number, level: string, msg: string | Promise<string>, data?: (Record<string, any> | Record<string, Promise<any>>) | undefined);
7
+ data?: Record<string, Promise<any> | any> | undefined;
8
+ constructor(timestamp: number, level: string, msg: string | Promise<string>, data?: Record<string, Promise<any> | any> | undefined);
9
9
  resolve(): Promise<void>;
10
10
  toString(): string;
11
11
  }
@@ -85,7 +85,7 @@ export class TraceTrait extends MsgTrait {
85
85
  return this._events.emit(...args);
86
86
  }
87
87
  catch (_err) {
88
- /* ignore error (usually caused by no existing listeners) */
88
+ /* ignore error (caused by emitting "error" without listeners) */
89
89
  return false;
90
90
  }
91
91
  }