mqtt-plus 1.4.9 → 1.4.11
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/AGENTS.md +7 -3
- package/CHANGELOG.md +28 -0
- package/doc/mqtt-plus-api.md +7 -7
- package/doc/mqtt-plus-internals.md +0 -3
- package/dst-stage1/mqtt-plus-base.d.ts +1 -1
- package/dst-stage1/mqtt-plus-event.d.ts +3 -3
- package/dst-stage1/mqtt-plus-event.js +6 -6
- package/dst-stage1/mqtt-plus-service.js +1 -1
- package/dst-stage1/mqtt-plus-sink.js +29 -19
- package/dst-stage1/mqtt-plus-source.d.ts +1 -0
- package/dst-stage1/mqtt-plus-source.js +45 -18
- package/dst-stage1/mqtt-plus-subscription.js +3 -4
- package/dst-stage1/mqtt-plus-timer.d.ts +1 -1
- package/dst-stage1/mqtt-plus-timer.js +8 -2
- package/dst-stage1/mqtt-plus-util.js +5 -3
- package/dst-stage2/mqtt-plus.cjs.js +90 -50
- package/dst-stage2/mqtt-plus.esm.js +90 -50
- package/dst-stage2/mqtt-plus.umd.js +12 -12
- package/etc/vite.mts +1 -1
- package/package.json +6 -10
- package/src/mqtt-plus-base.ts +1 -1
- package/src/mqtt-plus-event.ts +10 -10
- package/src/mqtt-plus-service.ts +1 -1
- package/src/mqtt-plus-sink.ts +35 -24
- package/src/mqtt-plus-source.ts +51 -21
- package/src/mqtt-plus-subscription.ts +4 -4
- package/src/mqtt-plus-timer.ts +9 -3
- package/src/mqtt-plus-util.ts +6 -4
- package/tst/mqtt-plus-0-broker.ts +2 -2
- package/tst/mqtt-plus-2-event.spec.ts +2 -2
- package/tst/mqtt-plus-5-source.spec.ts +2 -2
- package/tst/mqtt-plus-6-misc.spec.ts +1 -1
- package/dst-stage1/mqtt-plus-receiver.d.ts +0 -12
- package/dst-stage1/mqtt-plus-receiver.js +0 -42
- package/dst-stage1/mqtt-plus-resource-dn.d.ts +0 -41
- package/dst-stage1/mqtt-plus-resource-dn.js +0 -286
- package/dst-stage1/mqtt-plus-resource-up.d.ts +0 -32
- package/dst-stage1/mqtt-plus-resource-up.js +0 -230
- package/dst-stage1/mqtt-plus-resource.d.ts +0 -49
- package/dst-stage1/mqtt-plus-resource.js +0 -385
- package/dst-stage1/mqtt-plus-stream.d.ts +0 -24
- package/dst-stage1/mqtt-plus-stream.js +0 -191
- package/dst-stage1/mqtt-plus-topic.d.ts +0 -20
- package/dst-stage1/mqtt-plus-topic.js +0 -112
package/AGENTS.md
CHANGED
|
@@ -33,11 +33,13 @@ npm start sample # run sample/sample.ts via `tsx`
|
|
|
33
33
|
|
|
34
34
|
npm start clean # remove dst-stage1/ and dst-stage2/
|
|
35
35
|
npm start distclean # remove node_modules/ and package-lock.json
|
|
36
|
+
npm start publish # publish to npm (restricted to maintainer host)
|
|
36
37
|
|
|
37
38
|
```
|
|
38
39
|
|
|
39
|
-
Tests require
|
|
40
|
-
|
|
40
|
+
Tests require an MQTT broker under run-time; the test suite starts/stops
|
|
41
|
+
one automatically. If Docker is available, a Mosquitto broker is used;
|
|
42
|
+
otherwise, the Aedes in-process broker serves as the fallback.
|
|
41
43
|
|
|
42
44
|
Build Pipeline
|
|
43
45
|
--------------
|
|
@@ -51,7 +53,7 @@ Two-stage build:
|
|
|
51
53
|
`mqtt-plus.esm.js`, `mqtt-plus.cjs.js`, `mqtt-plus.umd.js`.
|
|
52
54
|
UMD build includes Node polyfills (events, stream, buffer).
|
|
53
55
|
|
|
54
|
-
Configuration lives in `etc/`: `tsc.json`, `vite.mts`, `eslint.mts`, `knip.jsonc`, `stx.conf`, `d2.mts`.
|
|
56
|
+
Configuration lives in `etc/`: `tsc.json`, `vite.mts`, `eslint.mts`, `knip.jsonc`, `stx.conf`, `d2.mts`, `d2.theme.d2`, `logo.ai`, `logo.svg`.
|
|
55
57
|
|
|
56
58
|
Architecture
|
|
57
59
|
------------
|
|
@@ -70,6 +72,7 @@ the bottom of this chain:
|
|
|
70
72
|
↓ TraceTrait — EventEmitter + structured logging
|
|
71
73
|
↓ BaseTrait — MQTT client hookup, subscription management, message routing
|
|
72
74
|
↓ SubscriptionTrait — ref-counted MQTT topic subscription management
|
|
75
|
+
↓ TimerTrait — named timer management (refresh/clear)
|
|
73
76
|
↓ MetaTrait — instance/per-request metadata
|
|
74
77
|
↓ AuthTrait — JWT authentication (jose), role-based access
|
|
75
78
|
↓ EventTrait — Event Emission pattern (event/emit)
|
|
@@ -98,6 +101,7 @@ Each trait lives in its own file: `src/mqtt-plus-<trait>.ts`.
|
|
|
98
101
|
| `src/mqtt-plus-trace.ts` | TraceTrait — EventEmitter and structured logging |
|
|
99
102
|
| `src/mqtt-plus-base.ts` | BaseTrait — MQTT client connection, subscription management, message routing |
|
|
100
103
|
| `src/mqtt-plus-subscription.ts` | SubscriptionTrait — ref-counted MQTT topic subscription management |
|
|
104
|
+
| `src/mqtt-plus-timer.ts` | TimerTrait — named timer management (refresh/clear) |
|
|
101
105
|
| `src/mqtt-plus-meta.ts` | MetaTrait — instance and per-request metadata management |
|
|
102
106
|
| `src/mqtt-plus-auth.ts` | AuthTrait — JWT authentication (jose) and role-based access control |
|
|
103
107
|
| `src/mqtt-plus-event.ts` | EventTrait — Event Emission communication pattern (event/emit) |
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,34 @@
|
|
|
2
2
|
ChangeLog
|
|
3
3
|
=========
|
|
4
4
|
|
|
5
|
+
1.4.11 (2026-03-05)
|
|
6
|
+
-------------------
|
|
7
|
+
|
|
8
|
+
- IMPROVEMENT: improve error handling and use error ensure function consistently
|
|
9
|
+
- IMPROVEMENT: improve cleanup handling and correctly track resources
|
|
10
|
+
- IMPROVEMENT: improve typing and fix overloads
|
|
11
|
+
- IMPROVEMENT: avoid warning in Vite
|
|
12
|
+
- BUGFIX: fix building for Vite compatibility
|
|
13
|
+
- BUGFIX: avoid unhandled rejection error
|
|
14
|
+
- BUGFIX: fix test
|
|
15
|
+
- UPDATE: update documentation
|
|
16
|
+
- UPDATE: upgrade NPM dependencies
|
|
17
|
+
- CLEANUP: rename emit() parameter "event" to "name" for consistency
|
|
18
|
+
- CLEANUP: cleanup code
|
|
19
|
+
|
|
20
|
+
1.4.10 (2026-03-01)
|
|
21
|
+
-------------------
|
|
22
|
+
|
|
23
|
+
- IMPROVEMENT: improve performance
|
|
24
|
+
- IMPROVEMENT: improve typing and export more public API types
|
|
25
|
+
- IMPROVEMENT: improve description
|
|
26
|
+
- BUGFIX: fix error handling and destruction problems
|
|
27
|
+
- BUGFIX: fix name of module
|
|
28
|
+
- BUGFIX: do not make fields exclusive
|
|
29
|
+
- UPDATE: upgrade NPM dependencies
|
|
30
|
+
- CLEANUP: various code cleanups (simplification, formatting, comments, output polishing)
|
|
31
|
+
- CLEANUP: cleanups for error handling
|
|
32
|
+
|
|
5
33
|
1.4.9 (2026-02-22)
|
|
6
34
|
------------------
|
|
7
35
|
|
package/doc/mqtt-plus-api.md
CHANGED
|
@@ -65,7 +65,7 @@ describing the available events, services, sources, and sinks.
|
|
|
65
65
|
Destruction
|
|
66
66
|
-----------
|
|
67
67
|
|
|
68
|
-
destroy(): void
|
|
68
|
+
destroy(): Promise<void>
|
|
69
69
|
|
|
70
70
|
Clean up the MQTT+ instance by removing all event listeners.
|
|
71
71
|
Call this method when the instance is no longer needed.
|
|
@@ -265,18 +265,18 @@ Event Emission
|
|
|
265
265
|
|
|
266
266
|
/* (simplified TypeScript API method signature) */
|
|
267
267
|
emit(
|
|
268
|
-
|
|
268
|
+
name: string,
|
|
269
269
|
...params: any[]
|
|
270
270
|
): void
|
|
271
271
|
emit({
|
|
272
|
-
|
|
272
|
+
name: string,
|
|
273
273
|
params: any[],
|
|
274
274
|
receiver?: string,
|
|
275
275
|
options?: MQTT::IClientPublishOptions,
|
|
276
276
|
meta?: Record<string, any>
|
|
277
277
|
}): void
|
|
278
278
|
emit({
|
|
279
|
-
|
|
279
|
+
name: string,
|
|
280
280
|
params: any[],
|
|
281
281
|
receiver?: string,
|
|
282
282
|
options?: MQTT::IClientPublishOptions,
|
|
@@ -300,8 +300,8 @@ Emit an event to all subscribers or a specific subscriber ("fire and forget").
|
|
|
300
300
|
- The remote `event()` `callback` is called with `params` and its
|
|
301
301
|
return value is silently ignored.
|
|
302
302
|
|
|
303
|
-
- Internally, publishes to the MQTT topic by `topicMake(
|
|
304
|
-
(default: `${
|
|
303
|
+
- Internally, publishes to the MQTT topic by `topicMake(name, "event-emission", peerId)`
|
|
304
|
+
(default: `${name}/event-emission/any` or `${name}/event-emission/${peerId}`).
|
|
305
305
|
|
|
306
306
|
- *Dry-Run Publishing for MQTT Last-Will:*
|
|
307
307
|
When you need to set up an MQTT "last will" message (automatically published
|
|
@@ -315,7 +315,7 @@ Emit an event to all subscribers or a specific subscriber ("fire and forget").
|
|
|
315
315
|
const mqttpDry = new MQTTp<API>(null, { id: "my-client" })
|
|
316
316
|
const will = mqttpDry.emit({
|
|
317
317
|
dry: true,
|
|
318
|
-
|
|
318
|
+
name: "example/connection",
|
|
319
319
|
params: [ "close" ],
|
|
320
320
|
[...]
|
|
321
321
|
})
|
|
@@ -122,8 +122,6 @@ Exactly one of `result` or `error` is present.
|
|
|
122
122
|
|----------|------------------------|----------|-------------------------------|
|
|
123
123
|
| `name` | `string` | yes | Sink endpoint name |
|
|
124
124
|
| `error` | `string` | no | Error message (nak) or absent (ack) |
|
|
125
|
-
| `auth` | `string[]` | no | JWT tokens (max 8) |
|
|
126
|
-
| `meta` | `Record<string, any>` | no | Arbitrary metadata |
|
|
127
125
|
| `credit` | `integer` | no | Initial flow control credit (min 1) |
|
|
128
126
|
|
|
129
127
|
### `sink-push-chunk`
|
|
@@ -158,7 +156,6 @@ Exactly one of `result` or `error` is present.
|
|
|
158
156
|
|----------|------------------------|----------|-------------------------------|
|
|
159
157
|
| `name` | `string` | yes | Source endpoint name |
|
|
160
158
|
| `error` | `string` | no | Error message (nak) or absent (ack) |
|
|
161
|
-
| `auth` | `string[]` | no | JWT tokens (max 8) |
|
|
162
159
|
| `meta` | `Record<string, any>` | no | Arbitrary metadata |
|
|
163
160
|
|
|
164
161
|
### `source-fetch-chunk`
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { MqttClient, type IClientSubscribeOptions, type IClientPublishOptions } from "mqtt";
|
|
1
|
+
import { type MqttClient, type IClientSubscribeOptions, type IClientPublishOptions } from "mqtt";
|
|
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";
|
|
@@ -11,16 +11,16 @@ export declare class EventTrait<T extends APISchema = APISchema> extends AuthTra
|
|
|
11
11
|
share?: string;
|
|
12
12
|
auth?: AuthOption;
|
|
13
13
|
}): Promise<Registration>;
|
|
14
|
-
emit<K extends EventKeys<T> & string>(
|
|
14
|
+
emit<K extends EventKeys<T> & string>(name: K, ...params: Parameters<T[K]>): void;
|
|
15
15
|
emit<K extends EventKeys<T> & string>(config: {
|
|
16
|
-
|
|
16
|
+
name: K;
|
|
17
17
|
params: Parameters<T[K]>;
|
|
18
18
|
receiver?: string;
|
|
19
19
|
options?: IClientPublishOptions;
|
|
20
20
|
meta?: Record<string, any>;
|
|
21
21
|
}): void;
|
|
22
22
|
emit<K extends EventKeys<T> & string>(config: {
|
|
23
|
-
|
|
23
|
+
name: K;
|
|
24
24
|
params: Parameters<T[K]>;
|
|
25
25
|
receiver?: string;
|
|
26
26
|
options?: IClientPublishOptions;
|
|
@@ -92,7 +92,7 @@ export class EventTrait extends AuthTrait {
|
|
|
92
92
|
}
|
|
93
93
|
emit(eventOrConfig, ...args) {
|
|
94
94
|
/* determine actual parameters */
|
|
95
|
-
let
|
|
95
|
+
let name;
|
|
96
96
|
let params;
|
|
97
97
|
let receiver;
|
|
98
98
|
let options = {};
|
|
@@ -100,7 +100,7 @@ export class EventTrait extends AuthTrait {
|
|
|
100
100
|
let dry;
|
|
101
101
|
if (typeof eventOrConfig === "object" && eventOrConfig !== null) {
|
|
102
102
|
/* object-based API */
|
|
103
|
-
|
|
103
|
+
name = eventOrConfig.name;
|
|
104
104
|
params = eventOrConfig.params;
|
|
105
105
|
receiver = eventOrConfig.receiver;
|
|
106
106
|
options = eventOrConfig.options ?? {};
|
|
@@ -109,7 +109,7 @@ export class EventTrait extends AuthTrait {
|
|
|
109
109
|
}
|
|
110
110
|
else {
|
|
111
111
|
/* positional API */
|
|
112
|
-
|
|
112
|
+
name = eventOrConfig;
|
|
113
113
|
params = args;
|
|
114
114
|
}
|
|
115
115
|
/* generate unique request id */
|
|
@@ -117,10 +117,10 @@ export class EventTrait extends AuthTrait {
|
|
|
117
117
|
/* generate encoded message */
|
|
118
118
|
const auth = this.authenticate();
|
|
119
119
|
const metaStore = this.metaStore(meta);
|
|
120
|
-
const request = this.msg.makeEventEmission(requestId,
|
|
120
|
+
const request = this.msg.makeEventEmission(requestId, name, params, this.options.id, receiver, auth, metaStore);
|
|
121
121
|
const message = this.codec.encode(request);
|
|
122
122
|
/* generate corresponding MQTT topic */
|
|
123
|
-
const topic = this.options.topicMake(
|
|
123
|
+
const topic = this.options.topicMake(name, "event-emission", receiver);
|
|
124
124
|
/* produce result */
|
|
125
125
|
if (dry)
|
|
126
126
|
/* return publish information */
|
|
@@ -128,7 +128,7 @@ export class EventTrait extends AuthTrait {
|
|
|
128
128
|
else
|
|
129
129
|
/* publish message to MQTT topic */
|
|
130
130
|
this.publishToTopic(topic, message, { qos: 2, ...options }).catch((err) => {
|
|
131
|
-
this.error(err, `emitting event "${
|
|
131
|
+
this.error(err, `emitting event "${name}" failed`);
|
|
132
132
|
});
|
|
133
133
|
}
|
|
134
134
|
}
|
|
@@ -98,7 +98,7 @@ export class ServiceTrait extends EventTrait {
|
|
|
98
98
|
await this.publishToTopic(topic, encoded, { qos: options.qos ?? 2 });
|
|
99
99
|
}
|
|
100
100
|
catch (err2) {
|
|
101
|
-
this.error(ensureError(err2), `
|
|
101
|
+
this.error(ensureError(err2), `sending error response for service "${name}" failed`);
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
});
|
|
@@ -39,7 +39,7 @@ export class SinkTrait extends SourceTrait {
|
|
|
39
39
|
/* destroy sink trait */
|
|
40
40
|
async destroy() {
|
|
41
41
|
for (const stream of this.pushStreams.values())
|
|
42
|
-
stream.destroy();
|
|
42
|
+
stream.destroy(new Error("sink destroyed"));
|
|
43
43
|
for (const spool of this.pushSpools.values())
|
|
44
44
|
await spool.unroll();
|
|
45
45
|
this.pushStreams.clear();
|
|
@@ -178,6 +178,7 @@ export class SinkTrait extends SourceTrait {
|
|
|
178
178
|
reqSpool.roll(() => { clearPushTimeout(); });
|
|
179
179
|
/* prepare info object */
|
|
180
180
|
const promise = streamToBuffer(readable);
|
|
181
|
+
promise.catch(() => { }); /* avoid unhandled promise rejection */
|
|
181
182
|
const info = {
|
|
182
183
|
sender,
|
|
183
184
|
stream: readable,
|
|
@@ -194,22 +195,21 @@ export class SinkTrait extends SourceTrait {
|
|
|
194
195
|
await sendResponse();
|
|
195
196
|
ackSent = true;
|
|
196
197
|
/* call handler */
|
|
197
|
-
|
|
198
|
+
await callback(...params, info);
|
|
198
199
|
}
|
|
199
200
|
catch (err) {
|
|
200
|
-
const error = ensureError(err);
|
|
201
|
+
const error = ensureError(err, `handler for sink "${name}" failed`);
|
|
201
202
|
/* cleanup resources */
|
|
202
203
|
const stream = this.pushStreams.get(requestId);
|
|
203
204
|
if (stream !== undefined)
|
|
204
205
|
stream.destroy(error);
|
|
205
|
-
reqSpool.unroll();
|
|
206
|
-
/* send error as nak response or as error
|
|
206
|
+
await reqSpool.unroll();
|
|
207
|
+
/* send error as nak response or as mid-stream error response */
|
|
207
208
|
this.error(error);
|
|
208
209
|
if (ackSent) {
|
|
209
|
-
const
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
await this.publishToTopic(chunkTopic, message, { qos: options.qos ?? 2 }).catch(() => { });
|
|
210
|
+
const responseMsg = this.msg.makeSinkPushResponse(requestId, name, error.message, this.options.id, sender);
|
|
211
|
+
const message = this.codec.encode(responseMsg);
|
|
212
|
+
await this.publishToTopic(responseTopic, message, { qos: options.qos ?? 2 }).catch(() => { });
|
|
213
213
|
}
|
|
214
214
|
else
|
|
215
215
|
await sendResponse(error.message).catch(() => { });
|
|
@@ -263,10 +263,19 @@ export class SinkTrait extends SourceTrait {
|
|
|
263
263
|
/* define abort controller and signal */
|
|
264
264
|
const abortController = new AbortController();
|
|
265
265
|
const abortSignal = abortController.signal;
|
|
266
|
+
/* ensure stream gets destroyed on abort */
|
|
267
|
+
if (data instanceof Readable) {
|
|
268
|
+
const stream = data;
|
|
269
|
+
abortSignal.addEventListener("abort", () => {
|
|
270
|
+
if (!stream.destroyed)
|
|
271
|
+
stream.destroy(ensureError(abortSignal.reason));
|
|
272
|
+
}, { once: true });
|
|
273
|
+
}
|
|
266
274
|
/* utility function for timeout refresh */
|
|
267
275
|
const pushTimerId = `sink-push-send:${requestId}`;
|
|
268
276
|
const refreshTimeout = () => this.timerRefresh(pushTimerId, () => {
|
|
269
|
-
|
|
277
|
+
const error = new Error(`push to sink "${name}" timed out`);
|
|
278
|
+
abortController.abort(error);
|
|
270
279
|
spool.unroll();
|
|
271
280
|
});
|
|
272
281
|
spool.roll(() => { this.timerClear(pushTimerId); });
|
|
@@ -275,6 +284,7 @@ export class SinkTrait extends SourceTrait {
|
|
|
275
284
|
/* send request and wait for response before sending chunks */
|
|
276
285
|
let initialCredit;
|
|
277
286
|
let creditGate;
|
|
287
|
+
let remoteError = false;
|
|
278
288
|
try {
|
|
279
289
|
await new Promise((resolve, reject) => {
|
|
280
290
|
/* handle abort signal */
|
|
@@ -293,10 +303,6 @@ export class SinkTrait extends SourceTrait {
|
|
|
293
303
|
}
|
|
294
304
|
});
|
|
295
305
|
spool.roll(() => { this.onResponse.delete(`sink-push-response:${requestId}`); });
|
|
296
|
-
this.onResponse.set(`sink-push-credit:${requestId}`, (_response) => {
|
|
297
|
-
refreshTimeout();
|
|
298
|
-
});
|
|
299
|
-
spool.roll(() => { this.onResponse.delete(`sink-push-credit:${requestId}`); });
|
|
300
306
|
/* generate and send request message */
|
|
301
307
|
const auth = this.authenticate();
|
|
302
308
|
const metaStore = this.metaStore(meta);
|
|
@@ -309,8 +315,10 @@ export class SinkTrait extends SourceTrait {
|
|
|
309
315
|
});
|
|
310
316
|
/* override handler for mid-stream (error) responses */
|
|
311
317
|
this.onResponse.set(`sink-push-response:${requestId}`, (response) => {
|
|
312
|
-
if (response.error)
|
|
318
|
+
if (response.error) {
|
|
319
|
+
remoteError = true;
|
|
313
320
|
abortController.abort(new Error(response.error));
|
|
321
|
+
}
|
|
314
322
|
});
|
|
315
323
|
/* create credit gate for flow control (if server granted credit) */
|
|
316
324
|
if (initialCredit !== undefined && initialCredit > 0)
|
|
@@ -327,6 +335,7 @@ export class SinkTrait extends SourceTrait {
|
|
|
327
335
|
gate.replenish(response.credit);
|
|
328
336
|
refreshTimeout();
|
|
329
337
|
});
|
|
338
|
+
spool.roll(() => { this.onResponse.delete(`sink-push-credit:${requestId}`); });
|
|
330
339
|
}
|
|
331
340
|
/* generate corresponding MQTT topic for chunks */
|
|
332
341
|
const chunkTopic = this.options.topicMake(name, "sink-push-chunk", receiver);
|
|
@@ -346,12 +355,13 @@ export class SinkTrait extends SourceTrait {
|
|
|
346
355
|
await sendBufferAsChunks(data, this.options.chunkSize, sendChunk, creditGate, abortSignal);
|
|
347
356
|
}
|
|
348
357
|
catch (err) {
|
|
349
|
-
|
|
358
|
+
const error = ensureError(err);
|
|
359
|
+
abortController.abort(error);
|
|
360
|
+
/* send error chunk only if receiver is known and error did not originate from receiver
|
|
350
361
|
(otherwise the sink already received the error via the nak response) */
|
|
351
|
-
if (receiver !== undefined) {
|
|
352
|
-
const error = ensureError(err).message;
|
|
362
|
+
if (receiver !== undefined && !remoteError) {
|
|
353
363
|
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);
|
|
364
|
+
const chunkMsg = this.msg.makeSinkPushChunk(requestId, name, undefined, error.message, true, this.options.id, receiver);
|
|
355
365
|
const message = this.codec.encode(chunkMsg);
|
|
356
366
|
await this.publishToTopic(chunkTopic, message, { qos: 2, ...options }).catch(() => { });
|
|
357
367
|
}
|
|
@@ -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
|
+
private sourceControllers;
|
|
9
10
|
destroy(): Promise<void>;
|
|
10
11
|
source<K extends SourceKeys<T> & string>(name: K, callback: WithInfo<T[K], InfoSource>): Promise<Registration>;
|
|
11
12
|
source<K extends SourceKeys<T> & string>(config: {
|
|
@@ -34,9 +34,13 @@ export class SourceTrait extends ServiceTrait {
|
|
|
34
34
|
super(...arguments);
|
|
35
35
|
/* source state */
|
|
36
36
|
this.sourceCreditGates = new Map();
|
|
37
|
+
this.sourceControllers = new Map();
|
|
37
38
|
}
|
|
38
39
|
/* destroy source trait */
|
|
39
40
|
async destroy() {
|
|
41
|
+
for (const controller of this.sourceControllers.values())
|
|
42
|
+
controller.abort(new Error("source destroyed"));
|
|
43
|
+
this.sourceControllers.clear();
|
|
40
44
|
for (const gate of this.sourceCreditGates.values())
|
|
41
45
|
gate.abort();
|
|
42
46
|
this.sourceCreditGates.clear();
|
|
@@ -96,12 +100,27 @@ export class SourceTrait extends ServiceTrait {
|
|
|
96
100
|
const message = this.codec.encode(response);
|
|
97
101
|
await this.publishToTopic(responseTopic, message, { qos: options.qos ?? 2 });
|
|
98
102
|
};
|
|
103
|
+
/* define abort controller and signal */
|
|
104
|
+
const abortController = new AbortController();
|
|
105
|
+
this.sourceControllers.set(requestId, abortController);
|
|
106
|
+
const abortSignal = abortController.signal;
|
|
107
|
+
/* ensure stream gets destroyed on abort */
|
|
108
|
+
abortSignal.addEventListener("abort", () => {
|
|
109
|
+
if (info.stream instanceof Readable && !info.stream.destroyed)
|
|
110
|
+
info.stream.destroy(ensureError(abortSignal.reason));
|
|
111
|
+
}, { once: true });
|
|
99
112
|
/* utility functions for timeout management */
|
|
100
113
|
const sourceTimerId = `source-fetch-send:${requestId}`;
|
|
101
114
|
const refreshSourceTimeout = () => this.timerRefresh(sourceTimerId, () => {
|
|
115
|
+
const error = new Error(`source fetch "${name}" timed out`);
|
|
116
|
+
abortController.abort(error);
|
|
102
117
|
const gate = this.sourceCreditGates.get(requestId);
|
|
103
|
-
if (gate !== undefined)
|
|
118
|
+
if (gate !== undefined) {
|
|
104
119
|
gate.abort();
|
|
120
|
+
this.sourceCreditGates.delete(requestId);
|
|
121
|
+
}
|
|
122
|
+
this.sourceControllers.delete(requestId);
|
|
123
|
+
this.onResponse.delete(`source-fetch-credit:${requestId}`);
|
|
105
124
|
});
|
|
106
125
|
const clearSourceTimeout = () => this.timerClear(sourceTimerId);
|
|
107
126
|
refreshSourceTimeout();
|
|
@@ -112,19 +131,9 @@ export class SourceTrait extends ServiceTrait {
|
|
|
112
131
|
const message = this.codec.encode(chunkMsg);
|
|
113
132
|
await this.publishToTopic(chunkTopic, message, { qos: options.qos ?? 2 });
|
|
114
133
|
};
|
|
115
|
-
/* handle credit-based flow control (if credit provided in request) */
|
|
116
|
-
const initialCredit = request.credit;
|
|
117
|
-
const creditGate = (initialCredit !== undefined && initialCredit > 0)
|
|
118
|
-
? new CreditGate(initialCredit) : undefined;
|
|
119
|
-
if (creditGate) {
|
|
120
|
-
this.sourceCreditGates.set(requestId, creditGate);
|
|
121
|
-
this.onResponse.set(`source-fetch-credit:${requestId}`, (creditParsed) => {
|
|
122
|
-
creditGate.replenish(creditParsed.credit);
|
|
123
|
-
refreshSourceTimeout();
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
134
|
/* call the handler callback */
|
|
127
135
|
let ackSent = false;
|
|
136
|
+
let creditGate;
|
|
128
137
|
try {
|
|
129
138
|
if (topicName !== request.name)
|
|
130
139
|
throw new Error(`source name mismatch (topic: "${topicName}", payload: "${request.name}")`);
|
|
@@ -132,6 +141,18 @@ export class SourceTrait extends ServiceTrait {
|
|
|
132
141
|
info.authenticated = await this.authenticated(request.sender, request.auth, auth);
|
|
133
142
|
if (info.authenticated !== undefined && !info.authenticated)
|
|
134
143
|
throw new Error(`source "${name}" failed authentication`);
|
|
144
|
+
/* handle credit-based flow control (if credit provided in request) */
|
|
145
|
+
const initialCredit = request.credit;
|
|
146
|
+
creditGate = (initialCredit !== undefined && initialCredit > 0)
|
|
147
|
+
? new CreditGate(initialCredit) : undefined;
|
|
148
|
+
if (creditGate) {
|
|
149
|
+
const gate = creditGate;
|
|
150
|
+
this.sourceCreditGates.set(requestId, gate);
|
|
151
|
+
this.onResponse.set(`source-fetch-credit:${requestId}`, (creditParsed) => {
|
|
152
|
+
gate.replenish(creditParsed.credit);
|
|
153
|
+
refreshSourceTimeout();
|
|
154
|
+
});
|
|
155
|
+
}
|
|
135
156
|
await callback(...params, info);
|
|
136
157
|
/* check for valid data source */
|
|
137
158
|
if (!(info.stream instanceof Readable) && !(info.buffer instanceof Promise))
|
|
@@ -144,15 +165,17 @@ export class SourceTrait extends ServiceTrait {
|
|
|
144
165
|
/* dispatch according to data type */
|
|
145
166
|
if (info.stream instanceof Readable)
|
|
146
167
|
/* handle Readable stream result */
|
|
147
|
-
await sendStreamAsChunks(info.stream, this.options.chunkSize, sendChunk, creditGate);
|
|
168
|
+
await sendStreamAsChunks(info.stream, this.options.chunkSize, sendChunk, creditGate, abortSignal);
|
|
148
169
|
else if (info.buffer instanceof Promise)
|
|
149
170
|
/* handle Buffer result */
|
|
150
|
-
await sendBufferAsChunks(await info.buffer, this.options.chunkSize, sendChunk, creditGate);
|
|
171
|
+
await sendBufferAsChunks(await info.buffer, this.options.chunkSize, sendChunk, creditGate, abortSignal);
|
|
151
172
|
}
|
|
152
173
|
catch (err) {
|
|
174
|
+
/* cleanup stream resource (if provided by handler) */
|
|
175
|
+
const error = ensureError(err, `handler for source "${name}" failed`);
|
|
176
|
+
abortController.abort(error);
|
|
153
177
|
/* send error as nak response or as error chunk */
|
|
154
|
-
|
|
155
|
-
this.error(error, `handler for source "${name}" failed`);
|
|
178
|
+
this.error(error);
|
|
156
179
|
if (ackSent)
|
|
157
180
|
await sendChunk(undefined, error.message, true).catch(() => { });
|
|
158
181
|
else
|
|
@@ -165,6 +188,7 @@ export class SourceTrait extends ServiceTrait {
|
|
|
165
188
|
creditGate.abort();
|
|
166
189
|
this.sourceCreditGates.delete(requestId);
|
|
167
190
|
}
|
|
191
|
+
this.sourceControllers.delete(requestId);
|
|
168
192
|
this.onResponse.delete(`source-fetch-credit:${requestId}`);
|
|
169
193
|
}
|
|
170
194
|
});
|
|
@@ -233,11 +257,13 @@ export class SourceTrait extends ServiceTrait {
|
|
|
233
257
|
this.publishToTopic(creditTopic, encoded, { qos: options.qos ?? 2 }).catch((err) => {
|
|
234
258
|
this.error(err, `sending credit for fetch "${name}" failed`);
|
|
235
259
|
});
|
|
260
|
+
refreshTimeout();
|
|
236
261
|
}
|
|
237
262
|
}
|
|
238
263
|
});
|
|
239
264
|
/* create promise for collecting stream chunks */
|
|
240
265
|
const buffer = streamToBuffer(stream);
|
|
266
|
+
buffer.catch(() => { }); /* avoid unhandled promise rejection */
|
|
241
267
|
/* create promise for meta (resolved on first chunk) */
|
|
242
268
|
let metaResolve;
|
|
243
269
|
const metaP = new Promise((resolve) => {
|
|
@@ -267,13 +293,14 @@ export class SourceTrait extends ServiceTrait {
|
|
|
267
293
|
}
|
|
268
294
|
if (response.sender)
|
|
269
295
|
serverId = response.sender;
|
|
270
|
-
metaResolve(response.meta);
|
|
271
296
|
if (response.error) {
|
|
272
297
|
stream.destroy(new Error(response.error));
|
|
273
298
|
spool.unroll();
|
|
274
299
|
}
|
|
275
|
-
else
|
|
300
|
+
else {
|
|
301
|
+
metaResolve(response.meta);
|
|
276
302
|
refreshTimeout();
|
|
303
|
+
}
|
|
277
304
|
});
|
|
278
305
|
/* register chunk dispatch callback */
|
|
279
306
|
this.onResponse.set(`source-fetch-chunk:${requestId}`, (response) => {
|
|
@@ -143,15 +143,14 @@ class RefCountedSubscription {
|
|
|
143
143
|
...this.unsubbing.keys()
|
|
144
144
|
]);
|
|
145
145
|
/* cancel all pending linger timers first (synchronously) */
|
|
146
|
-
for (const
|
|
147
|
-
clearTimeout(
|
|
146
|
+
for (const timer of this.lingers.values())
|
|
147
|
+
clearTimeout(timer);
|
|
148
148
|
this.lingers.clear();
|
|
149
149
|
this.counts.clear();
|
|
150
150
|
/* wait for any in-flight subscribe/unsubscribe operations to settle first */
|
|
151
151
|
await Promise.allSettled([...this.pending.values(), ...this.unsubbing.values()]);
|
|
152
152
|
/* then unsubscribe from all potentially active topics */
|
|
153
|
-
|
|
154
|
-
await this.unsubscribeFn(topic).catch(() => { });
|
|
153
|
+
await Promise.allSettled([...topics].map((topic) => this.unsubscribeFn(topic).catch(() => { })));
|
|
155
154
|
/* clear remaining internal state */
|
|
156
155
|
this.pending.clear();
|
|
157
156
|
this.unsubbing.clear();
|
|
@@ -3,6 +3,6 @@ import { SubscriptionTrait } from "./mqtt-plus-subscription";
|
|
|
3
3
|
export declare class TimerTrait<T extends APISchema = APISchema> extends SubscriptionTrait<T> {
|
|
4
4
|
private timers;
|
|
5
5
|
destroy(): Promise<void>;
|
|
6
|
-
protected timerRefresh(id: string, onTimeout: () => void): void;
|
|
6
|
+
protected timerRefresh(id: string, onTimeout: () => void | Promise<void>): void;
|
|
7
7
|
protected timerClear(id: string): void;
|
|
8
8
|
}
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
** SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
23
23
|
*/
|
|
24
24
|
import { SubscriptionTrait } from "./mqtt-plus-subscription";
|
|
25
|
+
import { ensureError } from "./mqtt-plus-error";
|
|
25
26
|
/* Timer trait with reusable timer management */
|
|
26
27
|
export class TimerTrait extends SubscriptionTrait {
|
|
27
28
|
constructor() {
|
|
@@ -41,9 +42,14 @@ export class TimerTrait extends SubscriptionTrait {
|
|
|
41
42
|
const timer = this.timers.get(id);
|
|
42
43
|
if (timer !== undefined)
|
|
43
44
|
clearTimeout(timer);
|
|
44
|
-
this.timers.set(id, setTimeout(() => {
|
|
45
|
+
this.timers.set(id, setTimeout(async () => {
|
|
45
46
|
this.timers.delete(id);
|
|
46
|
-
|
|
47
|
+
try {
|
|
48
|
+
await onTimeout();
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
this.error(ensureError(err), `timer "${id}" failed`);
|
|
52
|
+
}
|
|
47
53
|
}, this.options.timeout));
|
|
48
54
|
}
|
|
49
55
|
/* clear a named timer */
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
import { Buffer } from "node:buffer";
|
|
26
26
|
/* external requirements */
|
|
27
27
|
import PLazyAPI from "p-lazy";
|
|
28
|
-
/* workaround for ESM-only module "
|
|
29
|
-
of MQTT+'s CJS
|
|
28
|
+
/* workaround for ESM-only module "p-lazy" which, when used in the context
|
|
29
|
+
of MQTT+'s CJS build (e.g. inside test suite), exports via "default" */
|
|
30
30
|
export const PLazy = (PLazyAPI.default ?? PLazyAPI);
|
|
31
31
|
/* credit-based flow control gate for chunk producers */
|
|
32
32
|
export class CreditGate {
|
|
@@ -49,7 +49,9 @@ export class CreditGate {
|
|
|
49
49
|
/* wait for credit to be replenished */
|
|
50
50
|
await new Promise((resolve, reject) => {
|
|
51
51
|
const onAbort = () => {
|
|
52
|
-
this.waiters.
|
|
52
|
+
const idx = this.waiters.indexOf(waiter);
|
|
53
|
+
if (idx !== -1)
|
|
54
|
+
this.waiters.splice(idx, 1);
|
|
53
55
|
reject(abortSignal?.reason ?? new Error("aborted"));
|
|
54
56
|
};
|
|
55
57
|
if (abortSignal)
|