mqtt-plus 1.4.8 → 1.4.10
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 +28 -0
- package/dst-stage1/mqtt-plus-auth.js +14 -5
- package/dst-stage1/mqtt-plus-base.d.ts +2 -1
- package/dst-stage1/mqtt-plus-base.js +14 -5
- package/dst-stage1/mqtt-plus-error.js +2 -2
- package/dst-stage1/mqtt-plus-event.js +12 -11
- package/dst-stage1/mqtt-plus-service.js +27 -21
- package/dst-stage1/mqtt-plus-sink.js +44 -29
- package/dst-stage1/mqtt-plus-source.js +56 -34
- package/dst-stage1/mqtt-plus-subscription.js +15 -6
- package/dst-stage1/mqtt-plus-util.js +6 -4
- package/dst-stage1/mqtt-plus-version.js +1 -1
- package/dst-stage1/mqtt-plus.d.ts +1 -1
- package/dst-stage1/mqtt-plus.js +1 -0
- package/dst-stage2/mqtt-plus.cjs.js +176 -126
- package/dst-stage2/mqtt-plus.esm.js +175 -126
- package/dst-stage2/mqtt-plus.umd.js +12 -12
- package/package.json +5 -9
- package/src/mqtt-plus-auth.ts +15 -5
- package/src/mqtt-plus-base.ts +21 -7
- package/src/mqtt-plus-error.ts +2 -2
- package/src/mqtt-plus-event.ts +12 -13
- package/src/mqtt-plus-service.ts +31 -25
- package/src/mqtt-plus-sink.ts +49 -34
- package/src/mqtt-plus-source.ts +63 -39
- package/src/mqtt-plus-subscription.ts +18 -6
- package/src/mqtt-plus-util.ts +8 -5
- package/src/mqtt-plus-version.ts +1 -1
- package/src/mqtt-plus.ts +1 -1
- package/tst/mqtt-plus-0-broker.ts +2 -2
- package/tst/mqtt-plus-5-source.spec.ts +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,34 @@
|
|
|
2
2
|
ChangeLog
|
|
3
3
|
=========
|
|
4
4
|
|
|
5
|
+
1.4.10 (2026-03-01)
|
|
6
|
+
-------------------
|
|
7
|
+
|
|
8
|
+
- IMPROVEMENT: improve performance
|
|
9
|
+
- IMPROVEMENT: improve typing and export more public API types
|
|
10
|
+
- IMPROVEMENT: improve description
|
|
11
|
+
- BUGFIX: fix error handling and destruction problems
|
|
12
|
+
- BUGFIX: fix name of module
|
|
13
|
+
- BUGFIX: do not make fields exclusive
|
|
14
|
+
- UPDATE: upgrade NPM dependencies
|
|
15
|
+
- CLEANUP: various code cleanups (simplification, formatting, comments, output polishing)
|
|
16
|
+
- CLEANUP: cleanups for error handling
|
|
17
|
+
|
|
18
|
+
1.4.9 (2026-02-22)
|
|
19
|
+
------------------
|
|
20
|
+
|
|
21
|
+
- BUGFIX: clear internal response handlers in destroy()
|
|
22
|
+
- BUGFIX: correctly decrement counter in subscription handling
|
|
23
|
+
- BUGFIX: let the registration's destroy() throw errors correctly
|
|
24
|
+
- BUGFIX: correctly handle synchronous response handler failures
|
|
25
|
+
- BUGFIX: fix internal chunkToBuffer() method for byte-length calculation
|
|
26
|
+
- BUGFIX: apply the same limits on sender size for authenticate() as on receiver side
|
|
27
|
+
- BUGFIX: check for name/topic mismatches also in source fetch()
|
|
28
|
+
- REFACTOR: factor out topic subscription and spooling topic unsubscription into helper function
|
|
29
|
+
- REFACTOR: make response handlers async functions to correctly catch their failures
|
|
30
|
+
- IMPROVEMENT: use a cached TextEncoder in utility functions
|
|
31
|
+
- IMPROVEMENT: ensure generated NanoIDs do not conflict with pending requests
|
|
32
|
+
|
|
5
33
|
1.4.8 (2026-02-22)
|
|
6
34
|
------------------
|
|
7
35
|
|
|
@@ -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,8 +43,8 @@ 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 =
|
|
45
|
-
const salt =
|
|
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 */
|
|
@@ -59,12 +61,19 @@ export class AuthTrait extends MetaTrait {
|
|
|
59
61
|
return token;
|
|
60
62
|
}
|
|
61
63
|
authenticate(token, remove) {
|
|
62
|
-
if (token === undefined)
|
|
63
|
-
|
|
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
|
+
}
|
|
64
68
|
else if (remove === true)
|
|
65
69
|
this._tokens.delete(token);
|
|
66
|
-
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");
|
|
67
75
|
this._tokens.add(token);
|
|
76
|
+
}
|
|
68
77
|
}
|
|
69
78
|
/* validate client-side token on server-side */
|
|
70
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
|
|
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
|
-
|
|
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,
|
|
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
|
}
|
|
@@ -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 {
|
|
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) {
|
|
@@ -56,36 +56,37 @@ export class EventTrait extends AuthTrait {
|
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
}
|
|
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
|
|
86
|
-
|
|
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
|
}
|
|
@@ -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
|
-
/*
|
|
74
|
-
|
|
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
|
-
|
|
82
|
-
}).then((result) => {
|
|
81
|
+
const result = await callback(...params, info);
|
|
83
82
|
/* create success response message */
|
|
84
|
-
|
|
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
|
-
|
|
95
|
-
}
|
|
96
|
-
|
|
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), `sending error response 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
|
|
102
|
-
|
|
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
|
-
|
|
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 }));
|
|
@@ -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();
|
|
@@ -77,7 +77,7 @@ export class SinkTrait extends SourceTrait {
|
|
|
77
77
|
const topicReqD = this.options.topicMake(name, "sink-push-request", this.options.id);
|
|
78
78
|
const topicChunkD = this.options.topicMake(name, "sink-push-chunk", this.options.id);
|
|
79
79
|
/* remember the registration */
|
|
80
|
-
this.onRequest.set(`sink-push-request:${name}`, (request, topicName) => {
|
|
80
|
+
this.onRequest.set(`sink-push-request:${name}`, async (request, topicName) => {
|
|
81
81
|
/* determine information */
|
|
82
82
|
const requestId = request.id;
|
|
83
83
|
const params = request.params ?? [];
|
|
@@ -100,7 +100,8 @@ export class SinkTrait extends SourceTrait {
|
|
|
100
100
|
this.pushSpools.set(requestId, reqSpool);
|
|
101
101
|
reqSpool.roll(() => { this.pushSpools.delete(requestId); });
|
|
102
102
|
/* check authentication and prepare stream */
|
|
103
|
-
|
|
103
|
+
let ackSent = false;
|
|
104
|
+
try {
|
|
104
105
|
if (topicName !== request.name)
|
|
105
106
|
throw new Error(`sink name mismatch (topic: "${topicName}", payload: "${request.name}")`);
|
|
106
107
|
let authenticated = undefined;
|
|
@@ -148,8 +149,12 @@ export class SinkTrait extends SourceTrait {
|
|
|
148
149
|
readable.once("error", () => reqSpool.unroll());
|
|
149
150
|
/* register chunk dispatch callback */
|
|
150
151
|
this.onResponse.set(`sink-push-chunk:${requestId}`, (chunkParsed, chunkTopicName) => {
|
|
151
|
-
if (chunkTopicName !== chunkParsed.name)
|
|
152
|
-
|
|
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
|
+
}
|
|
153
158
|
if (chunkParsed.error !== undefined) {
|
|
154
159
|
readable.destroy(new Error(chunkParsed.error));
|
|
155
160
|
reqSpool.unroll();
|
|
@@ -187,27 +192,33 @@ export class SinkTrait extends SourceTrait {
|
|
|
187
192
|
makeMutuallyExclusiveFields(info, "stream", "buffer");
|
|
188
193
|
/* send ack response */
|
|
189
194
|
await sendResponse();
|
|
195
|
+
ackSent = true;
|
|
190
196
|
/* call handler */
|
|
191
|
-
|
|
192
|
-
}
|
|
197
|
+
await callback(...params, info);
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
const error = ensureError(err, `handler for sink "${name}" failed`);
|
|
193
201
|
/* cleanup resources */
|
|
194
202
|
const stream = this.pushStreams.get(requestId);
|
|
195
203
|
if (stream !== undefined)
|
|
196
|
-
stream.destroy(
|
|
197
|
-
reqSpool.unroll();
|
|
198
|
-
/* send error
|
|
199
|
-
this.error(
|
|
200
|
-
|
|
201
|
-
|
|
204
|
+
stream.destroy(error);
|
|
205
|
+
await reqSpool.unroll();
|
|
206
|
+
/* send error as nak response or as mid-stream error response */
|
|
207
|
+
this.error(error);
|
|
208
|
+
if (ackSent) {
|
|
209
|
+
const responseMsg = this.msg.makeSinkPushResponse(requestId, name, error.message, this.options.id, sender);
|
|
210
|
+
const message = this.codec.encode(responseMsg);
|
|
211
|
+
await this.publishToTopic(responseTopic, message, { qos: options.qos ?? 2 }).catch(() => { });
|
|
212
|
+
}
|
|
213
|
+
else
|
|
214
|
+
await sendResponse(error.message).catch(() => { });
|
|
215
|
+
}
|
|
202
216
|
});
|
|
203
217
|
spool.roll(() => { this.onRequest.delete(`sink-push-request:${name}`); });
|
|
204
218
|
/* subscribe to MQTT topics */
|
|
205
|
-
await
|
|
206
|
-
|
|
207
|
-
await
|
|
208
|
-
spool.roll(() => this.unsubscribeTopic(topicReqD).catch(() => { }));
|
|
209
|
-
await run(`subscribe to MQTT topic "${topicChunkD}"`, spool, () => this.subscribeTopic(topicChunkD, { qos: 2, ...options }));
|
|
210
|
-
spool.roll(() => this.unsubscribeTopic(topicChunkD).catch(() => { }));
|
|
219
|
+
await this.subscribeTopicAndSpool(spool, topicReqB, options);
|
|
220
|
+
await this.subscribeTopicAndSpool(spool, topicReqD, options);
|
|
221
|
+
await this.subscribeTopicAndSpool(spool, topicChunkD, options);
|
|
211
222
|
/* provide a registration for subsequent destruction */
|
|
212
223
|
return this.makeRegistration(spool, "sink", name, `sink-push-request:${name}`);
|
|
213
224
|
}
|
|
@@ -240,7 +251,10 @@ export class SinkTrait extends SourceTrait {
|
|
|
240
251
|
/* create a resource spool */
|
|
241
252
|
const spool = new Spool();
|
|
242
253
|
/* generate unique request id */
|
|
243
|
-
|
|
254
|
+
let requestId = nanoid();
|
|
255
|
+
while (this.onResponse.has(`sink-push-response:${requestId}`)
|
|
256
|
+
|| this.onResponse.has(`sink-push-credit:${requestId}`))
|
|
257
|
+
requestId = nanoid();
|
|
244
258
|
/* subscribe to response topic (for ack/nak) */
|
|
245
259
|
const responseTopic = this.options.topicMake(name, "sink-push-response", this.options.id);
|
|
246
260
|
await run(`subscribe to MQTT topic "${responseTopic}"`, spool, () => this.subscriptions.subscribe(responseTopic, { qos: options.qos ?? 2 }));
|
|
@@ -278,10 +292,6 @@ export class SinkTrait extends SourceTrait {
|
|
|
278
292
|
}
|
|
279
293
|
});
|
|
280
294
|
spool.roll(() => { this.onResponse.delete(`sink-push-response:${requestId}`); });
|
|
281
|
-
this.onResponse.set(`sink-push-credit:${requestId}`, (_response) => {
|
|
282
|
-
refreshTimeout();
|
|
283
|
-
});
|
|
284
|
-
spool.roll(() => { this.onResponse.delete(`sink-push-credit:${requestId}`); });
|
|
285
295
|
/* generate and send request message */
|
|
286
296
|
const auth = this.authenticate();
|
|
287
297
|
const metaStore = this.metaStore(meta);
|
|
@@ -312,6 +322,7 @@ export class SinkTrait extends SourceTrait {
|
|
|
312
322
|
gate.replenish(response.credit);
|
|
313
323
|
refreshTimeout();
|
|
314
324
|
});
|
|
325
|
+
spool.roll(() => { this.onResponse.delete(`sink-push-credit:${requestId}`); });
|
|
315
326
|
}
|
|
316
327
|
/* generate corresponding MQTT topic for chunks */
|
|
317
328
|
const chunkTopic = this.options.topicMake(name, "sink-push-chunk", receiver);
|
|
@@ -331,11 +342,15 @@ export class SinkTrait extends SourceTrait {
|
|
|
331
342
|
await sendBufferAsChunks(data, this.options.chunkSize, sendChunk, creditGate, abortSignal);
|
|
332
343
|
}
|
|
333
344
|
catch (err) {
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
345
|
+
/* send error chunk only if receiver is known
|
|
346
|
+
(otherwise the sink already received the error via the nak response) */
|
|
347
|
+
if (receiver !== undefined) {
|
|
348
|
+
const error = ensureError(err).message;
|
|
349
|
+
const chunkTopic = this.options.topicMake(name, "sink-push-chunk", receiver);
|
|
350
|
+
const chunkMsg = this.msg.makeSinkPushChunk(requestId, name, undefined, error, true, this.options.id, receiver);
|
|
351
|
+
const message = this.codec.encode(chunkMsg);
|
|
352
|
+
await this.publishToTopic(chunkTopic, message, { qos: 2, ...options }).catch(() => { });
|
|
353
|
+
}
|
|
339
354
|
throw err;
|
|
340
355
|
}
|
|
341
356
|
finally {
|
|
@@ -73,7 +73,7 @@ export class SourceTrait extends ServiceTrait {
|
|
|
73
73
|
const topicReqD = this.options.topicMake(name, "source-fetch-request", this.options.id);
|
|
74
74
|
const topicCreditD = this.options.topicMake(name, "source-fetch-credit", this.options.id);
|
|
75
75
|
/* remember the registration */
|
|
76
|
-
this.onRequest.set(`source-fetch-request:${name}`, (request, topicName) => {
|
|
76
|
+
this.onRequest.set(`source-fetch-request:${name}`, async (request, topicName) => {
|
|
77
77
|
/* determine information */
|
|
78
78
|
const requestId = request.id;
|
|
79
79
|
const params = request.params ?? [];
|
|
@@ -96,9 +96,13 @@ export class SourceTrait extends ServiceTrait {
|
|
|
96
96
|
const message = this.codec.encode(response);
|
|
97
97
|
await this.publishToTopic(responseTopic, message, { qos: options.qos ?? 2 });
|
|
98
98
|
};
|
|
99
|
+
/* define abort controller and signal */
|
|
100
|
+
const abortController = new AbortController();
|
|
101
|
+
const abortSignal = abortController.signal;
|
|
99
102
|
/* utility functions for timeout management */
|
|
100
103
|
const sourceTimerId = `source-fetch-send:${requestId}`;
|
|
101
104
|
const refreshSourceTimeout = () => this.timerRefresh(sourceTimerId, () => {
|
|
105
|
+
abortController.abort(new Error(`source fetch "${name}" timed out`));
|
|
102
106
|
const gate = this.sourceCreditGates.get(requestId);
|
|
103
107
|
if (gate !== undefined)
|
|
104
108
|
gate.abort();
|
|
@@ -112,28 +116,29 @@ export class SourceTrait extends ServiceTrait {
|
|
|
112
116
|
const message = this.codec.encode(chunkMsg);
|
|
113
117
|
await this.publishToTopic(chunkTopic, message, { qos: options.qos ?? 2 });
|
|
114
118
|
};
|
|
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
119
|
/* call the handler callback */
|
|
127
120
|
let ackSent = false;
|
|
128
|
-
|
|
121
|
+
let creditGate;
|
|
122
|
+
try {
|
|
129
123
|
if (topicName !== request.name)
|
|
130
124
|
throw new Error(`source name mismatch (topic: "${topicName}", payload: "${request.name}")`);
|
|
131
125
|
if (auth)
|
|
132
126
|
info.authenticated = await this.authenticated(request.sender, request.auth, auth);
|
|
133
127
|
if (info.authenticated !== undefined && !info.authenticated)
|
|
134
128
|
throw new Error(`source "${name}" failed authentication`);
|
|
135
|
-
|
|
136
|
-
|
|
129
|
+
/* handle credit-based flow control (if credit provided in request) */
|
|
130
|
+
const initialCredit = request.credit;
|
|
131
|
+
creditGate = (initialCredit !== undefined && initialCredit > 0)
|
|
132
|
+
? new CreditGate(initialCredit) : undefined;
|
|
133
|
+
if (creditGate) {
|
|
134
|
+
const gate = creditGate;
|
|
135
|
+
this.sourceCreditGates.set(requestId, gate);
|
|
136
|
+
this.onResponse.set(`source-fetch-credit:${requestId}`, (creditParsed) => {
|
|
137
|
+
gate.replenish(creditParsed.credit);
|
|
138
|
+
refreshSourceTimeout();
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
await callback(...params, info);
|
|
137
142
|
/* check for valid data source */
|
|
138
143
|
if (!(info.stream instanceof Readable) && !(info.buffer instanceof Promise))
|
|
139
144
|
throw new Error("handler did not provide data via info.stream or info.buffer fields");
|
|
@@ -145,19 +150,24 @@ export class SourceTrait extends ServiceTrait {
|
|
|
145
150
|
/* dispatch according to data type */
|
|
146
151
|
if (info.stream instanceof Readable)
|
|
147
152
|
/* handle Readable stream result */
|
|
148
|
-
await sendStreamAsChunks(info.stream, this.options.chunkSize, sendChunk, creditGate);
|
|
153
|
+
await sendStreamAsChunks(info.stream, this.options.chunkSize, sendChunk, creditGate, abortSignal);
|
|
149
154
|
else if (info.buffer instanceof Promise)
|
|
150
155
|
/* handle Buffer result */
|
|
151
|
-
await sendBufferAsChunks(await info.buffer, this.options.chunkSize, sendChunk, creditGate);
|
|
152
|
-
}
|
|
156
|
+
await sendBufferAsChunks(await info.buffer, this.options.chunkSize, sendChunk, creditGate, abortSignal);
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
/* cleanup stream resource (if provided by handler) */
|
|
160
|
+
const error = ensureError(err, `handler for source "${name}" failed`);
|
|
161
|
+
if (info.stream instanceof Readable && !info.stream.destroyed)
|
|
162
|
+
info.stream.destroy(error);
|
|
153
163
|
/* send error as nak response or as error chunk */
|
|
154
|
-
|
|
155
|
-
this.error(error, `handler for source "${name}" failed`);
|
|
164
|
+
this.error(error);
|
|
156
165
|
if (ackSent)
|
|
157
|
-
|
|
166
|
+
await sendChunk(undefined, error.message, true).catch(() => { });
|
|
158
167
|
else
|
|
159
|
-
|
|
160
|
-
}
|
|
168
|
+
await sendResponse(error.message).catch(() => { });
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
161
171
|
/* cleanup resources */
|
|
162
172
|
clearSourceTimeout();
|
|
163
173
|
if (creditGate) {
|
|
@@ -165,16 +175,13 @@ export class SourceTrait extends ServiceTrait {
|
|
|
165
175
|
this.sourceCreditGates.delete(requestId);
|
|
166
176
|
}
|
|
167
177
|
this.onResponse.delete(`source-fetch-credit:${requestId}`);
|
|
168
|
-
}
|
|
178
|
+
}
|
|
169
179
|
});
|
|
170
180
|
spool.roll(() => { this.onRequest.delete(`source-fetch-request:${name}`); });
|
|
171
181
|
/* subscribe to MQTT topics */
|
|
172
|
-
await
|
|
173
|
-
|
|
174
|
-
await
|
|
175
|
-
spool.roll(() => this.unsubscribeTopic(topicReqD).catch(() => { }));
|
|
176
|
-
await run(`subscribe to MQTT topic "${topicCreditD}"`, spool, () => this.subscribeTopic(topicCreditD, { qos: 2, ...options }));
|
|
177
|
-
spool.roll(() => this.unsubscribeTopic(topicCreditD).catch(() => { }));
|
|
182
|
+
await this.subscribeTopicAndSpool(spool, topicReqB, options);
|
|
183
|
+
await this.subscribeTopicAndSpool(spool, topicReqD, options);
|
|
184
|
+
await this.subscribeTopicAndSpool(spool, topicCreditD, options);
|
|
178
185
|
/* provide a registration for subsequent destruction */
|
|
179
186
|
return this.makeRegistration(spool, "source", name, `source-fetch-request:${name}`);
|
|
180
187
|
}
|
|
@@ -201,7 +208,10 @@ export class SourceTrait extends ServiceTrait {
|
|
|
201
208
|
/* create a resource spool */
|
|
202
209
|
const spool = new Spool();
|
|
203
210
|
/* generate unique request id */
|
|
204
|
-
|
|
211
|
+
let requestId = nanoid();
|
|
212
|
+
while (this.onResponse.has(`source-fetch-response:${requestId}`)
|
|
213
|
+
|| this.onResponse.has(`source-fetch-chunk:${requestId}`))
|
|
214
|
+
requestId = nanoid();
|
|
205
215
|
/* subscribe to response topic (for ack/nak) and chunk topic (for data) */
|
|
206
216
|
const responseTopic = this.options.topicMake(name, "source-fetch-response", this.options.id);
|
|
207
217
|
const chunkTopic = this.options.topicMake(name, "source-fetch-chunk", this.options.id);
|
|
@@ -232,6 +242,7 @@ export class SourceTrait extends ServiceTrait {
|
|
|
232
242
|
this.publishToTopic(creditTopic, encoded, { qos: options.qos ?? 2 }).catch((err) => {
|
|
233
243
|
this.error(err, `sending credit for fetch "${name}" failed`);
|
|
234
244
|
});
|
|
245
|
+
refreshTimeout();
|
|
235
246
|
}
|
|
236
247
|
}
|
|
237
248
|
});
|
|
@@ -242,7 +253,7 @@ export class SourceTrait extends ServiceTrait {
|
|
|
242
253
|
const metaP = new Promise((resolve) => {
|
|
243
254
|
metaResolve = resolve;
|
|
244
255
|
});
|
|
245
|
-
spool.roll(() => { metaResolve
|
|
256
|
+
spool.roll(() => { metaResolve(undefined); });
|
|
246
257
|
/* define timer */
|
|
247
258
|
const timerId = `source-fetch:${requestId}`;
|
|
248
259
|
const refreshTimeout = () => {
|
|
@@ -259,18 +270,29 @@ export class SourceTrait extends ServiceTrait {
|
|
|
259
270
|
stream.once("error", () => spool.unroll());
|
|
260
271
|
/* register response dispatch callback */
|
|
261
272
|
this.onResponse.set(`source-fetch-response:${requestId}`, (response) => {
|
|
273
|
+
if (response.name !== name) {
|
|
274
|
+
stream.destroy(new Error(`source name mismatch (expected "${name}", got "${response.name}")`));
|
|
275
|
+
spool.unroll();
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
262
278
|
if (response.sender)
|
|
263
279
|
serverId = response.sender;
|
|
264
|
-
metaResolve?.(response.meta);
|
|
265
280
|
if (response.error) {
|
|
266
281
|
stream.destroy(new Error(response.error));
|
|
267
282
|
spool.unroll();
|
|
268
283
|
}
|
|
269
|
-
else
|
|
284
|
+
else {
|
|
285
|
+
metaResolve(response.meta);
|
|
270
286
|
refreshTimeout();
|
|
287
|
+
}
|
|
271
288
|
});
|
|
272
289
|
/* register chunk dispatch callback */
|
|
273
290
|
this.onResponse.set(`source-fetch-chunk:${requestId}`, (response) => {
|
|
291
|
+
if (response.name !== name) {
|
|
292
|
+
stream.destroy(new Error(`source name mismatch (expected "${name}", got "${response.name}")`));
|
|
293
|
+
spool.unroll();
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
274
296
|
if (response.sender)
|
|
275
297
|
serverId = response.sender;
|
|
276
298
|
if (response.error) {
|